diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift new file mode 100644 index 00000000..470d23dd --- /dev/null +++ b/bitchat/Features/media/ImageUtils.swift @@ -0,0 +1,94 @@ +import Foundation +#if os(iOS) +import UIKit +#else +import AppKit +#endif + +enum ImageUtilsError: Error { + case invalidImage + case encodingFailed +} + +enum ImageUtils { + private static let compressionQuality: CGFloat = 0.85 + + static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL { + let data = try Data(contentsOf: url) + #if os(iOS) + guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage } + return try processImage(image, maxDimension: maxDimension) + #else + guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage } + return try processImage(image, maxDimension: maxDimension) + #endif + } + + #if os(iOS) + static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL { + let scaled = scaledImage(image, maxDimension: maxDimension) + guard let jpegData = scaled.jpegData(compressionQuality: compressionQuality) else { + throw ImageUtilsError.encodingFailed + } + let outputURL = try makeOutputURL() + try jpegData.write(to: outputURL, options: .atomic) + return outputURL + } + + private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + let size = image.size + let maxSide = max(size.width, size.height) + guard maxSide > maxDimension else { return image } + let scale = maxDimension / maxSide + let newSize = CGSize(width: size.width * scale, height: size.height * scale) + UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0) + image.draw(in: CGRect(origin: .zero, size: newSize)) + let rendered = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return rendered ?? image + } + #else + static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL { + let scaled = scaledImage(image, maxDimension: maxDimension) + guard let tiffData = scaled.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let jpegData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality]) else { + throw ImageUtilsError.encodingFailed + } + let outputURL = try makeOutputURL() + try jpegData.write(to: outputURL, options: .atomic) + return outputURL + } + + private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage { + let size = image.size + let maxSide = max(size.width, size.height) + guard maxSide > maxDimension else { return image } + let scale = maxDimension / maxSide + let newSize = NSSize(width: size.width * scale, height: size.height * scale) + let scaledImage = NSImage(size: newSize) + scaledImage.lockFocus() + image.draw(in: NSRect(origin: .zero, size: newSize), + from: NSRect(origin: .zero, size: size), + operation: .copy, + fraction: 1.0) + scaledImage.unlockFocus() + return scaledImage + } + #endif + + private static func makeOutputURL() throws -> URL { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + let fileName = "img_\(formatter.string(from: Date())).jpg" + + let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + return directory.appendingPathComponent(fileName) + } + + private static func applicationFilesDirectory() throws -> URL { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + return base.appendingPathComponent("files", isDirectory: true) + } +} diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift new file mode 100644 index 00000000..7e823dc1 --- /dev/null +++ b/bitchat/Features/voice/VoiceNotePlaybackController.swift @@ -0,0 +1,168 @@ +import Foundation +import AVFoundation +import BitLogger + +/// Controls playback for a single voice note and coordinates exclusive playback across the app. +final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate { + @Published private(set) var isPlaying: Bool = false + @Published private(set) var currentTime: TimeInterval = 0 + @Published private(set) var duration: TimeInterval = 0 + @Published private(set) var progress: Double = 0 + + private var player: AVAudioPlayer? + private var timer: Timer? + private var url: URL + + init(url: URL) { + self.url = url + super.init() + preparePlayer(for: url) + } + + deinit { + timer?.invalidate() + } + + func replaceURL(_ url: URL) { + guard url != self.url else { return } + stop() + self.url = url + preparePlayer(for: url) + } + + func togglePlayback() { + isPlaying ? pause() : play() + } + + func play() { + guard ensurePlayerReady() else { return } + VoiceNotePlaybackCoordinator.shared.activate(self) + player?.play() + startTimer() + updateProgress() + isPlaying = true + } + + func pause() { + player?.pause() + stopTimer() + updateProgress() + isPlaying = false + } + + func stop() { + player?.stop() + player?.currentTime = 0 + stopTimer() + updateProgress() + isPlaying = false + VoiceNotePlaybackCoordinator.shared.deactivate(self) + } + + func seek(to fraction: Double) { + guard ensurePlayerReady() else { return } + let clamped = max(0, min(1, fraction)) + if let player = player { + player.currentTime = clamped * player.duration + if isPlaying { + player.play() + } + updateProgress() + } + } + + // MARK: - AVAudioPlayerDelegate + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + stopTimer() + updateProgress() + isPlaying = false + VoiceNotePlaybackCoordinator.shared.deactivate(self) + } + + // MARK: - Private Helpers + + private func preparePlayer(for url: URL) { + do { + let player = try AVAudioPlayer(contentsOf: url) + player.delegate = self + player.prepareToPlay() + self.player = player + duration = player.duration + currentTime = player.currentTime + progress = duration > 0 ? currentTime / duration : 0 + } catch { + SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session) + player = nil + duration = 0 + currentTime = 0 + progress = 0 + } + } + + private func ensurePlayerReady() -> Bool { + if player == nil { + preparePlayer(for: url) + } + #if os(iOS) + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers]) + try session.setActive(true, options: []) + } catch { + SecureLogger.error("Failed to activate audio session: \(error)", category: .session) + } + #endif + return player != nil + } + + private func startTimer() { + if timer != nil { return } + timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in + self?.updateProgress() + } + if let timer = timer { + RunLoop.main.add(timer, forMode: .common) + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func updateProgress() { + guard let player = player else { + currentTime = 0 + duration = 0 + progress = 0 + return + } + currentTime = player.currentTime + duration = player.duration + progress = duration > 0 ? currentTime / duration : 0 + } +} + +/// Ensures only one voice note plays at a time. +final class VoiceNotePlaybackCoordinator { + static let shared = VoiceNotePlaybackCoordinator() + + private weak var activeController: VoiceNotePlaybackController? + + private init() {} + + func activate(_ controller: VoiceNotePlaybackController) { + if activeController === controller { + return + } + activeController?.pause() + activeController = controller + } + + func deactivate(_ controller: VoiceNotePlaybackController) { + if activeController === controller { + activeController = nil + } + } +} diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift new file mode 100644 index 00000000..478e1d2a --- /dev/null +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -0,0 +1,169 @@ +import Foundation +import AVFoundation + +/// Manages audio capture for mesh voice notes with predictable encoding settings. +/// Recording runs on an internal serial queue to avoid AVAudioSession contention. +final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { + enum RecorderError: Error { + case microphoneAccessDenied + case recorderInitializationFailed + case recordingInProgress + } + + static let shared = VoiceRecorder() + + private let queue = DispatchQueue(label: "com.bitchat.voice-recorder") + private let paddingInterval: TimeInterval = 0.5 + + private var recorder: AVAudioRecorder? + private var currentURL: URL? + private var stopWorkItem: DispatchWorkItem? + + private override init() { + super.init() + } + + // MARK: - Permissions + + @discardableResult + func requestPermission() async -> Bool { + #if os(iOS) + return await withCheckedContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + #elseif os(macOS) + return await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { granted in + continuation.resume(returning: granted) + } + } + #else + return true + #endif + } + + // MARK: - Recording Lifecycle + + func startRecording() throws -> URL { + try queue.sync { + if recorder?.isRecording == true { + throw RecorderError.recordingInProgress + } + + #if os(iOS) + let session = AVAudioSession.sharedInstance() + guard session.recordPermission == .granted else { + throw RecorderError.microphoneAccessDenied + } + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP] + ) + try session.setActive(true, options: .notifyOthersOnDeactivation) + #endif + #if os(macOS) + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + throw RecorderError.microphoneAccessDenied + } + #endif + + let outputURL = try makeOutputURL() + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 44_100, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 32_000 + ] + + let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings) + audioRecorder.delegate = self + audioRecorder.isMeteringEnabled = true + audioRecorder.prepareToRecord() + audioRecorder.record() + + recorder = audioRecorder + currentURL = outputURL + stopWorkItem?.cancel() + stopWorkItem = nil + return outputURL + } + } + + func stopRecording(completion: @escaping (URL?) -> Void) { + queue.async { [weak self] in + guard let self = self, let recorder = self.recorder, recorder.isRecording else { + completion(self?.currentURL) + return + } + + let item = DispatchWorkItem { [weak self] in + guard let self = self else { return } + recorder.stop() + self.cleanupSession() + let url = self.currentURL + self.recorder = nil + self.currentURL = url + completion(url) + } + self.stopWorkItem = item + self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item) + } + } + + func cancelRecording() { + queue.async { [weak self] in + guard let self = self else { return } + self.stopWorkItem?.cancel() + self.stopWorkItem = nil + if let recorder = self.recorder, recorder.isRecording { + recorder.stop() + } + self.cleanupSession() + if let url = self.currentURL { + try? FileManager.default.removeItem(at: url) + } + self.recorder = nil + self.currentURL = nil + } + } + + // MARK: - Metering + + func currentAveragePower() -> Float { + queue.sync { + recorder?.updateMeters() + return recorder?.averagePower(forChannel: 0) ?? -160 + } + } + + // MARK: - Helpers + + private func makeOutputURL() throws -> URL { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + let fileName = "voice_\(formatter.string(from: Date())).m4a" + + let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true) + try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil) + return baseDirectory.appendingPathComponent(fileName) + } + + private func applicationFilesDirectory() throws -> URL { + #if os(iOS) + return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent("files", isDirectory: true) + #else + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + return base.appendingPathComponent("files", isDirectory: true) + #endif + } + + private func cleanupSession() { + #if os(iOS) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + #endif + } +} diff --git a/bitchat/Features/voice/Waveform.swift b/bitchat/Features/voice/Waveform.swift new file mode 100644 index 00000000..bac92114 --- /dev/null +++ b/bitchat/Features/voice/Waveform.swift @@ -0,0 +1,95 @@ +import AVFoundation +import Foundation +import BitLogger + +/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap. +final class WaveformCache { + static let shared = WaveformCache() + + private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent) + private var cache: [URL: [Float]] = [:] + + private init() {} + + func cachedWaveform(for url: URL) -> [Float]? { + queue.sync { cache[url] } + } + + func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) { + queue.async { [weak self] in + guard let self = self else { return } + if let cached = self.cache[url] { + DispatchQueue.main.async { completion(cached) } + return + } + + guard let computed = self.computeWaveform(url: url, bins: bins) else { + DispatchQueue.main.async { completion([]) } + return + } + + self.queue.async(flags: .barrier) { + self.cache[url] = computed + } + DispatchQueue.main.async { completion(computed) } + } + } + + func purge(url: URL) { + queue.async(flags: .barrier) { [weak self] in + self?.cache.removeValue(forKey: url) + } + } + + func purgeAll() { + queue.async(flags: .barrier) { [weak self] in + self?.cache.removeAll() + } + } + + private func computeWaveform(url: URL, bins: Int) -> [Float]? { + guard bins > 0 else { return nil } + do { + let audioFile = try AVAudioFile(forReading: url) + let length = Int(audioFile.length) + guard length > 0 else { return nil } + + guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else { + return nil + } + try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length)) + guard let channelData = buffer.floatChannelData else { return nil } + + let channelCount = Int(audioFile.processingFormat.channelCount) + let frameLength = Int(buffer.frameLength) + let samplesPerBin = max(1, frameLength / bins) + + var magnitudes: [Float] = Array(repeating: 0, count: bins) + for bin in 0..= end { break } + + var sum: Float = 0 + var sampleCount = 0 + for frame in start.. 0 ? sum / Float(sampleCount) : 0 + } + + if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 { + magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) } + } + return magnitudes + } catch { + SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session) + return nil + } + } +} diff --git a/bitchat/Models/BitchatPacket.swift b/bitchat/Models/BitchatPacket.swift index 2a947eb3..aa64d60d 100644 --- a/bitchat/Models/BitchatPacket.swift +++ b/bitchat/Models/BitchatPacket.swift @@ -22,8 +22,8 @@ struct BitchatPacket: Codable { var signature: Data? var ttl: UInt8 - init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) { - self.version = 1 + init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) { + self.version = version self.type = type self.senderID = senderID self.recipientID = recipientID diff --git a/bitchat/Protocols/BinaryEncodingUtils.swift b/bitchat/Protocols/BinaryEncodingUtils.swift index 799385b0..10c14c99 100644 --- a/bitchat/Protocols/BinaryEncodingUtils.swift +++ b/bitchat/Protocols/BinaryEncodingUtils.swift @@ -6,6 +6,7 @@ // import Foundation +import CryptoKit // MARK: - Hex Encoding/Decoding @@ -16,6 +17,11 @@ extension Data { } return self.map { String(format: "%02x", $0) }.joined() } + + func sha256Hex() -> String { + let digest = SHA256.hash(data: self) + return digest.map { String(format: "%02x", $0) }.joined() + } init?(hexString: String) { let len = hexString.count / 2 diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index 390ec6ef..5574946b 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -22,11 +22,11 @@ /// /// ## Wire Format /// ``` -/// Header (Fixed 13 bytes): -/// +--------+------+-----+-----------+-------+----------------+ -/// |Version | Type | TTL | Timestamp | Flags | PayloadLength | -/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes | -/// +--------+------+-----+-----------+-------+----------------+ +/// Header (Fixed 14 bytes for v1, 16 bytes for v2): +/// +--------+------+-----+-----------+-------+------------------+ +/// |Version | Type | TTL | Timestamp | Flags | PayloadLength | +/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes | +/// +--------+------+-----+-----------+-------+------------------+ /// /// Variable sections: /// +----------+-------------+---------+------------+ @@ -105,10 +105,23 @@ extension Data { /// their binary wire format representation. /// - Note: All multi-byte values use network byte order (big-endian) struct BinaryProtocol { - static let headerSize = 13 + static let v1HeaderSize = 14 + static let v2HeaderSize = 16 static let senderIDSize = 8 static let recipientIDSize = 8 static let signatureSize = 64 + + static func headerSize(for version: UInt8) -> Int { + switch version { + case 1: return v1HeaderSize + case 2: return v2HeaderSize + default: return 0 + } + } + + private static func lengthFieldSize(for version: UInt8) -> Int { + return version == 2 ? 4 : 2 + } struct Flags { static let hasRecipient: UInt8 = 0x01 @@ -118,70 +131,68 @@ struct BinaryProtocol { // Encode BitchatPacket to binary format static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? { - var data = Data() - - - // Try to compress payload if beneficial + let version = packet.version + guard version == 1 || version == 2 else { return nil } + + // Try to compress payload when beneficial, keeping original size for later decoding var payload = packet.payload - var originalPayloadSize: UInt16? = nil var isCompressed = false - + var originalPayloadSize: Int? if CompressionUtil.shouldCompress(payload) { - if let compressedPayload = CompressionUtil.compress(payload) { - // Store original size for decompression (2 bytes after payload) - originalPayloadSize = UInt16(payload.count) + // Only compress when we can represent the original length in the outbound frame + let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max) + if payload.count <= maxRepresentable, + let compressedPayload = CompressionUtil.compress(payload) { + originalPayloadSize = payload.count payload = compressedPayload isCompressed = true - - } else { } - } else { } - - // Header - // Reserve capacity to reduce reallocations. Estimate base size conservatively. - // header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad - let estimatedPayload = payload.count + (isCompressed ? 2 : 0) - let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255 - data.reserveCapacity(estimated) - data.append(packet.version) + + let lengthFieldBytes = lengthFieldSize(for: version) + let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0 + let payloadDataSize = payload.count + originalSizeFieldBytes + + if version == 1 && payloadDataSize > Int(UInt16.max) { return nil } + if version == 2 && payloadDataSize > Int(UInt32.max) { return nil } + + let estimatedHeader = headerSize(for: version) + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + let estimatedPayload = payloadDataSize + let estimatedSignature = (packet.signature == nil ? 0 : signatureSize) + var data = Data() + data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255) + + data.append(version) data.append(packet.type) data.append(packet.ttl) - - // Timestamp (8 bytes, big-endian) - for i in (0..<8).reversed() { - data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF)) + + for shift in stride(from: 56, through: 0, by: -8) { + data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF)) } - - // Flags + var flags: UInt8 = 0 - if packet.recipientID != nil { - flags |= Flags.hasRecipient - } - if packet.signature != nil { - flags |= Flags.hasSignature - } - if isCompressed { - flags |= Flags.isCompressed - } + if packet.recipientID != nil { flags |= Flags.hasRecipient } + if packet.signature != nil { flags |= Flags.hasSignature } + if isCompressed { flags |= Flags.isCompressed } data.append(flags) - - // Payload length (2 bytes, big-endian) - includes original size if compressed - let payloadDataSize = payload.count + (isCompressed ? 2 : 0) - let payloadLength = UInt16(payloadDataSize) - - - data.append(UInt8((payloadLength >> 8) & 0xFF)) - data.append(UInt8(payloadLength & 0xFF)) - - // SenderID (exactly 8 bytes) + + if version == 2 { + let length = UInt32(payloadDataSize) + for shift in stride(from: 24, through: 0, by: -8) { + data.append(UInt8((length >> UInt32(shift)) & 0xFF)) + } + } else { + let length = UInt16(payloadDataSize) + data.append(UInt8((length >> 8) & 0xFF)) + data.append(UInt8(length & 0xFF)) + } + let senderBytes = packet.senderID.prefix(senderIDSize) data.append(senderBytes) if senderBytes.count < senderIDSize { data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count)) } - - // RecipientID (if present) + if let recipientID = packet.recipientID { let recipientBytes = recipientID.prefix(recipientIDSize) data.append(recipientBytes) @@ -189,30 +200,30 @@ struct BinaryProtocol { data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count)) } } - - // Payload (with original size prepended if compressed) + if isCompressed, let originalSize = originalPayloadSize { - // Prepend original size (2 bytes, big-endian) - data.append(UInt8((originalSize >> 8) & 0xFF)) - data.append(UInt8(originalSize & 0xFF)) + if version == 2 { + let value = UInt32(originalSize) + for shift in stride(from: 24, through: 0, by: -8) { + data.append(UInt8((value >> UInt32(shift)) & 0xFF)) + } + } else { + let value = UInt16(originalSize) + data.append(UInt8((value >> 8) & 0xFF)) + data.append(UInt8(value & 0xFF)) + } } data.append(payload) - - // Signature (if present) + if let signature = packet.signature { data.append(signature.prefix(signatureSize)) } - - - // Apply padding to standard block sizes for traffic analysis resistance + if padding { let optimalSize = MessagePadding.optimalBlockSize(for: data.count) - let paddedData = MessagePadding.pad(data, toSize: optimalSize) - return paddedData - } else { - // Caller explicitly requested no padding (e.g., BLE write path) - return data + return MessagePadding.pad(data, toSize: optimalSize) } + return data } // Decode binary data to BitchatPacket @@ -227,87 +238,100 @@ struct BinaryProtocol { // Core decoding implementation used by decode(_:) with and without padding removal private static func decodeCore(_ raw: Data) -> BitchatPacket? { - // Minimum size: header + senderID - guard raw.count >= headerSize + senderIDSize else { return nil } + guard raw.count >= v1HeaderSize + senderIDSize else { return nil } return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in guard let base = buf.baseAddress else { return nil } var offset = 0 func require(_ n: Int) -> Bool { offset + n <= buf.count } - // Read single byte func read8() -> UInt8? { guard require(1) else { return nil } - let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee + let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee offset += 1 - return v + return value } - // Read big-endian 16-bit func read16() -> UInt16? { guard require(2) else { return nil } - let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self) - let v = (UInt16(p[0]) << 8) | UInt16(p[1]) + let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self) + let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1]) offset += 2 - return v + return value + } + func read32() -> UInt32? { + guard require(4) else { return nil } + let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self) + let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3]) + offset += 4 + return value } - // Copy N bytes into Data func readData(_ n: Int) -> Data? { guard require(n) else { return nil } let ptr = base.advanced(by: offset) - let d = Data(bytes: ptr, count: n) + let data = Data(bytes: ptr, count: n) offset += n - return d + return data } - // Version - guard let version = read8(), version == 1 else { return nil } - guard let type = read8() else { return nil } - guard let ttl = read8() else { return nil } + guard let version = read8(), version == 1 || version == 2 else { return nil } + let lengthFieldBytes = lengthFieldSize(for: version) + let minimumRequired = headerSize(for: version) + senderIDSize + guard raw.count >= minimumRequired else { return nil } - // Timestamp 8 bytes BE - guard require(8) else { return nil } - var ts: UInt64 = 0 + guard let type = read8(), let ttl = read8() else { return nil } + + var timestamp: UInt64 = 0 for _ in 0..<8 { - guard let b = read8() else { return nil } - ts = (ts << 8) | UInt64(b) + guard let byte = read8() else { return nil } + timestamp = (timestamp << 8) | UInt64(byte) } - // Flags guard let flags = read8() else { return nil } let hasRecipient = (flags & Flags.hasRecipient) != 0 let hasSignature = (flags & Flags.hasSignature) != 0 let isCompressed = (flags & Flags.isCompressed) != 0 - // Payload length - guard let payloadLen = read16(), payloadLen <= 65535 else { return nil } + let payloadLength: Int + if version == 2 { + guard let len = read32() else { return nil } + payloadLength = Int(len) + } else { + guard let len = read16() else { return nil } + payloadLength = Int(len) + } + + guard payloadLength >= 0 else { return nil } - // SenderID guard let senderID = readData(senderIDSize) else { return nil } - // Recipient var recipientID: Data? = nil if hasRecipient { recipientID = readData(recipientIDSize) if recipientID == nil { return nil } } - // Payload let payload: Data if isCompressed { - // Need original size (2 bytes) - guard let origSize16 = read16() else { return nil } - let originalSize = Int(origSize16) + guard payloadLength >= lengthFieldBytes else { return nil } + let originalSize: Int + if version == 2 { + guard let rawSize = read32() else { return nil } + originalSize = Int(rawSize) + } else { + guard let rawSize = read16() else { return nil } + originalSize = Int(rawSize) + } + // Guard to keep decompression bounded (1 MiB ceiling aligns with previous behaviour) guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil } - let compSize = Int(payloadLen) - 2 - guard compSize >= 0, let compressed = readData(compSize) else { return nil } + let compressedSize = payloadLength - lengthFieldBytes + guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil } guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize), decompressed.count == originalSize else { return nil } payload = decompressed } else { - guard let p = readData(Int(payloadLen)) else { return nil } - payload = p + guard let rawPayload = readData(payloadLength) else { return nil } + payload = rawPayload } - // Signature var signature: Data? = nil if hasSignature { signature = readData(signatureSize) @@ -320,10 +344,11 @@ struct BinaryProtocol { type: type, senderID: senderID, recipientID: recipientID, - timestamp: ts, + timestamp: timestamp, payload: payload, signature: signature, - ttl: ttl + ttl: ttl, + version: version ) } } diff --git a/bitchat/Protocols/BitchatFilePacket.swift b/bitchat/Protocols/BitchatFilePacket.swift new file mode 100644 index 00000000..dde74d60 --- /dev/null +++ b/bitchat/Protocols/BitchatFilePacket.swift @@ -0,0 +1,151 @@ +// +// BitchatFilePacket.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files). +/// Mirrors the Android client specification to ensure cross-platform interoperability. +struct BitchatFilePacket { + var fileName: String? + var fileSize: UInt64? + var mimeType: String? + var content: Data + + /// Canonical TLV tags defined by the Android implementation. + private enum TLVType: UInt8 { + case fileName = 0x01 + case fileSize = 0x02 + case mimeType = 0x03 + case content = 0x04 + } + + /// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length). + /// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max). + func encode() -> Data? { + let resolvedSize = fileSize ?? UInt64(content.count) + guard resolvedSize <= UInt64(UInt32.max) else { return nil } + guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil } + guard content.count <= Int(UInt32.max) else { return nil } + guard FileTransferLimits.isValidPayload(content.count) else { return nil } + + func appendBE(_ value: T, into data: inout Data) { + var big = value.bigEndian + withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } + } + + var encoded = Data() + + if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) { + encoded.append(TLVType.fileName.rawValue) + appendBE(UInt16(nameData.count), into: &encoded) + encoded.append(nameData) + } + + encoded.append(TLVType.fileSize.rawValue) + appendBE(UInt16(4), into: &encoded) + appendBE(UInt32(resolvedSize), into: &encoded) + + if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) { + encoded.append(TLVType.mimeType.rawValue) + appendBE(UInt16(mimeData.count), into: &encoded) + encoded.append(mimeData) + } + + encoded.append(TLVType.content.rawValue) + appendBE(UInt32(content.count), into: &encoded) + encoded.append(content) + + return encoded + } + + /// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible. + static func decode(_ data: Data) -> BitchatFilePacket? { + var cursor = data.startIndex + let end = data.endIndex + + var fileName: String? + var fileSize: UInt64? + var mimeType: String? + var content = Data() + + while cursor < end { + let typeRaw = data[cursor] + cursor = data.index(after: cursor) + + guard cursor <= end else { return nil } + let tlvType = TLVType(rawValue: typeRaw) + + func readBigEndianLength(bytes: Int) -> Int? { + guard data.distance(from: cursor, to: end) >= bytes else { return nil } + var result = 0 + for _ in 0..= 0 else { return nil } + guard data.distance(from: cursor, to: end) >= tlvLength else { return nil } + + let valueStart = cursor + cursor = data.index(cursor, offsetBy: tlvLength) + let value = data[valueStart.. UInt64(FileTransferLimits.maxPayloadBytes) { + return nil + } + fileSize = size + } + case .mimeType: + mimeType = String(data: Data(value), encoding: .utf8) + case .content: + let proposedSize = content.count + value.count + if proposedSize > FileTransferLimits.maxPayloadBytes { + return nil + } + content.append(contentsOf: value) + case nil: + continue + } + } + + guard !content.isEmpty else { return nil } + guard FileTransferLimits.isValidPayload(content.count) else { return nil } + return BitchatFilePacket( + fileName: fileName, + fileSize: fileSize ?? UInt64(content.count), + mimeType: mimeType, + content: content + ) + } +} diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index d7585a97..6ccea7e3 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -79,6 +79,7 @@ enum MessageType: UInt8 { // Fragmentation (simplified) case fragment = 0x20 // Single fragment type for large messages + case fileTransfer = 0x22 // Binary file/audio/image payloads var description: String { switch self { @@ -89,6 +90,7 @@ enum MessageType: UInt8 { case .noiseHandshake: return "noiseHandshake" case .noiseEncrypted: return "noiseEncrypted" case .fragment: return "fragment" + case .fileTransfer: return "fileTransfer" } } } diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 47a0faec..946a80dc 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -7,6 +7,92 @@ import CryptoKit import UIKit #endif +struct NotificationStreamAssembler { + private var buffer = Data() + + mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) { + guard !chunk.isEmpty else { return ([], [], false) } + + buffer.append(chunk) + + var frames: [Data] = [] + var dropped: [UInt8] = [] + var reset = false + let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes + + while buffer.count >= BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize { + guard let version = buffer.first else { break } + guard version == 1 || version == 2 else { + dropped.append(buffer.removeFirst()) + continue + } + + let headerSize = BinaryProtocol.headerSize(for: version) + let framePrefix = headerSize + BinaryProtocol.senderIDSize + guard headerSize > 0 else { + dropped.append(buffer.removeFirst()) + continue + } + guard buffer.count >= framePrefix else { break } + + let flagsIndex = buffer.startIndex + 11 + let flags = buffer[flagsIndex] + let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0 + let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0 + + let lengthOffset = 12 + let payloadLength: Int + if version == 2 { + let lengthIndex = buffer.startIndex + lengthOffset + payloadLength = + (Int(buffer[lengthIndex]) << 24) | + (Int(buffer[lengthIndex + 1]) << 16) | + (Int(buffer[lengthIndex + 2]) << 8) | + Int(buffer[lengthIndex + 3]) + } else { + let lengthIndex = buffer.startIndex + lengthOffset + payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1]) + } + + var frameLength = framePrefix + payloadLength + if hasRecipient { frameLength += BinaryProtocol.recipientIDSize } + if hasSignature { frameLength += BinaryProtocol.signatureSize } + + guard frameLength > 0, frameLength <= maxFrameLength else { + buffer.removeAll() + reset = true + break + } + + if buffer.count < frameLength { + if let nextStart = buffer.dropFirst().firstIndex(where: { $0 == 1 || $0 == 2 }) { + let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart) + if dropCount > 0 { + let removed = buffer.prefix(dropCount) + buffer.removeFirst(dropCount) + dropped.append(contentsOf: removed) + } + } + break + } + + let frame = Data(buffer.prefix(frameLength)) + frames.append(frame) + buffer.removeFirst(frameLength) + } + + if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) { + buffer.removeAll(keepingCapacity: false) + } + + return (frames, dropped, reset) + } + + mutating func reset() { + buffer.removeAll(keepingCapacity: false) + } +} + /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). @@ -66,11 +152,22 @@ final class BLEService: NSObject { // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() + private lazy var mediaDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + return formatter + }() // 5. Fragment Reassembly (necessary for messages > MTU) private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 } private var incomingFragments: [FragmentKey: [Int: Data]] = [:] private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:] + private struct ActiveTransferState { + let totalFragments: Int + var sentFragments: Int + var workItems: [DispatchWorkItem] + } + private var activeTransfers: [String: ActiveTransferState] = [:] // Backoff for peripherals that recently timed out connecting private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout @@ -482,10 +579,18 @@ final class BLEService: NSObject { stopServices() // Clear all sessions and peers - collectionsQueue.sync(flags: .barrier) { + let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) { + let entries = activeTransfers.map { ($0.key, $0.value.workItems) } peers.removeAll() incomingFragments.removeAll() fragmentMetadata.removeAll() + activeTransfers.removeAll() + return entries + } + + for entry in cancelledTransfers { + entry.items.forEach { $0.cancel() } + TransferProgressManager.shared.cancel(id: entry.id) } // Clear processed messages @@ -568,6 +673,15 @@ final class BLEService: NSObject { } // MARK: Messaging + + func cancelTransfer(_ transferId: String) { + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self, let state = self.activeTransfers.removeValue(forKey: transferId) else { return } + state.workItems.forEach { $0.cancel() } + TransferProgressManager.shared.cancel(id: transferId) + SecureLogger.debug("🛑 Cancelled transfer \(transferId.prefix(8))…", category: .session) + } + } // Transport protocol conformance helper: simplified public message send func sendMessage(_ content: String, mentions: [String]) { @@ -577,6 +691,111 @@ final class BLEService: NSObject { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { sendPrivateMessage(content, to: peerID.id, messageID: messageID) + + + func sendFileBroadcast(_ filePacket: BitchatFilePacket) { + messageQueue.async { [weak self] in + guard let self = self else { return } + guard let payload = filePacket.encode() else { + SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session) + return + } + + let packet = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: self.myPeerIDData, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: self.messageTTL, + version: 2 + ) + + let senderHex = packet.senderID.hexEncodedString() + let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" + self.messageDeduplicator.markProcessed(dedupID) + + SecureLogger.debug("📁 Broadcasting file transfer payload bytes=\(payload.count)", category: .session) + self.broadcastPacket(packet) + self.gossipSyncManager?.onPublicPacketSeen(packet) + } + } + + func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID) { + messageQueue.async { [weak self] in + guard let self = self else { return } + guard let payload = filePacket.encode() else { + SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) + return + } + guard let recipientData = Data(hexString: peerID.id) else { + SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID)", category: .session) + return + } + + var packet = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: self.myPeerIDData, + recipientID: recipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: self.messageTTL, + version: 2 + ) + + if let signed = self.noiseService.signPacket(packet) { + packet = signed + } + + SecureLogger.debug("📁 Sending private file transfer to \(peerID.prefix(8))… bytes=\(payload.count)", category: .session) + self.broadcastPacket(packet) + } + } + + func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { + // Ensure this runs on message queue to avoid main thread blocking + messageQueue.async { [weak self] in + guard let self = self else { return } + + guard content.count <= self.maxMessageLength else { + SecureLogger.error("Message too long: \(content.count) chars", category: .session) + return + } + + let finalMessageID = messageID ?? UUID().uuidString + let _ = UInt64(Date().timeIntervalSince1970 * 1000) + + if let recipientID = recipientID { + // Private message + self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID) + } else { + // Public broadcast + // Create packet with explicit fields so we can sign it + let basePacket = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: self.myPeerID) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(content.utf8), + signature: nil, + ttl: self.messageTTL + ) + guard let signedPacket = self.noiseService.signPacket(basePacket) else { + SecureLogger.error("❌ Failed to sign public message", category: .security) + return + } + // Pre-mark our own broadcast as processed to avoid handling relayed self copy + let senderHex = signedPacket.senderID.hexEncodedString() + let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" + self.messageDeduplicator.markProcessed(dedupID) + // Call synchronously since we're already on background queue + self.broadcastPacket(signedPacket) + // Track our own broadcast for sync + self.gossipSyncManager?.onPublicPacketSeen(signedPacket) + } + } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @@ -603,6 +822,695 @@ final class BLEService: NSObject { } } else { // Queue for after handshake and initiate if needed + } + } + + // MARK: - Packet Broadcasting + + private func broadcastPacket(_ packet: BitchatPacket) { + // Encode once using a small per-type padding policy, then delegate by type + let padForBLE = padPolicy(for: packet.type) + if packet.type == MessageType.fileTransfer.rawValue { + sendFragmentedPacket(packet, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil) + return + } + guard let data = packet.toBinaryData(padding: padForBLE) else { + SecureLogger.error("❌ Failed to convert packet to binary data", category: .session) + return + } + if packet.type == MessageType.noiseEncrypted.rawValue { + sendEncrypted(packet, data: data, pad: padForBLE) + return + } + sendGenericBroadcast(packet, data: data, pad: padForBLE) + } + + // MARK: - Broadcast helpers (single responsibility) + private func padPolicy(for type: UInt8) -> Bool { + switch MessageType(rawValue: type) { + case .noiseEncrypted, .noiseHandshake: + return true + default: + return false + } + } + + private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) { + guard let recipientID = packet.recipientID else { return } + let recipientPeerID = recipientID.hexEncodedString() + var sentEncrypted = false + + // Per-link limits for the specific peer + var peripheralMaxLen: Int? + if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) { + if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) { + peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse) + } + } + var centralMaxLen: Int? + do { + let (centrals, mapping) = snapshotSubscribedCentrals() + if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) { + centralMaxLen = central.maximumUpdateValueLength + } + } + if let pm = peripheralMaxLen, data.count > pm { + let overhead = 13 + 8 + 8 + 13 + let chunk = max(64, pm - overhead) + sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID) + return + } + if let cm = centralMaxLen, data.count > cm { + let overhead = 13 + 8 + 8 + 13 + let chunk = max(64, cm - overhead) + sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID) + return + } + + // Direct write via peripheral link + if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }), + let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }), + state.isConnected, + let characteristic = state.characteristic { + writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic) + sentEncrypted = true + } + + // Notify via central link (dual-role) + if let characteristic = characteristic, !sentEncrypted { + let (centrals, mapping) = snapshotSubscribedCentrals() + for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID { + let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false + if success { sentEncrypted = true; break } + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount { + self.pendingNotifications.append((data: data, centrals: [central])) + SecureLogger.debug("📋 Queued encrypted packet for retry (notification queue full)", category: .session) + } + } + } + } + + if !sentEncrypted { + // Flood as last resort with recipient set; link aware + sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID) + } + } + + private func sendGenericBroadcast(_ packet: BitchatPacket, data: Data, pad: Bool) { + sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: nil) + } + + private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) { + // Determine last-hop link for this message to avoid echoing back + let messageID = makeMessageID(for: packet) + let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link } + let directedPeerHint: String? = { + if let explicit = directedOnlyPeer { return explicit } + if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty { + return recipient + } + return nil + }() + + let states = snapshotPeripheralStates() + var minCentralWriteLen: Int? + for s in states where s.isConnected { + let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse) + minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m + } + var snapshotCentrals: [CBCentral] = [] + if let _ = characteristic { + let (centrals, _) = snapshotSubscribedCentrals() + snapshotCentrals = centrals + } + var minNotifyLen: Int? + if !snapshotCentrals.isEmpty { + minNotifyLen = snapshotCentrals.map { $0.maximumUpdateValueLength }.min() + } + // Avoid re-fragmenting fragment packets + if packet.type != MessageType.fragment.rawValue, + let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(), + data.count > minLen { + let overhead = 13 + 8 + 8 + 13 + let chunk = max(64, minLen - overhead) + sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer) + return + } + // Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link + let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString } + let subscribedCentrals: [CBCentral] + var centralIDs: [String] = [] + if let _ = characteristic { + let (centrals, _) = snapshotSubscribedCentrals() + subscribedCentrals = centrals + centralIDs = centrals.map { $0.identifier.uuidString } + } else { + subscribedCentrals = [] + } + + // Exclude ingress link + var allowedPeripheralIDs = connectedPeripheralIDs + var allowedCentralIDs = centralIDs + if let ingress = ingressLink { + switch ingress { + case .peripheral(let id): + allowedPeripheralIDs.removeAll { $0 == id } + case .central(let id): + allowedCentralIDs.removeAll { $0 == id } + } + } + + // For broadcast (no directed peer) and non-fragment, choose a subset deterministically + // Special-case control/presence messages: do NOT subset to maximize immediate coverage + var selectedPeripheralIDs = Set(allowedPeripheralIDs) + var selectedCentralIDs = Set(allowedCentralIDs) + if directedPeerHint == nil + && packet.type != MessageType.fragment.rawValue + && packet.type != MessageType.announce.rawValue + && packet.type != MessageType.requestSync.rawValue { + let kp = subsetSizeForFanout(allowedPeripheralIDs.count) + let kc = subsetSizeForFanout(allowedCentralIDs.count) + selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) + selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID) + } + + // If directed and we currently have no links to forward on, spool for a short window + if let only = directedPeerHint, + selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty, + (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) { + spoolDirectedPacket(packet, recipientPeerID: only) + } + + // Writes to selected connected peripherals + for s in states where s.isConnected { + let pid = s.peripheral.identifier.uuidString + guard selectedPeripheralIDs.contains(pid) else { continue } + if let ch = s.characteristic { + writeOrEnqueue(data, to: s.peripheral, characteristic: ch) + } + } + // Notify selected subscribed centrals + if let ch = characteristic { + let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) } + if !targets.isEmpty { + _ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) + } + } + } + + // Directed send helper (unicast to a specific peerID) without altering packet contents + private func sendPacketDirected(_ packet: BitchatPacket, to peerID: String) { + guard let data = packet.toBinaryData(padding: false) else { return } + sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) + } + + // MARK: - Directed store-and-forward + private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) { + let msgID = makeMessageID(for: packet) + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:] + if byMsg[msgID] == nil { + byMsg[msgID] = (packet: packet, enqueuedAt: Date()) + self.pendingDirectedRelays[recipientPeerID] = byMsg + SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session) + } + } + } + + private func flushDirectedSpool() { + // Move items out and attempt broadcast; if still no links, they'll be re-spooled + let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) { + var out: [(String, BitchatPacket)] = [] + let now = Date() + for (recipient, dict) in pendingDirectedRelays { + for (_, entry) in dict { + if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds { + out.append((recipient, entry.packet)) + } + } + // Clear recipient bucket; items will be re-spooled if still no links + pendingDirectedRelays.removeValue(forKey: recipient) + } + return out + } + guard !toSend.isEmpty else { return } + for (_, packet) in toSend { + messageQueue.async { [weak self] in self?.broadcastPacket(packet) } + } + } + + private func rebroadcastRecentAnnounces() { + // Snapshot sender order to preserve ordering and avoid holding locks while sending + let packets: [BitchatPacket] = collectionsQueue.sync { + recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] } + } + guard !packets.isEmpty else { return } + for (idx, pkt) in packets.enumerated() { + // Stagger slightly to avoid bursts + let delayMs = idx * 20 + messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in + self?.broadcastPacket(pkt) + } + } + } + + private func sendData(_ data: Data, to peripheral: CBPeripheral) { + // Fire-and-forget: Simple send without complex fallback logic + guard peripheral.state == .connected else { return } + + let peripheralUUID = peripheral.identifier.uuidString + guard let state = peripherals[peripheralUUID], + let characteristic = state.characteristic else { return } + + // Fire-and-forget principle: always use .withoutResponse for speed + // CoreBluetooth will handle fragmentation at L2CAP layer + writeOrEnqueue(data, to: peripheral, characteristic: characteristic) + } + + // MARK: - Fragmentation (Required for messages > BLE MTU) + + private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: String? = nil) { + guard let fullData = packet.toBinaryData(padding: pad) else { return } + // Fragment the unpadded frame; each fragment will be encoded independently + + let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) + let chunk = maxChunk ?? defaultFragmentSize + let safeChunk = max(64, chunk) + let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in + Data(fullData[offset.. 4 { + bleQueue.async { [weak self] in + guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } + if c.isScanning { c.stopScan() } + // Resume scanning after we expect last fragment to be sent + let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) // ~8ms per fragment + self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in + self?.startScanning() + } + } + } + let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs + + let transferId: String? = { + guard packet.type == MessageType.fileTransfer.rawValue else { return nil } + let id = packet.payload.sha256Hex() + collectionsQueue.sync(flags: .barrier) { + self.activeTransfers[id] = ActiveTransferState(totalFragments: totalFragments, sentFragments: 0, workItems: []) + } + TransferProgressManager.shared.start(id: id, totalFragments: totalFragments) + return id + }() + + var scheduledItems: [(item: DispatchWorkItem, index: Int)] = [] + + for (index, fragment) in fragments.enumerated() { + var payload = Data() + payload.append(fragmentID) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) }) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(fragments.count).bigEndian) { Data($0) }) + payload.append(packet.type) + payload.append(fragment) + + let fragmentRecipient: Data? = { + if let only = directedOnlyPeer { return Data(hexString: only) } + return packet.recipientID + }() + + let fragmentPacket = BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: packet.senderID, + recipientID: fragmentRecipient, + timestamp: packet.timestamp, + payload: payload, + signature: nil, + ttl: packet.ttl + ) + + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + if let transferId = transferId { + let isActive = self.collectionsQueue.sync { self.activeTransfers[transferId] != nil } + guard isActive else { return } + } + self.broadcastPacket(fragmentPacket) + if let transferId = transferId { + self.markFragmentSent(transferId: transferId) + } + } + + scheduledItems.append((item: workItem, index: index)) + } + + if let transferId = transferId { + let workItems = scheduledItems.map { $0.item } + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self, var state = self.activeTransfers[transferId] else { return } + state.workItems = workItems + self.activeTransfers[transferId] = state + } + } + + for (workItem, index) in scheduledItems { + let delayMs = index * perFragMs + messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem) + } + } + + private func markFragmentSent(transferId: String) { + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self, var state = self.activeTransfers[transferId] else { return } + state.sentFragments = min(state.sentFragments + 1, state.totalFragments) + self.activeTransfers[transferId] = state + TransferProgressManager.shared.recordFragmentSent(id: transferId) + if state.sentFragments >= state.totalFragments { + self.activeTransfers.removeValue(forKey: transferId) + } + } + } + + private func handleFragment(_ packet: BitchatPacket, from peerID: String) { + // Don't process our own fragments + if peerID == myPeerID { + return + } + + // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type + guard packet.payload.count >= 13 else { return } + + // Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian + var senderU64: UInt64 = 0 + for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) } + var fragU64: UInt64 = 0 + for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) } + // Parse big-endian UInt16 safely without alignment assumptions + let idxHi = UInt16(packet.payload[8]) + let idxLo = UInt16(packet.payload[9]) + let index = Int((idxHi << 8) | idxLo) + let totHi = UInt16(packet.payload[10]) + let totLo = UInt16(packet.payload[11]) + let total = Int((totHi << 8) | totLo) + let originalType = packet.payload[12] + let fragmentData = packet.payload.suffix(from: 13) + + // Sanity checks + guard total > 0 && index >= 0 && index < total else { return } + + // Store fragment + let key = FragmentKey(sender: senderU64, id: fragU64) + if incomingFragments[key] == nil { + // Cap in-flight assemblies to prevent memory/battery blowups + if incomingFragments.count >= maxInFlightAssemblies { + // Evict the oldest assembly by timestamp + if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { + incomingFragments.removeValue(forKey: oldest) + fragmentMetadata.removeValue(forKey: oldest) + } + } + incomingFragments[key] = [:] + fragmentMetadata[key] = (originalType, total, Date()) + } + incomingFragments[key]?[index] = Data(fragmentData) + + // Check if complete + if let fragments = incomingFragments[key], + fragments.count == total { + // Reassemble + var reassembled = Data() + for i in 0.. 2 { + collectionsQueue.async(flags: .barrier) { [weak self] in + if let task = self?.scheduledRelays.removeValue(forKey: messageID) { + task.cancel() + } + } + } + return // Duplicate ignored + } + + // Update peer info without verbose logging - update the peer we received from, not the original sender + updatePeerLastSeen(peerID) + + // Track recent traffic timestamps for adaptive behavior + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + let now = Date() + self.recentPacketTimestamps.append(now) + // keep last N timestamps within window + let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds) + if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount { + self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount) + } + self.recentPacketTimestamps.removeAll { $0 < cutoff } + } + + + // Process by type + switch MessageType(rawValue: packet.type) { + case .announce: + handleAnnounce(packet, from: senderID) + + case .message: + handleMessage(packet, from: senderID) + + case .requestSync: + handleRequestSync(packet, from: senderID) + + case .noiseHandshake: + handleNoiseHandshake(packet, from: senderID) + + case .noiseEncrypted: + handleNoiseEncrypted(packet, from: senderID) + + case .fragment: + handleFragment(packet, from: senderID) + + case .fileTransfer: + handleFileTransfer(packet, from: senderID) + + case .leave: + handleLeave(packet, from: senderID) + + default: + SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) + break + } + + // Relay if TTL > 1 and we're not the original sender + // Relay decision and scheduling (extracted via RelayController) + do { + let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } + let decision = RelayController.decide( + ttl: packet.ttl, + senderIsSelf: senderID == myPeerID, + isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, + isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil), + isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, + isHandshake: packet.type == MessageType.noiseHandshake.rawValue, + isAnnounce: packet.type == MessageType.announce.rawValue, + degree: degree, + highDegreeThreshold: highDegreeThreshold + ) + guard decision.shouldRelay else { return } + let work = DispatchWorkItem { [weak self] in + guard let self = self else { return } + // Remove scheduled task before executing + self.collectionsQueue.async(flags: .barrier) { [weak self] in + _ = self?.scheduledRelays.removeValue(forKey: messageID) + } + var relayPacket = packet + relayPacket.ttl = decision.newTTL + self.broadcastPacket(relayPacket) + } + // Track the scheduled relay so duplicates can cancel it + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.scheduledRelays[messageID] = work + } + messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work) + } + } + + private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) { + guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { + SecureLogger.error("❌ Failed to decode announce packet from \(peerID)", category: .session) + return + } + + // Verify that the sender's derived ID from the announced noise public key matches the packet senderID + // This helps detect relayed or spoofed announces. Only warn in release; assert in debug. + let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey) + if derivedFromKey != peerID { + SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))…", category: .security) + + } + + // Don't add ourselves as a peer + if peerID == myPeerID { + return + } + + // Suppress announce logs to reduce noise + + // Precompute signature verification outside barrier to reduce contention + let existingPeerForVerify = collectionsQueue.sync { peers[peerID] } + var verifiedAnnounce = false + if packet.signature != nil { + verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) + if !verifiedAnnounce { + SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: .security) + } + } + if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey { + SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: .security) + verifiedAnnounce = false + } + + // Track if this is a new or reconnected peer + var isNewPeer = false + var isReconnectedPeer = false + + collectionsQueue.sync(flags: .barrier) { + // Check if we have an actual BLE connection to this peer + let peripheralUUID = peerToPeripheralUUID[peerID] + let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true + + // Check if this peer is subscribed to us as a central + // Note: We can't identify which specific central is which peer without additional mapping + let hasCentralSubscription = centralToPeerID.values.contains(peerID) + + // Direct announces arrive with full TTL (no prior hop) + let isDirectAnnounce = (packet.ttl == messageTTL) + + // Check if we already have this peer (might be reconnecting) + let existingPeer = peers[peerID] + let wasDisconnected = existingPeer?.isConnected == false + + // Set flags for use outside the sync block + isNewPeer = (existingPeer == nil) + isReconnectedPeer = wasDisconnected + + // Use precomputed verification result + let verified = verifiedAnnounce + + // Require verified announce; ignore otherwise (no backward compatibility) + if !verified { + SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.prefix(8))…", category: .security) + return + } + + // Update or create peer info + if let existing = existingPeer, existing.isConnected { + // Update lastSeen and identity info + peers[peerID] = PeerInfo( + id: existing.id, + nickname: announcement.nickname, + isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isVerifiedNickname: true, + lastSeen: Date() + ) + } else { + // New peer or reconnecting peer + peers[peerID] = PeerInfo( + id: peerID, + nickname: announcement.nickname, + isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isVerifiedNickname: true, + lastSeen: Date() + ) + } + + // Log connection status only for direct connectivity changes; debounce to reduce spam + if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { + let now = Date() + if existingPeer == nil { + SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) + } else if wasDisconnected { + // Debounce 'reconnected' logs within short window + if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds { + // Skip duplicate log + } else { + SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) + lastReconnectLogAt[peerID] = now + } + } else if existingPeer?.nickname != announcement.nickname { + SecureLogger.debug("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: .session) + } + } + } + + // Persist cryptographic identity and signing key for robust offline verification + do { + // Derive fingerprint from Noise public key + let hash = SHA256.hash(data: announcement.noisePublicKey) + let fingerprint = hash.map { String(format: "%02x", $0) }.joined() + identityManager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + claimedNickname: announcement.nickname + ) + } + + // Record this announce for lightweight rebroadcast buffer (exclude self) + if peerID != myPeerID { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) @@ -611,6 +1519,148 @@ final class BLEService: NSObject { SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session) } } + + private func handleFileTransfer(_ packet: BitchatPacket, from peerID: String) { + if peerID == myPeerID && packet.ttl != 0 { return } + + var accepted = false + var senderNickname = "" + + if peerID == myPeerID { + accepted = true + senderNickname = myNickname + } else if let info = peers[peerID], info.isVerifiedNickname { + accepted = true + senderNickname = info.nickname + let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname) + if hasCollision { + senderNickname += "#" + String(peerID.prefix(4)) + } + } else if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + for candidate in candidates { + if let signingKey = candidate.signingPublicKey, + noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { + accepted = true + if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { + senderNickname = social.localPetname ?? social.claimedNickname + } else { + senderNickname = "anon" + String(peerID.prefix(4)) + } + break + } + } + if !accepted && packet.ttl == 0 { + accepted = true + senderNickname = "anon" + String(peerID.prefix(4)) + } + } else if packet.ttl == 0 { + accepted = true + senderNickname = "anon" + String(peerID.prefix(4)) + } + + guard accepted else { + SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.prefix(8))…", category: .security) + return + } + + // Skip directed packets that are not intended for us + if let recipient = packet.recipientID { + let recipientHex = recipient.hexEncodedString() + if recipientHex != myPeerID && !recipient.allSatisfy({ $0 == 0xFF }) { + return + } + } + + if let recipient = packet.recipientID, + recipient.allSatisfy({ $0 == 0xFF }) { + gossipSyncManager?.onPublicPacketSeen(packet) + } else if packet.recipientID == nil { + gossipSyncManager?.onPublicPacketSeen(packet) + } + + guard let filePacket = BitchatFilePacket.decode(packet.payload) else { + SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) + return + } + + guard FileTransferLimits.isValidPayload(filePacket.content.count) else { + SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(filePacket.content.count) bytes)", category: .security) + return + } + + let mime = (filePacket.mimeType ?? "application/octet-stream").lowercased() + let category: IncomingMediaCategory + if mime.hasPrefix("audio/") { + category = .audio + } else if mime.hasPrefix("image/") { + category = .image + } else { + category = .other + } + + let fallbackExt = defaultExtension(for: mime) ?? (category == .image ? "jpg" : category == .audio ? "m4a" : "bin") + let subdirectory: String + let prefix: String + switch category { + case .audio: + subdirectory = "voicenotes/incoming" + prefix = "voice" + case .image: + subdirectory = "images/incoming" + prefix = "image" + case .other: + subdirectory = "files/incoming" + prefix = "file" + } + + guard let destination = saveIncomingFile( + data: filePacket.content, + preferredName: filePacket.fileName, + subdirectory: subdirectory, + fallbackExtension: fallbackExt, + defaultPrefix: prefix + ) else { + return + } + + let marker: String + switch category { + case .audio: + marker = "[voice] \(destination.path)" + case .image: + marker = "[image] \(destination.path)" + case .other: + marker = "[file] \(destination.path)" + } + + let isPrivateMessage: Bool = { + guard let recipient = packet.recipientID else { return false } + return recipient.hexEncodedString() == myPeerID + }() + + if isPrivateMessage { + updatePeerLastSeen(peerID) + } + + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + let message = BitchatMessage( + sender: senderNickname, + content: marker, + timestamp: ts, + isRelay: false, + originalSender: nil, + isPrivate: isPrivateMessage, + recipientNickname: nil, + senderPeerID: peerID + ) + + SecureLogger.debug("📁 Stored incoming media from \(peerID.prefix(8))… -> \(destination.lastPathComponent)", category: .session) + + notifyUI { [weak self] in + self?.delegate?.didReceiveMessage(message) + } + } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { SecureLogger.debug("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", category: .session) @@ -663,6 +1713,188 @@ final class BLEService: NSObject { SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session) } } + + private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) { + _ = collectionsQueue.sync(flags: .barrier) { + // Remove the peer when they leave + peers.removeValue(forKey: peerID) + } + // Remove any stored announcement for sync purposes + gossipSyncManager?.removeAnnouncementForPeer(peerID) + // Send on main thread + notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after removal) + let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } + + self.delegate?.didDisconnectFromPeer(peerID) + self.delegate?.didUpdatePeerList(currentPeerIDs) + } + } + + // MARK: - Helper Functions + + private enum IncomingMediaCategory { + case audio + case image + case other + } + + private func applicationFilesDirectory() throws -> URL { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let filesDir = base.appendingPathComponent("files", isDirectory: true) + try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) + return filesDir + } + + private func sanitizeFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { + var candidate = name ?? "" + candidate = candidate.replacingOccurrences(of: "\\", with: "/") + candidate = candidate.components(separatedBy: "/").last ?? defaultName + candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + if candidate.isEmpty { candidate = defaultName } + let invalid = CharacterSet(charactersIn: "<>:\"|?*") + candidate = candidate.components(separatedBy: invalid).joined(separator: "_") + if candidate.isEmpty { candidate = defaultName } + if candidate.count > 120 { + candidate = String(candidate.prefix(120)) + } + if let fallbackExtension = fallbackExtension, (candidate as NSString).pathExtension.isEmpty { + candidate += ".\(fallbackExtension)" + } + return candidate + } + + private func uniqueFileURL(in directory: URL, fileName: String) -> URL { + var candidate = directory.appendingPathComponent(fileName) + if !FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + let baseName = (fileName as NSString).deletingPathExtension + let ext = (fileName as NSString).pathExtension + var counter = 1 + repeat { + let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)" + candidate = directory.appendingPathComponent(newName) + counter += 1 + } while FileManager.default.fileExists(atPath: candidate.path) + return candidate + } + + private func saveIncomingFile(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String) -> URL? { + do { + let base = try applicationFilesDirectory().appendingPathComponent(subdirectory, isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil) + let timestamp = mediaDateFormatter.string(from: Date()) + let defaultName = "\(defaultPrefix)_\(timestamp)" + let sanitized = sanitizeFileName(preferredName, defaultName: defaultName, fallbackExtension: fallbackExtension) + let destination = uniqueFileURL(in: base, fileName: sanitized) + try data.write(to: destination, options: .atomic) + return destination + } catch { + SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session) + return nil + } + } + + private func defaultExtension(for mimeType: String) -> String? { + switch mimeType.lowercased() { + case "audio/mp4", "audio/m4a", "audio/aac": + return "m4a" + case "audio/mpeg": + return "mp3" + case "audio/wav", "audio/x-wav": + return "wav" + case "audio/ogg": + return "ogg" + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + case "application/pdf": + return "pdf" + default: + return nil + } + } + + private func sendLeave() { + SecureLogger.debug("👋 Sending leave announcement", category: .session) + let packet = BitchatPacket( + type: MessageType.leave.rawValue, + ttl: messageTTL, + senderID: myPeerID, + payload: Data(myNickname.utf8) + ) + broadcastPacket(packet) + } + + private func sendAnnounce(forceSend: Bool = false) { + // Throttle announces to prevent flooding + let now = Date() + let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) + + // Even forced sends should respect a minimum interval to avoid overwhelming BLE + let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval + + if timeSinceLastAnnounce < minInterval { + // Skipping announce (rate limited) + return + } + lastAnnounceSent = now + + // Reduced logging - only log errors, not every announce + + // Create announce payload with both noise and signing public keys + let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification + let signingPub = noiseService.getSigningPublicKeyData() // For signature verification + + let announcement = AnnouncementPacket( + nickname: myNickname, + noisePublicKey: noisePub, + signingPublicKey: signingPub + ) + + guard let payload = announcement.encode() else { + SecureLogger.error("❌ Failed to encode announce packet", category: .session) + return + } + + // Create packet with signature using the noise private key + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: myPeerIDData, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, // Will be set by signPacket below + ttl: messageTTL + ) + + // Sign the packet using the noise private key + guard let signedPacket = noiseService.signPacket(packet) else { + SecureLogger.error("❌ Failed to sign announce packet", category: .security) + return + } + + // Call directly if on messageQueue, otherwise dispatch + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(signedPacket) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(signedPacket) + } + } + // Ensure our own announce is included in sync state + gossipSyncManager?.onPublicPacketSeen(signedPacket) + } + + } // MARK: QR Verification over Noise @@ -2825,11 +4057,6 @@ extension BLEService { } } - private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) { - _ = collectionsQueue.sync(flags: .barrier) { - // Remove the peer when they leave - peers.removeValue(forKey: peerID) - } // Remove any stored announcement for sync purposes gossipSyncManager?.removeAnnouncementForPeer(peerID) // Send on main thread diff --git a/bitchat/Services/TransferProgressManager.swift b/bitchat/Services/TransferProgressManager.swift new file mode 100644 index 00000000..3ab721ba --- /dev/null +++ b/bitchat/Services/TransferProgressManager.swift @@ -0,0 +1,65 @@ +import Foundation +import Combine + +/// Centralized progress bus for Bluetooth file transfers. +/// Emits Combine events consumed by ChatViewModel to update UI progress indicators. +final class TransferProgressManager { + static let shared = TransferProgressManager() + + enum Event { + case started(id: String, totalFragments: Int) + case updated(id: String, sentFragments: Int, totalFragments: Int) + case completed(id: String, totalFragments: Int) + case cancelled(id: String, sentFragments: Int, totalFragments: Int) + } + + private let subject = PassthroughSubject() + private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent) + private var states: [String: (sent: Int, total: Int)] = [:] + + var publisher: AnyPublisher { + subject.eraseToAnyPublisher() + } + + func start(id: String, totalFragments: Int) { + queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.states[id] = (sent: 0, total: totalFragments) + self.subject.send(.started(id: id, totalFragments: totalFragments)) + } + } + + func recordFragmentSent(id: String) { + queue.async(flags: .barrier) { [weak self] in + guard let self = self, var state = self.states[id] else { return } + state.sent = min(state.sent + 1, state.total) + self.states[id] = state + self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total)) + if state.sent >= state.total { + self.states.removeValue(forKey: id) + self.subject.send(.completed(id: id, totalFragments: state.total)) + } + } + } + + func cancel(id: String) { + queue.async(flags: .barrier) { [weak self] in + guard let self = self, let state = self.states.removeValue(forKey: id) else { return } + self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total)) + } + } + + func reset(id: String) { + queue.async(flags: .barrier) { [weak self] in + self?.states.removeValue(forKey: id) + } + } + + func snapshot(id: String) -> (sent: Int, total: Int)? { + var result: (sent: Int, total: Int)? + queue.sync { + result = states[id] + } + return result + } +} diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 502d33ba..18f714e3 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -50,6 +50,9 @@ protocol Transport: AnyObject { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendBroadcastAnnounce() func sendDeliveryAck(for messageID: String, to peerID: PeerID) + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) + func cancelTransfer(_ transferId: String) // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -59,6 +62,9 @@ protocol Transport: AnyObject { extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} + func cancelTransfer(_ transferId: String) {} } protocol TransportPeerEventsDelegate: AnyObject { diff --git a/bitchat/Utils/FileTransferLimits.swift b/bitchat/Utils/FileTransferLimits.swift new file mode 100644 index 00000000..cdd7bf9b --- /dev/null +++ b/bitchat/Utils/FileTransferLimits.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios. +enum FileTransferLimits { + /// Absolute ceiling enforced for any file payload (voice, image, other). + static let maxPayloadBytes: Int = 8 * 1024 * 1024 // 8 MiB + /// Voice notes stay small for low-latency relays. + static let maxVoiceNoteBytes: Int = 2 * 1024 * 1024 // 2 MiB + /// Compressed images after downscaling should comfortably fit under this budget. + static let maxImageBytes: Int = 4 * 1024 * 1024 // 4 MiB + + static func isValidPayload(_ size: Int) -> Bool { + size <= maxPayloadBytes + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4a39de8b..ab732955 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -87,6 +87,7 @@ import Tor #if os(iOS) import UIKit #endif +import UniformTypeIdentifiers /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, @@ -430,6 +431,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Delivery tracking private var cancellables = Set() + private var transferIdToMessageID: [String: String] = [:] + private var messageIDToTransferId: [String: String] = [:] // MARK: - QR Verification (pending state) private struct PendingVerification { @@ -644,6 +647,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Set up Noise encryption callbacks setupNoiseCallbacks() + TransferProgressManager.shared.publisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + self?.handleTransferEvent(event) + } + .store(in: &cancellables) + // Observe location channel selection LocationChannelManager.shared.$selectedChannel .receive(on: DispatchQueue.main) @@ -2381,6 +2391,411 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } + // MARK: - Media Transfers + + private enum MediaSendError: Error { + case encodingFailed + case tooLarge + case copyFailed + } + + @MainActor + func sendVoiceNote(at url: URL) { + let targetPeer = selectedPrivateChatPeer + let message = enqueueMediaMessage(content: "[voice] \(url.path)", targetPeer: targetPeer) + let messageID = message.id + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + do { + let data = try Data(contentsOf: url) + guard data.count <= FileTransferLimits.maxVoiceNoteBytes else { + SecureLogger.warning("Voice note exceeds size limit (\(data.count) bytes)", category: .session) + try? FileManager.default.removeItem(at: url) + await MainActor.run { + self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") + } + return + } + let packet = BitchatFilePacket( + fileName: url.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "audio/mp4", + content: data + ) + guard let payload = packet.encode() else { throw MediaSendError.encodingFailed } + let transferId = payload.sha256Hex() + await MainActor.run { + self.registerTransfer(transferId: transferId, messageID: messageID) + if let peerID = targetPeer { + self.meshService.sendFilePrivate(packet, to: peerID) + } else { + self.meshService.sendFileBroadcast(packet) + } + } + } catch { + SecureLogger.error("Voice note send failed: \(error)", category: .session) + await MainActor.run { + self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") + } + } + } + } + + @MainActor + func sendImage(from sourceURL: URL) { + let targetPeer = selectedPrivateChatPeer + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + var processedURL: URL? + do { + let outputURL = try ImageUtils.processImage(at: sourceURL) + processedURL = outputURL + let data = try Data(contentsOf: outputURL) + guard data.count <= FileTransferLimits.maxImageBytes else { + SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session) + await MainActor.run { + self.addSystemMessage("Image is too large to send.") + } + try? FileManager.default.removeItem(at: outputURL) + return + } + let packet = BitchatFilePacket( + fileName: outputURL.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "image/jpeg", + content: data + ) + guard let payload = packet.encode() else { throw MediaSendError.encodingFailed } + let transferId = payload.sha256Hex() + await MainActor.run { + let message = self.enqueueMediaMessage(content: "[image] \(outputURL.path)", targetPeer: targetPeer) + let messageID = message.id + self.registerTransfer(transferId: transferId, messageID: messageID) + if let peerID = targetPeer { + self.meshService.sendFilePrivate(packet, to: peerID) + } else { + self.meshService.sendFileBroadcast(packet) + } + } + } catch { + SecureLogger.error("Image send preparation failed: \(error)", category: .session) + await MainActor.run { + self.addSystemMessage("Failed to prepare image for sending.") + } + if let url = processedURL { + try? FileManager.default.removeItem(at: url) + } + } + } + } + + @MainActor + func sendFileAttachment(from sourceURL: URL) { + let targetPeer = selectedPrivateChatPeer + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + var destinationURL: URL? + do { + let data = try Data(contentsOf: sourceURL) + guard FileTransferLimits.isValidPayload(data.count) else { + throw MediaSendError.tooLarge + } + + let destination = try self.prepareOutgoingFileCopy(from: sourceURL, data: data) + destinationURL = destination + let packet = BitchatFilePacket( + fileName: destination.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: self.mimeType(for: destination), + content: data + ) + guard let payload = packet.encode() else { + try? FileManager.default.removeItem(at: destination) + throw MediaSendError.encodingFailed + } + let transferId = payload.sha256Hex() + + await MainActor.run { + let message = self.enqueueMediaMessage(content: "[file] \(destination.path)", targetPeer: targetPeer) + let messageID = message.id + self.registerTransfer(transferId: transferId, messageID: messageID) + if let peerID = targetPeer { + self.meshService.sendFilePrivate(packet, to: peerID) + } else { + self.meshService.sendFileBroadcast(packet) + } + } + } catch MediaSendError.tooLarge { + await MainActor.run { + self.addSystemMessage("File is too large to send.") + } + } catch { + if let destination = destinationURL { + try? FileManager.default.removeItem(at: destination) + } + SecureLogger.error("File attachment send failed: \(error)", category: .session) + await MainActor.run { + self.addSystemMessage("Failed to send file.") + } + } + } + } + + @MainActor + func cancelMediaSend(messageID: String) { + if let transferId = messageIDToTransferId[messageID] { + meshService.cancelTransfer(transferId) + } + clearTransferMapping(for: messageID) + removeMessage(withID: messageID, cleanupFile: true) + } + + @MainActor + func deleteMediaMessage(messageID: String) { + clearTransferMapping(for: messageID) + removeMessage(withID: messageID, cleanupFile: true) + } + + @MainActor + private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage { + let timestamp = Date() + let message: BitchatMessage + + if let peerID = targetPeer { + message = BitchatMessage( + sender: nickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: nicknameForPeer(peerID), + senderPeerID: meshService.myPeerID, + deliveryStatus: .sending + ) + var chats = privateChats + chats[peerID, default: []].append(message) + privateChats = chats + trimPrivateChatMessagesIfNeeded(for: peerID) + } else { + let (displayName, senderPeerID) = currentPublicSender() + message = BitchatMessage( + sender: displayName, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: senderPeerID, + deliveryStatus: .sending + ) + messages.append(message) + switch activeChannel { + case .mesh: + meshTimeline.append(message) + trimMeshTimelineIfNeeded() + case .location(let ch): + var arr = geoTimelines[ch.geohash] ?? [] + arr.append(message) + if arr.count > geoTimelineCap { + arr = Array(arr.suffix(geoTimelineCap)) + } + geoTimelines[ch.geohash] = arr + } + trimMessagesIfNeeded() + } + + let key = normalizedContentKey(message.content) + recordContentKey(key, timestamp: timestamp) + objectWillChange.send() + return message + } + + private func currentPublicSender() -> (name: String, peerID: String) { + var displaySender = nickname + var senderPeerID = meshService.myPeerID + if case .location(let ch) = activeChannel, + let identity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let suffix = String(identity.publicKeyHex.suffix(4)) + displaySender = nickname + "#" + suffix + let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength) + senderPeerID = "nostr:\(shortKey)" + } + return (displaySender, senderPeerID) + } + + @MainActor + private func nicknameForPeer(_ peerID: String) -> String { + if let name = meshService.peerNickname(peerID: peerID) { + return name + } + if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), + !favorite.peerNickname.isEmpty { + return favorite.peerNickname + } + if let noiseKey = Data(hexString: peerID), + let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + !favorite.peerNickname.isEmpty { + return favorite.peerNickname + } + return "user" + } + + @MainActor + private func registerTransfer(transferId: String, messageID: String) { + transferIdToMessageID[transferId] = messageID + messageIDToTransferId[messageID] = transferId + } + + @MainActor + private func clearTransferMapping(for messageID: String) { + if let transferId = messageIDToTransferId.removeValue(forKey: messageID) { + transferIdToMessageID.removeValue(forKey: transferId) + } + } + + @MainActor + private func handleMediaSendFailure(messageID: String, reason: String) { + updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) + clearTransferMapping(for: messageID) + } + + @MainActor + private func handleTransferEvent(_ event: TransferProgressManager.Event) { + switch event { + case .started(let id, let total): + guard let messageID = transferIdToMessageID[id] else { return } + updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) + case .updated(let id, let sent, let total): + guard let messageID = transferIdToMessageID[id] else { return } + updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) + case .completed(let id, _): + guard let messageID = transferIdToMessageID[id] else { return } + updateMessageDeliveryStatus(messageID, status: .sent) + clearTransferMapping(for: messageID) + case .cancelled(let id, _, _): + guard let messageID = transferIdToMessageID[id] else { return } + clearTransferMapping(for: messageID) + removeMessage(withID: messageID, cleanupFile: true) + } + } + + private func cleanupLocalFile(forMessage message: BitchatMessage) { + let prefixes = ["[voice] ", "[image] ", "[file] "] + guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return } + let path = String(message.content.dropFirst(prefix.count)) + if FileManager.default.fileExists(atPath: path) { + try? FileManager.default.removeItem(atPath: path) + } + } + + private func prepareOutgoingFileCopy(from sourceURL: URL, data: Data) throws -> URL { + let base = try applicationFilesDirectory().appendingPathComponent("files/outgoing", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil) + + let rawName = sourceURL.lastPathComponent + let sanitized = sanitizeOutgoingFileName(rawName.isEmpty ? "file" : rawName) + var destination = base.appendingPathComponent(sanitized) + var counter = 1 + while FileManager.default.fileExists(atPath: destination.path) { + let baseName = (sanitized as NSString).deletingPathExtension + let ext = (sanitized as NSString).pathExtension + let newName: String + if ext.isEmpty { + newName = "\(baseName) (\(counter))" + } else { + newName = "\(baseName) (\(counter)).\(ext)" + } + destination = base.appendingPathComponent(newName) + counter += 1 + } + + try data.write(to: destination, options: .atomic) + return destination + } + + private func sanitizeOutgoingFileName(_ name: String) -> String { + var candidate = name + candidate = candidate.replacingOccurrences(of: "\\", with: "/") + candidate = candidate.components(separatedBy: "/").last ?? name + candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + if candidate.isEmpty { candidate = "file" } + let invalid = CharacterSet(charactersIn: "<>:\"|?*") + candidate = candidate.components(separatedBy: invalid).joined(separator: "_") + if candidate.isEmpty { candidate = "file" } + if (candidate as NSString).pathExtension.isEmpty { + candidate += ".bin" + } + return candidate + } + + private func mimeType(for url: URL) -> String { + let ext = url.pathExtension.lowercased() + if !ext.isEmpty, + let type = UTType(filenameExtension: ext), + let mime = type.preferredMIMEType { + return mime + } + return "application/octet-stream" + } + + private func applicationFilesDirectory() throws -> URL { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let filesDir = base.appendingPathComponent("files", isDirectory: true) + try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) + return filesDir + } + + @MainActor + private func removeMessage(withID messageID: String, cleanupFile: Bool = false) { + var removedMessage: BitchatMessage? + + if let idx = messages.firstIndex(where: { $0.id == messageID }) { + removedMessage = messages.remove(at: idx) + } + + meshTimeline.removeAll { $0.id == messageID } + + for key in Array(geoTimelines.keys) { + var entries = geoTimelines[key] ?? [] + if let idx = entries.firstIndex(where: { $0.id == messageID }) { + removedMessage = removedMessage ?? entries[idx] + entries.remove(at: idx) + if entries.isEmpty { + geoTimelines.removeValue(forKey: key) + } else { + geoTimelines[key] = entries + } + } + } + + var chats = privateChats + for (peerID, items) in chats { + let filtered = items.filter { $0.id != messageID } + if filtered.count != items.count { + if filtered.isEmpty { + chats.removeValue(forKey: peerID) + } else { + chats[peerID] = filtered + } + if removedMessage == nil { + removedMessage = items.first(where: { $0.id == messageID }) + } + } + } + privateChats = chats + + if cleanupFile, let message = removedMessage { + cleanupLocalFile(forMessage: message) + } + + objectWillChange.send() + } + // MARK: - Geohash DMs initiation @MainActor func startGeohashDM(withPubkeyHex hex: String) { @@ -3655,6 +4070,53 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { return result } + + @MainActor + func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + let isSelf: Bool = { + if let spid = message.senderPeerID { + if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") { + if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))" + } + } + return spid == meshService.myPeerID + } + if message.sender == nickname { return true } + if message.sender.hasPrefix(nickname + "#") { return true } + return false + }() + + let isDark = colorScheme == .dark + let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) + + if message.sender == "system" { + var style = AttributeContainer() + style.foregroundColor = baseColor + style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) + return AttributedString(message.sender).mergingAttributes(style) + } + + var result = AttributedString() + let (baseName, suffix) = message.sender.splitSuffix() + var senderStyle = AttributeContainer() + senderStyle.foregroundColor = baseColor + senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced) + if let spid = message.senderPeerID, + let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") { + senderStyle.link = url + } + + result.append(AttributedString("<@").mergingAttributes(senderStyle)) + result.append(AttributedString(baseName).mergingAttributes(senderStyle)) + if !suffix.isEmpty { + var suffixStyle = senderStyle + suffixStyle.foregroundColor = baseColor.opacity(0.6) + result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) + } + result.append(AttributedString("> ").mergingAttributes(senderStyle)) + return result + } func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { var result = AttributedString() diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index cffad5c4..d13f7586 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -9,7 +9,12 @@ import SwiftUI #if os(iOS) import UIKit +import PhotosUI +#else +import AppKit #endif +import UniformTypeIdentifiers +import BitLogger // MARK: - Supporting Types @@ -17,6 +22,11 @@ import UIKit // +private struct MessageDisplayItem: Identifiable { + let id: String + let message: BitchatMessage +} + // MARK: - Main Content View struct ContentView: View { @@ -27,7 +37,6 @@ struct ContentView: View { @ObservedObject private var bookmarks = GeohashBookmarksStore.shared @ObservedObject private var notesCounter = LocationNotesCounter.shared @State private var messageText = "" - @State private var textFieldSelection: NSRange? = nil @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) private var dismiss @@ -52,6 +61,20 @@ struct ContentView: View { @State private var showLocationNotes = false @State private var notesGeohash: String? = nil @State private var sheetNotesCount: Int = 0 + @State private var imagePreviewURL: URL? = nil + @State private var recordingAlertMessage: String = "" + @State private var showRecordingAlert = false + @State private var isRecordingVoiceNote = false + @State private var isPreparingVoiceNote = false + @State private var recordingDuration: TimeInterval = 0 + @State private var recordingTimer: Timer? + @State private var showImageImporter = false + @State private var showFileImporter = false +#if os(iOS) + @State private var showPhotoPicker = false + @State private var selectedPhotoPickerItem: PhotosPickerItem? +#endif + @State private var showAttachmentActions = false @ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44 @ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11 @ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12 @@ -175,6 +198,51 @@ struct ContentView: View { FingerprintView(viewModel: viewModel, peerID: peerID) } } +#if os(iOS) + .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoPickerItem, matching: .images) + .onChange(of: selectedPhotoPickerItem) { newItem in + guard let item = newItem else { return } + Task { await handlePhotoSelection(item) } + } +#else + .fileImporter(isPresented: $showImageImporter, allowedContentTypes: [.image], allowsMultipleSelection: false) { result in + handleImportResult(result, handler: handleImportedImage) + } +#endif + .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in + handleImportResult(result, handler: handleImportedFile) + } + .sheet(isPresented: Binding( + get: { imagePreviewURL != nil }, + set: { presenting in if !presenting { imagePreviewURL = nil } } + )) { + if let url = imagePreviewURL { + ImagePreviewView(url: url) + } + } + .confirmationDialog("Attach", isPresented: $showAttachmentActions, titleVisibility: .visible) { +#if os(iOS) + Button("Image") { + showAttachmentActions = false + DispatchQueue.main.async { showPhotoPicker = true } + } +#else + Button("Image") { + showAttachmentActions = false + DispatchQueue.main.async { showImageImporter = true } + } +#endif + Button("File") { + showAttachmentActions = false + DispatchQueue.main.async { showFileImporter = true } + } + Button("Cancel", role: .cancel) {} + } + .alert("Recording Error", isPresented: $showRecordingAlert, actions: { + Button("OK", role: .cancel) {} + }, message: { + Text(recordingAlertMessage) + }) .confirmationDialog( selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"), isPresented: $showMessageActions, @@ -250,237 +318,97 @@ struct ContentView: View { // MARK: - Message List View private func messagesView(privatePeer: String?, isAtBottom: Binding) -> some View { - ScrollViewReader { proxy in + let messages: [BitchatMessage] = { + if let privatePeer = privatePeer { + return viewModel.getPrivateChatMessages(for: privatePeer) + } + return viewModel.messages + }() + + let currentWindowCount: Int = { + if let peer = privatePeer { + return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate + } + return windowCountPublic + }() + + let windowedMessages: [BitchatMessage] = Array(messages.suffix(currentWindowCount)) + + let contextKey: String = { + if let peer = privatePeer { return "dm:\(peer)" } + switch locationManager.selectedChannel { + case .mesh: return "mesh" + case .location(let ch): return "geo:\(ch.geohash)" + } + }() + + let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in + let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message) + } + + return ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - // Extract messages based on context (private or public chat) - let messages: [BitchatMessage] = { - if let privatePeer = privatePeer { - let msgs = viewModel.getPrivateChatMessages(for: privatePeer) - return msgs - } else { - return viewModel.messages - } - }() - - // Implement windowing with adjustable window count per chat - let currentWindowCount: Int = { - if let peer = privatePeer { return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate } - return windowCountPublic - }() - let windowedMessages = messages.suffix(currentWindowCount) - - // Build stable UI IDs with a context key to avoid ID collisions when switching channels - let contextKey: String = { - if let peer = privatePeer { return "dm:\(peer)" } - switch locationManager.selectedChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } - // Filter out empty/whitespace-only messages to avoid blank rows - let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - - ForEach(filteredItems, id: \.uiID) { item in + ForEach(messageItems) { item in let message = item.message - VStack(alignment: .leading, spacing: 0) { - // Check if current user is mentioned - - if message.sender == "system" { - // System messages - Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs) - } - } - .id(item.uiID) - .onAppear { - // Track if last item is visible to enable auto-scroll only when near bottom - if message.id == windowedMessages.last?.id { - isAtBottom.wrappedValue = true - } - // Infinite scroll up: when top row appears, increase window and preserve anchor - if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { - let step = TransportConfig.uiWindowStepCount - let contextKey: String = { - if let peer = privatePeer { return "dm:\(peer)" } - switch locationManager.selectedChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - let preserveID = "\(contextKey)|\(message.id)" - if let peer = privatePeer { - let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate - let newCount = min(messages.count, current + step) - if newCount != current { - windowCountPrivate[peer] = newCount - DispatchQueue.main.async { - proxy.scrollTo(preserveID, anchor: .top) - } - } - } else { - let current = windowCountPublic - let newCount = min(messages.count, current + step) - if newCount != current { - windowCountPublic = newCount - DispatchQueue.main.async { - proxy.scrollTo(preserveID, anchor: .top) - } - } + messageRow(for: message) + .id(item.id) + .onAppear { + if message.id == windowedMessages.last?.id { + isAtBottom.wrappedValue = true + } + if message.id == windowedMessages.first?.id, + messages.count > windowedMessages.count { + expandWindow( + ifNeededFor: message, + allMessages: messages, + privatePeer: privatePeer, + proxy: proxy + ) } } - } - .onDisappear { - if message.id == windowedMessages.last?.id { - isAtBottom.wrappedValue = false + .onDisappear { + if message.id == windowedMessages.last?.id { + isAtBottom.wrappedValue = false + } } - } - .contentShape(Rectangle()) - .onTapGesture { - // Tap on message body: insert @mention for this sender - if message.sender != "system" { - let name = message.sender - messageText = "@\(name) " - isTextFieldFocused = true + .contentShape(Rectangle()) + .onTapGesture { + if message.sender != "system" { + messageText = "@\(message.sender) " + isTextFieldFocused = true + } } - } - .contextMenu { - Button("content.message.copy") { - #if os(iOS) - UIPasteboard.general.string = message.content - #else - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(message.content, forType: .string) - #endif + .contextMenu { + Button("content.message.copy") { + #if os(iOS) + UIPasteboard.general.string = message.content + #else + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(message.content, forType: .string) + #endif + } } - } - .padding(.horizontal, 12) - .padding(.vertical, 2) + .padding(.horizontal, 12) + .padding(.vertical, 2) } } .transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } } .padding(.vertical, 4) } .background(backgroundColor) - .onOpenURL { url in - guard url.scheme == "bitchat", url.host == "user" else { return } - let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let peerID = id.removingPercentEncoding ?? id - selectedMessageSenderID = peerID - // Derive a stable display name from the peerID instead of peeking at the last message, - // which may be a transformed system action (sender == "system"). - if peerID.hasPrefix("nostr") { - // For geohash senders, resolve display name via mapping (works for "nostr:" and "nostr_" keys) - selectedMessageSender = viewModel.geohashDisplayName(for: peerID) - } else { - // Mesh sender: use current mesh nickname if available; otherwise fall back to last non-system message - if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: peerID)) { - selectedMessageSender = name - } else { - selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender - } - } - if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) { - selectedMessageSender = nil - selectedMessageSenderID = nil - } else { - showMessageActions = true - } - } - .onOpenURL { url in - guard url.scheme == "bitchat", url.host == "geohash" else { return } - let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() - let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") - guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } - func levelForLength(_ len: Int) -> GeohashChannelLevel { - switch len { - case 0...2: return .region - case 3...4: return .province - case 5: return .city - case 6: return .neighborhood - case 7: return .block - default: return .block - } - } - let level = levelForLength(gh.count) - let ch = GeohashChannel(level: level, geohash: gh) - // Do not mark teleported when opening a geohash that is in our regional set. - // If availableChannels is empty (e.g., cold start), defer marking and let - // LocationChannelManager compute teleported based on actual location. - let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh } - if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty { - LocationChannelManager.shared.markTeleported(for: gh, true) - } - LocationChannelManager.shared.select(ChannelID.location(ch)) - } + .onOpenURL { handleOpenURL($0) } .onTapGesture(count: 3) { - // Triple-tap to clear current chat viewModel.sendMessage("/clear") } .onAppear { - // Force scroll to bottom when opening a chat view - let targetID: String? = { - if let peer = privatePeer, - let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { - return "dm:\(peer)|\(last)" - } - let contextKey: String = { - switch locationManager.selectedChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } - return nil - }() - isAtBottom.wrappedValue = true - DispatchQueue.main.async { - if let target = targetID { proxy.scrollTo(target, anchor: .bottom) } - } - // Second pass after a brief delay to handle late layout - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - let targetID2: String? = { - if let peer = privatePeer, - let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { - return "dm:\(peer)|\(last)" - } - let contextKey: String = { - switch locationManager.selectedChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } - return nil - }() - if let t2 = targetID2 { proxy.scrollTo(t2, anchor: .bottom) } - } + scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom) } .onChange(of: privatePeer) { _ in - // When switching to a different private chat, jump to bottom - let targetID: String? = { - if let peer = privatePeer, - let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { - return "dm:\(peer)|\(last)" - } - let contextKey: String = { - switch locationManager.selectedChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } - return nil - }() - isAtBottom.wrappedValue = true - DispatchQueue.main.async { - if let target = targetID { proxy.scrollTo(target, anchor: .bottom) } - } + scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom) } .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { @@ -617,9 +545,10 @@ struct ContentView: View { } // MARK: - Input View - + + @ViewBuilder private var inputView: some View { - VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 6) { // @mentions autocomplete if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty { VStack(alignment: .leading, spacing: 0) { @@ -649,7 +578,7 @@ struct ContentView: View { ) .padding(.horizontal, 12) } - + // Command suggestions if showCommandSuggestions && !commandSuggestions.isEmpty { VStack(alignment: .leading, spacing: 0) { @@ -670,10 +599,10 @@ struct ContentView: View { (["/unfav"], "", "remove from favorites") ] let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo) - + // Build the display let allCommands = commandInfo - + // Show matching commands ForEach(commandSuggestions, id: \.self) { command in // Find the command info for this suggestion @@ -690,16 +619,16 @@ struct ContentView: View { .font(.bitchatSystem(size: 11, design: .monospaced)) .foregroundColor(textColor) .fontWeight(.medium) - + // Show syntax if any if let syntax = info.syntax { Text(syntax) .font(.bitchatSystem(size: 10, design: .monospaced)) .foregroundColor(secondaryTextColor.opacity(0.8)) } - + Spacer() - + // Show description Text(info.description) .font(.bitchatSystem(size: 10, design: .monospaced)) @@ -721,26 +650,49 @@ struct ContentView: View { ) .padding(.horizontal, 12) } - + + // Recording indicator + if isPreparingVoiceNote || isRecordingVoiceNote { + recordingIndicator + } + HStack(alignment: .center, spacing: 4) { - TextField("content.input.message_placeholder", text: $messageText) + TextField( + "", + text: $messageText, + prompt: Text( + String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") + ) + .foregroundColor(secondaryTextColor.opacity(0.6)) + ) .textFieldStyle(.plain) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .font(.bitchatSystem(size: 15, design: .monospaced)) .foregroundColor(textColor) .focused($isTextFieldFocused) - .padding(.leading, 12) - // iOS keyboard autocomplete and capitalization enabled by default + .autocorrectionDisabled(true) +#if os(iOS) + .textInputAutocapitalization(.sentences) +#endif + .submitLabel(.send) + .onSubmit { sendMessage() } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7)) + ) + .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: messageText) { newValue in // Cancel previous debounce timer autocompleteDebounceTimer?.invalidate() - + // Debounce autocomplete updates to reduce calls during rapid typing autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in // Get cursor position (approximate - end of text for now) let cursorPosition = newValue.count viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) } - + // Check for command autocomplete (instant, no debounce needed) if newValue.hasPrefix("/") && newValue.count >= 1 { // Build context-aware command list @@ -763,20 +715,20 @@ struct ContentView: View { commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command"))) commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command"))) } - + let input = newValue.lowercased() - + // Map of aliases to primary commands let aliases: [String: String] = [ "/join": "/j", "/msg": "/m" ] - + // Filter commands, but convert aliases to primary commandSuggestions = commandDescriptions .filter { $0.0.starts(with: input) } .map { $0.0 } - + // Also check if input matches an alias for (alias, primary) in aliases { if alias.starts(with: input) && !commandSuggestions.contains(primary) { @@ -785,7 +737,7 @@ struct ContentView: View { } } } - + // Remove duplicates and sort commandSuggestions = Array(Set(commandSuggestions)).sorted() showCommandSuggestions = !commandSuggestions.isEmpty @@ -794,43 +746,79 @@ struct ContentView: View { commandSuggestions = [] } } - .onSubmit { - sendMessage() + + HStack(alignment: .center, spacing: 4) { + if shouldShowMediaControls { + attachmentButton + } + + sendOrMicButton } - - Button(action: sendMessage) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 20)) - .foregroundColor(messageText.isEmpty ? Color.gray : - viewModel.selectedPrivateChatPeer != nil - ? Color.orange : textColor) } - .buttonStyle(.plain) - .padding(.trailing, 12) - .accessibilityLabel( - String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button") - ) - .accessibilityHint( - messageText.isEmpty - ? String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message") - : String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message") - ) - } - .padding(.vertical, 8) - .background(backgroundColor.opacity(0.95)) } - .onAppear { - // Delay keyboard focus to avoid iOS constraint warnings - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { - isTextFieldFocused = true + .padding(.horizontal, 6) + .padding(.top, 6) + .padding(.bottom, 8) + .background(backgroundColor.opacity(0.95)) + } + + private func handleOpenURL(_ url: URL) { + guard url.scheme == "bitchat", url.host == "user" else { return } + let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let peerID = id.removingPercentEncoding ?? id + selectedMessageSenderID = peerID + + if peerID.hasPrefix("nostr") { + selectedMessageSender = viewModel.geohashDisplayName(for: peerID) + } else { + if let name = viewModel.meshService.peerNickname(peerID: peerID) { + selectedMessageSender = name + } else { + selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender } } + + if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) { + selectedMessageSender = nil + selectedMessageSenderID = nil + } else { + showMessageActions = true + } + } + + private func scrollToBottom(on proxy: ScrollViewProxy, + privatePeer: String?, + isAtBottom: Binding) { + let targetID: String? = { + if let peer = privatePeer, + let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { + return "dm:\(peer)|\(last)" + } + let contextKey: String = { + switch locationManager.selectedChannel { + case .mesh: return "mesh" + case .location(let ch): return "geo:\(ch.geohash)" + } + }() + if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } + return nil + }() + + isAtBottom.wrappedValue = true + guard let target = targetID else { return } + DispatchQueue.main.async { + proxy.scrollTo(target, anchor: .bottom) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + proxy.scrollTo(target, anchor: .bottom) + } } - // MARK: - Actions private func sendMessage() { - viewModel.sendMessage(messageText) + let trimmed = trimmedMessageText + guard !trimmed.isEmpty else { return } + viewModel.sendMessage(trimmed) messageText = "" } @@ -1185,6 +1173,19 @@ struct ContentView: View { isNostrAvailable: isNostrAvailable ) } + + // Split a name into base and a '#abcd' suffix if present + private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) { + guard name.count >= 5 else { return (name, "") } + let suffix = String(name.suffix(5)) + if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in + ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) + }) { + let base = String(name.dropLast(5)) + return (base, suffix) + } + return (name, "") + } // Compute channel-aware people count and color for toolbar (cross-platform) private func channelPeopleCountAndColor() -> (Int, Color) { @@ -1508,3 +1509,808 @@ extension ContentView { } } } + +// MARK: - Helper Views + +// Rounded payment chip button +private struct PaymentChipView: View { + let emoji: String + let label: String + let colorScheme: ColorScheme + let action: () -> Void + + private var fgColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + private var bgColor: Color { + colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) + } + private var border: Color { fgColor.opacity(0.25) } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Text(emoji) + Text(label) + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(bgColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(border, lineWidth: 1) + ) + .foregroundColor(fgColor) + } + .buttonStyle(.plain) + } +} + +// + +private enum MessageMedia { + case voice(URL) + case image(URL) + case file(URL) + + var url: URL { + switch self { + case .voice(let url), .image(let url), .file(let url): + return url + } + } +} + +private extension ContentView { + func mediaAttachment(for message: BitchatMessage) -> MessageMedia? { + guard let baseDirectory = applicationFilesDirectory() else { return nil } + let basePath = baseDirectory.standardizedFileURL.path + + func url(from prefix: String) -> URL? { + guard message.content.hasPrefix(prefix) else { return nil } + let rawPath = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawPath.isEmpty else { return nil } + let url = URL(fileURLWithPath: rawPath) + guard url.isFileURL else { return nil } + let standardized = url.standardizedFileURL + let path = standardized.path + guard path == basePath || path.hasPrefix(basePath + "/") else { return nil } + guard FileManager.default.fileExists(atPath: path) else { return nil } + return standardized + } + + if let url = url(from: "[voice] ") { return .voice(url) } + if let url = url(from: "[image] ") { return .image(url) } + if let url = url(from: "[file] ") { return .file(url) } + return nil + } + + func mediaSendState(for message: BitchatMessage, mediaURL: URL) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + var isSending = false + var progress: Double? + if let status = message.deliveryStatus { + switch status { + case .sending: + isSending = true + progress = 0 + case .partiallyDelivered(let reached, let total): + if total > 0 { + isSending = true + progress = Double(reached) / Double(total) + } + default: + break + } + } + let isOutgoing = mediaURL.path.contains("/outgoing/") + let canCancel = isSending && isOutgoing + let clamped = progress.map { max(0, min(1, $0)) } + return (isSending, isSending ? clamped : nil, canCancel) + } + + @ViewBuilder + private func messageRow(for message: BitchatMessage) -> some View { + if message.sender == "system" { + systemMessageRow(message) + } else if let media = mediaAttachment(for: message) { + mediaMessageRow(message: message, media: media) + } else { + textMessageRow(message) + } + } + + @ViewBuilder + private func systemMessageRow(_ message: BitchatMessage) -> some View { + Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func mediaMessageRow(message: BitchatMessage, media: MessageMedia) -> some View { + let mediaURL = media.url + let state = mediaSendState(for: message, mediaURL: mediaURL) + let isOutgoing = mediaURL.path.contains("/outgoing/") + let isAuthoredByUs = isOutgoing || (message.senderPeerID == viewModel.meshService.myPeerID) + let shouldBlurImage = !isAuthoredByUs + let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 4) { + Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status, colorScheme: colorScheme) + .padding(.leading, 4) + } + } + + Group { + switch media { + case .voice(let url): + VoiceNoteView( + url: url, + isSending: state.isSending, + sendProgress: state.progress, + onCancel: cancelAction + ) + case .image(let url): + BlockRevealImageView( + url: url, + revealProgress: state.progress, + isSending: state.isSending, + onCancel: cancelAction, + initiallyBlurred: shouldBlurImage, + onOpen: { + if !state.isSending { + imagePreviewURL = url + } + }, + onDelete: shouldBlurImage ? { + viewModel.deleteMediaMessage(messageID: message.id) + } : nil + ) + .frame(maxWidth: 280) + case .file(let url): + FileAttachmentView( + url: url, + isSending: state.isSending, + progress: state.progress, + onCancel: cancelAction + ) + } + } + } + .padding(.vertical, 6) + } + + @ViewBuilder + private func textMessageRow(_ message: BitchatMessage) -> some View { + let cashuTokens = message.content.extractCashuTokens() + let lightningLinks = message.content.extractLightningLinks() + let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty + let isExpanded = expandedMessageIDs.contains(message.id) + + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 0) { + Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) + .frame(maxWidth: .infinity, alignment: .leading) + + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status, colorScheme: colorScheme) + .padding(.leading, 4) + } + } + + if isLong && cashuTokens.isEmpty { + let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") + Button(labelKey) { + if isExpanded { expandedMessageIDs.remove(message.id) } + else { expandedMessageIDs.insert(message.id) } + } + .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(Color.blue) + .padding(.top, 4) + } + + if !lightningLinks.isEmpty || !cashuTokens.isEmpty { + HStack(spacing: 8) { + ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { index in + let link = lightningLinks[index] + PaymentChipView( + emoji: "⚡", + label: L10n.string( + "content.payment.lightning", + comment: "Label for Lightning payment chip" + ), + colorScheme: colorScheme + ) { + #if os(iOS) + if let url = URL(string: link) { UIApplication.shared.open(url) } + #else + if let url = URL(string: link) { NSWorkspace.shared.open(url) } + #endif + } + } + + ForEach(Array(cashuTokens.prefix(3)).indices, id: \.self) { index in + let token = cashuTokens[index] + let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token + let urlStr = "cashu:\(enc)" + PaymentChipView( + emoji: "🥜", + label: L10n.string( + "content.payment.cashu", + comment: "Label for Cashu payment chip" + ), + colorScheme: colorScheme + ) { + #if os(iOS) + if let url = URL(string: urlStr) { UIApplication.shared.open(url) } + #else + if let url = URL(string: urlStr) { NSWorkspace.shared.open(url) } + #endif + } + } + } + .padding(.top, 6) + .padding(.leading, 2) + } + } + } + + private func expandWindow(ifNeededFor message: BitchatMessage, + allMessages: [BitchatMessage], + privatePeer: String?, + proxy: ScrollViewProxy) { + let step = TransportConfig.uiWindowStepCount + let contextKey: String = { + if let peer = privatePeer { return "dm:\(peer)" } + switch locationManager.selectedChannel { + case .mesh: return "mesh" + case .location(let ch): return "geo:\(ch.geohash)" + } + }() + let preserveID = "\(contextKey)|\(message.id)" + + if let peer = privatePeer { + let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate + let newCount = min(allMessages.count, current + step) + guard newCount != current else { return } + windowCountPrivate[peer] = newCount + DispatchQueue.main.async { + proxy.scrollTo(preserveID, anchor: .top) + } + } else { + let current = windowCountPublic + let newCount = min(allMessages.count, current + step) + guard newCount != current else { return } + windowCountPublic = newCount + DispatchQueue.main.async { + proxy.scrollTo(preserveID, anchor: .top) + } + } + } + + var recordingIndicator: some View { + HStack(spacing: 12) { + Image(systemName: "waveform.circle.fill") + .foregroundColor(.red) + .font(.bitchatSystem(size: 20)) + Text("Recording \(formattedRecordingDuration())") + .font(.bitchatSystem(size: 13, design: .monospaced)) + .foregroundColor(.red) + Spacer() + Button(action: cancelVoiceRecording) { + Label("Cancel", systemImage: "xmark.circle") + .labelStyle(.iconOnly) + .font(.bitchatSystem(size: 18)) + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.red.opacity(0.15)) + ) + } + + private var trimmedMessageText: String { + messageText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var shouldShowMediaControls: Bool { + if viewModel.selectedPrivateChatPeer != nil { + return true + } + switch locationManager.selectedChannel { + case .mesh: + return true + case .location: + return false + } + } + + private var composerAccentColor: Color { + viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor + } + + var attachmentButton: some View { +#if os(macOS) + Menu { + Button("Image") { + showImageImporter = false + DispatchQueue.main.async { showImageImporter = true } + } + Button("File") { + showFileImporter = false + DispatchQueue.main.async { showFileImporter = true } + } + } label: { + Image(systemName: "paperclip.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(composerAccentColor) + .frame(width: 36, height: 36) + } + .menuStyle(.borderlessButton) +#else + Button(action: { showAttachmentActions = true }) { + Image(systemName: "paperclip.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(composerAccentColor) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) +#endif + } + + var sendOrMicButton: some View { + let hasText = !trimmedMessageText.isEmpty + return ZStack { + micButtonView + .opacity(hasText ? 0 : 1) + .allowsHitTesting(!hasText) + sendButtonView(enabled: hasText) + .opacity(hasText ? 1 : 0) + .allowsHitTesting(hasText) + } + .frame(width: 36, height: 36) + } + + private var micButtonView: some View { + let tint = (isRecordingVoiceNote || isPreparingVoiceNote) ? Color.red : composerAccentColor + + return Image(systemName: "mic.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(tint) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .overlay( + Color.clear + .contentShape(Circle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in startVoiceRecording() } + .onEnded { _ in finishVoiceRecording(send: true) } + ) + ) + .accessibilityLabel("Hold to record a voice note") + } + + private func sendButtonView(enabled: Bool) -> some View { + let activeColor = composerAccentColor + return Button(action: sendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(enabled ? activeColor : Color.gray) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .disabled(!enabled) + .accessibilityLabel( + L10n.string( + "content.accessibility.send_message", + comment: "Accessibility label for the send message button" + ) + ) + .accessibilityHint( + enabled + ? L10n.string( + "content.accessibility.send_hint_ready", + comment: "Hint prompting the user to send the message" + ) + : L10n.string( + "content.accessibility.send_hint_empty", + comment: "Hint prompting the user to enter a message" + ) + ) + } + + func formattedRecordingDuration() -> String { + let clamped = max(0, recordingDuration) + let minutes = Int(clamped) / 60 + let seconds = Int(clamped) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + func startVoiceRecording() { + guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return } + isPreparingVoiceNote = true + Task { @MainActor in + let granted = await VoiceRecorder.shared.requestPermission() + guard granted else { + isPreparingVoiceNote = false + recordingAlertMessage = "Microphone access is required to record voice notes." + showRecordingAlert = true + return + } + do { + _ = try VoiceRecorder.shared.startRecording() + recordingDuration = 0 + recordingTimer?.invalidate() + recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + recordingDuration += 0.1 + } + if let timer = recordingTimer { + RunLoop.main.add(timer, forMode: .common) + } + isPreparingVoiceNote = false + isRecordingVoiceNote = true + } catch { + SecureLogger.error("Voice recording failed to start: \(error)", category: .session) + recordingAlertMessage = "Could not start recording." + showRecordingAlert = true + VoiceRecorder.shared.cancelRecording() + isPreparingVoiceNote = false + isRecordingVoiceNote = false + } + } + } + + func finishVoiceRecording(send: Bool) { + if isPreparingVoiceNote { + isPreparingVoiceNote = false + VoiceRecorder.shared.cancelRecording() + return + } + guard isRecordingVoiceNote else { return } + isRecordingVoiceNote = false + recordingTimer?.invalidate() + recordingTimer = nil + if send { + let minimumDuration: TimeInterval = 1.0 + VoiceRecorder.shared.stopRecording { url in + DispatchQueue.main.async { + guard + let url = url, + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let fileSize = attributes[.size] as? NSNumber, + fileSize.intValue > 0, + recordingDuration >= minimumDuration + else { + if let url = url { + try? FileManager.default.removeItem(at: url) + } + recordingAlertMessage = recordingDuration < minimumDuration + ? "Recording is too short." + : "Recording failed to save." + showRecordingAlert = true + return + } + viewModel.sendVoiceNote(at: url) + } + } + } else { + VoiceRecorder.shared.cancelRecording() + } + } + + func cancelVoiceRecording() { + if isPreparingVoiceNote || isRecordingVoiceNote { + finishVoiceRecording(send: false) + } + } + + func handleImportResult(_ result: Result<[URL], Error>, handler: @escaping (URL) async -> Void) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + let needsStop = url.startAccessingSecurityScopedResource() + Task { + defer { + if needsStop { + url.stopAccessingSecurityScopedResource() + } + } + await handler(url) + } + case .failure(let error): + SecureLogger.error("Media import failed: \(error)", category: .session) + } + } + +#if os(iOS) + func handlePhotoSelection(_ item: PhotosPickerItem) async { + defer { Task { @MainActor in selectedPhotoPickerItem = nil } } + do { + if let data = try await item.loadTransferable(type: Data.self) { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + try data.write(to: tempURL, options: .atomic) + await MainActor.run { + viewModel.sendImage(from: tempURL) + } + } + } catch { + SecureLogger.error("Photo picker load failed: \(error)", category: .session) + } + } +#endif + + func handleImportedImage(url: URL) async { + await MainActor.run { + viewModel.sendImage(from: url) + } + } + + func handleImportedFile(url: URL) async { + await MainActor.run { + viewModel.sendFileAttachment(from: url) + } + } + + func applicationFilesDirectory() -> URL? { + do { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + return base.appendingPathComponent("files", isDirectory: true) + } catch { + SecureLogger.error("Failed to resolve application files directory: \(error)", category: .session) + return nil + } + } +} + +// + +// Delivery status indicator view +struct DeliveryStatusView: View { + let status: DeliveryStatus + let colorScheme: ColorScheme + + // MARK: - Computed Properties + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + private var secondaryTextColor: Color { + colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) + } + + private enum Strings { + static func delivered(to nickname: String) -> String { + L10n.string( + "content.delivery.delivered_to", + comment: "Tooltip for delivered private messages", + nickname + ) + } + + static func read(by nickname: String) -> String { + L10n.string( + "content.delivery.read_by", + comment: "Tooltip for read private messages", + nickname + ) + } + + static func failed(_ reason: String) -> String { + L10n.string( + "content.delivery.failed", + comment: "Tooltip for failed message delivery", + reason + ) + } + + static func deliveredToMembers(_ reached: Int, _ total: Int) -> String { + L10n.string( + "content.delivery.delivered_members", + comment: "Tooltip for partially delivered messages", + reached, + total + ) + } + } + + // MARK: - Body + + var body: some View { + switch status { + case .sending: + Image(systemName: "circle") + .font(.bitchatSystem(size: 10)) + .foregroundColor(secondaryTextColor.opacity(0.6)) + + case .sent: + Image(systemName: "checkmark") + .font(.bitchatSystem(size: 10)) + .foregroundColor(secondaryTextColor.opacity(0.6)) + + case .delivered(let nickname, _): + HStack(spacing: -2) { + Image(systemName: "checkmark") + .font(.bitchatSystem(size: 10)) + Image(systemName: "checkmark") + .font(.bitchatSystem(size: 10)) + } + .foregroundColor(textColor.opacity(0.8)) + .help(Strings.delivered(to: nickname)) + + case .read(let nickname, _): + HStack(spacing: -2) { + Image(systemName: "checkmark") + .font(.bitchatSystem(size: 10, weight: .bold)) + Image(systemName: "checkmark") + .font(.bitchatSystem(size: 10, weight: .bold)) + } + .foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue + .help(Strings.read(by: nickname)) + + case .failed(let reason): + Image(systemName: "exclamationmark.triangle") + .font(.bitchatSystem(size: 10)) + .foregroundColor(Color.red.opacity(0.8)) + .help(Strings.failed(reason)) + + case .partiallyDelivered(let reached, let total): + HStack(spacing: 1) { + Image(systemName: "checkmark") + .font(.bitchatSystem(size: 10)) + Text("\(reached)/\(total)") + .font(.bitchatSystem(size: 10, design: .monospaced)) + } + .foregroundColor(secondaryTextColor.opacity(0.6)) + .help(Strings.deliveredToMembers(reached, total)) + } + } +} + +struct ImagePreviewView: View { + let url: URL + + @Environment(\.dismiss) private var dismiss + #if os(iOS) + @State private var showExporter = false + @State private var platformImage: UIImage? + #else + @State private var platformImage: NSImage? + #endif + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + VStack { + Spacer() + if let image = platformImage { + #if os(iOS) + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + #else + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + #endif + } else { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } + Spacer() + HStack { + Button(action: { dismiss() }) { + Text("Close") + .font(.bitchatSystem(size: 15, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1)) + } + Spacer() + Button(action: saveCopy) { + Text("Save") + .font(.bitchatSystem(size: 15, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6))) + } + } + .padding([.horizontal, .bottom], 24) + } + } + .onAppear(perform: loadImage) + #if os(iOS) + .sheet(isPresented: $showExporter) { + FileExportWrapper(url: url) + } + #endif + } + + private func loadImage() { + DispatchQueue.global(qos: .userInitiated).async { + #if os(iOS) + guard let image = UIImage(contentsOfFile: url.path) else { return } + #else + guard let image = NSImage(contentsOf: url) else { return } + #endif + DispatchQueue.main.async { + self.platformImage = image + } + } + } + + private func saveCopy() { + #if os(iOS) + showExporter = true + #else + do { + guard let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else { + SecureLogger.error("Missing downloads directory for save", category: .session) + return + } + let baseName = url.lastPathComponent + let destination = uniqueFileURL(in: downloads, fileName: baseName) + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.copyItem(at: url, to: destination) + } catch { + SecureLogger.error("Failed to save image preview copy: \(error)", category: .session) + } + #endif + } + +#if !os(iOS) + private func uniqueFileURL(in directory: URL, fileName: String) -> URL { + var candidate = directory.appendingPathComponent(fileName) + let base = (fileName as NSString).deletingPathExtension + let ext = (fileName as NSString).pathExtension + var counter = 1 + while FileManager.default.fileExists(atPath: candidate.path) { + let suffix = " (\(counter))" + let name = ext.isEmpty ? base + suffix : base + suffix + "." + ext + candidate = directory.appendingPathComponent(name) + counter += 1 + } + return candidate + } +#endif + + #if os(iOS) + private struct FileExportWrapper: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let controller = UIDocumentPickerViewController(forExporting: [url]) + controller.shouldShowFileExtensions = true + return controller + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} + } +#endif +} diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift new file mode 100644 index 00000000..78af23b9 --- /dev/null +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -0,0 +1,180 @@ +import SwiftUI + +#if os(iOS) +import UIKit +private typealias PlatformImage = UIImage +#else +import AppKit +private typealias PlatformImage = NSImage +#endif + +struct BlockRevealImageView: View { + private let url: URL + private let revealProgress: Double? + private let isSending: Bool + private let onCancel: (() -> Void)? + private let initiallyBlurred: Bool + private let onOpen: (() -> Void)? + private let onDelete: (() -> Void)? + + @State private var platformImage: PlatformImage? + @State private var aspectRatio: CGFloat = 1 + @State private var isBlurred: Bool = false + + init( + url: URL, + revealProgress: Double?, + isSending: Bool, + onCancel: (() -> Void)?, + initiallyBlurred: Bool = false, + onOpen: (() -> Void)? = nil, + onDelete: (() -> Void)? = nil + ) { + self.url = url + self.revealProgress = revealProgress + self.isSending = isSending + self.onCancel = onCancel + self.initiallyBlurred = initiallyBlurred + self.onOpen = onOpen + self.onDelete = onDelete + } + + private var fraction: Double { + guard let revealProgress = revealProgress else { return 1 } + return max(0, min(1, revealProgress)) + } + + var body: some View { + ZStack(alignment: .topTrailing) { + if let image = platformImage { + Image(platformImage: image) + .resizable() + .aspectRatio(aspectRatio, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + .mask( + BlockRevealMask( + fraction: fraction, + columns: 24, + rows: 16 + ) + .animation(.easeOut(duration: 0.2), value: fraction) + ) + .blur(radius: isBlurred ? 20 : 0) + .overlay { + if isBlurred { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.35)) + .overlay( + Image(systemName: "eye.slash.fill") + .font(.bitchatSystem(size: 24, weight: .semibold)) + .foregroundColor(.white.opacity(0.85)) + ) + } + } + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.2)) + .frame(height: 200) + .overlay( + ProgressView() + .progressViewStyle(.circular) + ) + } + + if let onCancel = onCancel, isSending { + Button(action: onCancel) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 12, weight: .bold)) + .padding(8) + .background(Circle().fill(Color.black.opacity(0.7))) + .foregroundColor(.white) + .padding(8) + } + .buttonStyle(.plain) + } + } + .onAppear { + isBlurred = initiallyBlurred + loadImage() + } + .onChange(of: url) { _ in + isBlurred = initiallyBlurred + loadImage() + } + .gesture(gestureHandler) + } + + private func loadImage() { + DispatchQueue.global(qos: .userInitiated).async { + #if os(iOS) + guard let image = UIImage(contentsOfFile: url.path) else { return } + #else + guard let image = NSImage(contentsOf: url) else { return } + #endif + let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1 + DispatchQueue.main.async { + self.platformImage = image + self.aspectRatio = ratio + } + } + } + + private var gestureHandler: some Gesture { + let doubleTap = TapGesture(count: 2).onEnded { + guard !isSending else { return } + onDelete?() + } + let singleTap = TapGesture().onEnded { + guard !isSending else { return } + if isBlurred { + withAnimation(.easeOut(duration: 0.2)) { + isBlurred = false + } + } else { + onOpen?() + } + } + return doubleTap.exclusively(before: singleTap) + } +} + +private struct BlockRevealMask: Shape { + let fraction: Double + let columns: Int + let rows: Int + + func path(in rect: CGRect) -> Path { + var path = Path() + guard fraction > 0, columns > 0, rows > 0 else { return path } + let totalBlocks = columns * rows + let revealCount = max(0, min(totalBlocks, Int(ceil(fraction * Double(totalBlocks))))) + guard revealCount > 0 else { return path } + let blockWidth = rect.width / CGFloat(columns) + let blockHeight = rect.height / CGFloat(rows) + var remaining = revealCount + for row in 0.. Void)? + + @Environment(\.colorScheme) private var colorScheme + #if os(iOS) + @State private var showExporter = false + #endif + + init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) { + self.url = url + self.isSending = isSending + self.progress = progress + self.onCancel = onCancel + } + + private var fileName: String { + url.lastPathComponent + } + + private var normalizedProgress: Double? { + guard let progress = progress else { return nil } + return max(0, min(1, progress)) + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "doc.fill") + .foregroundColor(Color.blue) + .font(.bitchatSystem(size: 24)) + + VStack(alignment: .leading, spacing: 4) { + Text(fileName) + .font(.bitchatSystem(size: 14, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(2) + Text(url.path) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + if let progress = normalizedProgress { + ProgressView(value: progress) + .progressViewStyle(.linear) + .tint(Color.blue) + } + } + + Spacer() + + Button(action: openFile) { + Text("Open") + .font(.bitchatSystem(size: 13, weight: .semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule().fill(Color.blue.opacity(0.15)) + ) + } + .buttonStyle(.plain) + + if let onCancel = onCancel, isSending { + Button(action: onCancel) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 11, weight: .bold)) + .frame(width: 26, height: 26) + .background(Circle().fill(Color.red.opacity(0.9))) + .foregroundColor(.white) + } + .buttonStyle(.plain) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white) + ) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + #if os(iOS) + .sheet(isPresented: $showExporter) { + FileExportController(url: url) + } + #endif + } + + private func openFile() { + #if os(iOS) + showExporter = true + #else + NSWorkspace.shared.open(url) + #endif + } +} + +#if os(iOS) +import UniformTypeIdentifiers +import UIKit + +private struct FileExportController: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let controller = UIDocumentPickerViewController(forExporting: [url]) + controller.shouldShowFileExtensions = true + return controller + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} +} +#else +import AppKit +#endif diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift new file mode 100644 index 00000000..940e7e59 --- /dev/null +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -0,0 +1,118 @@ +import SwiftUI +import AVFoundation + +struct VoiceNoteView: View { + private let url: URL + private let isSending: Bool + private let sendProgress: Double? + private let onCancel: (() -> Void)? + + @Environment(\.colorScheme) private var colorScheme + @StateObject private var playback: VoiceNotePlaybackController + @State private var waveform: [Float] = [] + + init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) { + self.url = url + self.isSending = isSending + self.sendProgress = sendProgress + self.onCancel = onCancel + _playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url)) + } + + private var samples: [Float] { + if waveform.isEmpty { + return Array(repeating: 0.25, count: 64) + } + return waveform + } + + private var backgroundColor: Color { + colorScheme == .dark ? Color.black.opacity(0.6) : Color.white + } + + private var borderColor: Color { + colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2) + } + + private var durationText: String { + let duration = playback.duration + guard duration.isFinite, duration > 0 else { return "--:--" } + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private var currentText: String { + let current = playback.currentTime + guard current.isFinite, current > 0 else { return "00:00" } + let minutes = Int(current) / 60 + let seconds = Int(current) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private var playbackLabel: String { + playback.isPlaying ? currentText + "/" + durationText : durationText + } + + var body: some View { + HStack(spacing: 12) { + Button(action: playback.togglePlayback) { + Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill") + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Circle().fill(Color.green)) + } + .buttonStyle(.plain) + + WaveformView( + samples: samples, + playbackProgress: playback.progress, + sendProgress: sendProgress, + onSeek: { fraction in + playback.seek(to: fraction) + }, + isInteractive: playback.isPlaying + ) + + Text(playbackLabel) + .font(.bitchatSystem(size: 13, design: .monospaced)) + .foregroundColor(Color.secondary) + + if let onCancel = onCancel, isSending { + Button(action: onCancel) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 12, weight: .bold)) + .frame(width: 28, height: 28) + .background(Circle().fill(Color.red.opacity(0.9))) + .foregroundColor(.white) + } + .buttonStyle(.plain) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(backgroundColor) + .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2) + ) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(borderColor, lineWidth: 1) + ) + .onAppear { + WaveformCache.shared.waveform(for: url, completion: { bins in + self.waveform = bins + }) + playback.replaceURL(url) + } + .onChange(of: url) { newValue in + WaveformCache.shared.waveform(for: newValue, completion: { bins in + self.waveform = bins + }) + playback.replaceURL(newValue) + } + .onDisappear { + playback.stop() + } + } +} diff --git a/bitchat/Views/Media/WaveformView.swift b/bitchat/Views/Media/WaveformView.swift new file mode 100644 index 00000000..063ef44d --- /dev/null +++ b/bitchat/Views/Media/WaveformView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct WaveformView: View { + let samples: [Float] + let playbackProgress: Double + let sendProgress: Double? + let onSeek: ((Double) -> Void)? + let isInteractive: Bool + + private var clampedPlayback: Double { + max(0, min(1, playbackProgress)) + } + + private var clampedSend: Double? { + guard let sendProgress = sendProgress else { return nil } + return max(0, min(1, sendProgress)) + } + + var body: some View { + GeometryReader { geometry in + ZStack { + Canvas { context, size in + guard !samples.isEmpty else { return } + let width = max(size.width, 1) + let height = max(size.height, 1) + let barWidth = max(width / CGFloat(samples.count), 1) + for (index, sample) in samples.enumerated() { + let normalized = max(0, min(sample, 1)) + let barHeight = CGFloat(normalized) * height + let originX = CGFloat(index) * barWidth + let rect = CGRect( + x: originX, + y: (height - barHeight) / 2, + width: max(barWidth * 0.7, 1), + height: barHeight + ) + let binPosition = Double(index) / Double(samples.count) + let color: Color + if binPosition <= clampedPlayback { + color = Color.green + } else if let send = clampedSend, binPosition <= send { + color = Color.blue + } else { + color = Color.gray.opacity(0.35) + } + context.fill(Path(rect), with: .color(color)) + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + + if isInteractive, let onSeek = onSeek { + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + guard geometry.size.width > 0 else { return } + let fraction = max(0, min(1, value.location.x / geometry.size.width)) + onSeek(fraction) + } + ) + } + } + } + .frame(height: 48) + } +}