Add BLE file transfer support and media UX

This commit is contained in:
jack
2025-10-15 00:37:40 +01:00
committed by islam
parent 5267489fa2
commit 2cd90ff813
19 changed files with 4147 additions and 373 deletions
+94
View File
@@ -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)
}
}
@@ -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
}
}
}
+169
View File
@@ -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
}
}
+95
View File
@@ -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..<bins {
let start = bin * samplesPerBin
let end = min(frameLength, start + samplesPerBin)
if start >= end { break }
var sum: Float = 0
var sampleCount = 0
for frame in start..<end {
var sampleValue: Float = 0
for channel in 0..<channelCount {
sampleValue += fabsf(channelData[channel][frame])
}
sum += sampleValue / Float(channelCount)
sampleCount += 1
}
magnitudes[bin] = sampleCount > 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
}
}
}
+2 -2
View File
@@ -22,8 +22,8 @@ struct BitchatPacket: Codable {
var signature: Data? var signature: Data?
var ttl: UInt8 var ttl: UInt8
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) { init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
self.version = 1 self.version = version
self.type = type self.type = type
self.senderID = senderID self.senderID = senderID
self.recipientID = recipientID self.recipientID = recipientID
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import CryptoKit
// MARK: - Hex Encoding/Decoding // MARK: - Hex Encoding/Decoding
@@ -16,6 +17,11 @@ extension Data {
} }
return self.map { String(format: "%02x", $0) }.joined() 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) { init?(hexString: String) {
let len = hexString.count / 2 let len = hexString.count / 2
+130 -105
View File
@@ -22,11 +22,11 @@
/// ///
/// ## Wire Format /// ## Wire Format
/// ``` /// ```
/// Header (Fixed 13 bytes): /// Header (Fixed 14 bytes for v1, 16 bytes for v2):
/// +--------+------+-----+-----------+-------+----------------+ /// +--------+------+-----+-----------+-------+------------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength | /// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes | /// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
/// +--------+------+-----+-----------+-------+----------------+ /// +--------+------+-----+-----------+-------+------------------+
/// ///
/// Variable sections: /// Variable sections:
/// +----------+-------------+---------+------------+ /// +----------+-------------+---------+------------+
@@ -105,10 +105,23 @@ extension Data {
/// their binary wire format representation. /// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian) /// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol { struct BinaryProtocol {
static let headerSize = 13 static let v1HeaderSize = 14
static let v2HeaderSize = 16
static let senderIDSize = 8 static let senderIDSize = 8
static let recipientIDSize = 8 static let recipientIDSize = 8
static let signatureSize = 64 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 { struct Flags {
static let hasRecipient: UInt8 = 0x01 static let hasRecipient: UInt8 = 0x01
@@ -118,70 +131,68 @@ struct BinaryProtocol {
// Encode BitchatPacket to binary format // Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? { static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
var data = Data() let version = packet.version
guard version == 1 || version == 2 else { return nil }
// Try to compress payload if beneficial // Try to compress payload when beneficial, keeping original size for later decoding
var payload = packet.payload var payload = packet.payload
var originalPayloadSize: UInt16? = nil
var isCompressed = false var isCompressed = false
var originalPayloadSize: Int?
if CompressionUtil.shouldCompress(payload) { if CompressionUtil.shouldCompress(payload) {
if let compressedPayload = CompressionUtil.compress(payload) { // Only compress when we can represent the original length in the outbound frame
// Store original size for decompression (2 bytes after payload) let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
originalPayloadSize = UInt16(payload.count) if payload.count <= maxRepresentable,
let compressedPayload = CompressionUtil.compress(payload) {
originalPayloadSize = payload.count
payload = compressedPayload payload = compressedPayload
isCompressed = true isCompressed = true
} else {
} }
} else {
} }
// Header let lengthFieldBytes = lengthFieldSize(for: version)
// Reserve capacity to reduce reallocations. Estimate base size conservatively. let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad let payloadDataSize = payload.count + originalSizeFieldBytes
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255 if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
data.reserveCapacity(estimated) if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
data.append(packet.version)
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.type)
data.append(packet.ttl) data.append(packet.ttl)
// Timestamp (8 bytes, big-endian) for shift in stride(from: 56, through: 0, by: -8) {
for i in (0..<8).reversed() { data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
} }
// Flags
var flags: UInt8 = 0 var flags: UInt8 = 0
if packet.recipientID != nil { if packet.recipientID != nil { flags |= Flags.hasRecipient }
flags |= Flags.hasRecipient if packet.signature != nil { flags |= Flags.hasSignature }
} if isCompressed { flags |= Flags.isCompressed }
if packet.signature != nil {
flags |= Flags.hasSignature
}
if isCompressed {
flags |= Flags.isCompressed
}
data.append(flags) data.append(flags)
// Payload length (2 bytes, big-endian) - includes original size if compressed if version == 2 {
let payloadDataSize = payload.count + (isCompressed ? 2 : 0) let length = UInt32(payloadDataSize)
let payloadLength = UInt16(payloadDataSize) for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
}
data.append(UInt8((payloadLength >> 8) & 0xFF)) } else {
data.append(UInt8(payloadLength & 0xFF)) let length = UInt16(payloadDataSize)
data.append(UInt8((length >> 8) & 0xFF))
// SenderID (exactly 8 bytes) data.append(UInt8(length & 0xFF))
}
let senderBytes = packet.senderID.prefix(senderIDSize) let senderBytes = packet.senderID.prefix(senderIDSize)
data.append(senderBytes) data.append(senderBytes)
if senderBytes.count < senderIDSize { if senderBytes.count < senderIDSize {
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count)) data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
} }
// RecipientID (if present)
if let recipientID = packet.recipientID { if let recipientID = packet.recipientID {
let recipientBytes = recipientID.prefix(recipientIDSize) let recipientBytes = recipientID.prefix(recipientIDSize)
data.append(recipientBytes) data.append(recipientBytes)
@@ -189,30 +200,30 @@ struct BinaryProtocol {
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count)) data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
} }
} }
// Payload (with original size prepended if compressed)
if isCompressed, let originalSize = originalPayloadSize { if isCompressed, let originalSize = originalPayloadSize {
// Prepend original size (2 bytes, big-endian) if version == 2 {
data.append(UInt8((originalSize >> 8) & 0xFF)) let value = UInt32(originalSize)
data.append(UInt8(originalSize & 0xFF)) 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) data.append(payload)
// Signature (if present)
if let signature = packet.signature { if let signature = packet.signature {
data.append(signature.prefix(signatureSize)) data.append(signature.prefix(signatureSize))
} }
// Apply padding to standard block sizes for traffic analysis resistance
if padding { if padding {
let optimalSize = MessagePadding.optimalBlockSize(for: data.count) let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
let paddedData = MessagePadding.pad(data, toSize: optimalSize) return MessagePadding.pad(data, toSize: optimalSize)
return paddedData
} else {
// Caller explicitly requested no padding (e.g., BLE write path)
return data
} }
return data
} }
// Decode binary data to BitchatPacket // Decode binary data to BitchatPacket
@@ -227,87 +238,100 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal // Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? { private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: header + senderID guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
guard raw.count >= headerSize + senderIDSize else { return nil }
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil } guard let base = buf.baseAddress else { return nil }
var offset = 0 var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count } func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Read single byte
func read8() -> UInt8? { func read8() -> UInt8? {
guard require(1) else { return nil } 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 offset += 1
return v return value
} }
// Read big-endian 16-bit
func read16() -> UInt16? { func read16() -> UInt16? {
guard require(2) else { return nil } guard require(2) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self) let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt16(p[0]) << 8) | UInt16(p[1]) let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
offset += 2 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? { func readData(_ n: Int) -> Data? {
guard require(n) else { return nil } guard require(n) else { return nil }
let ptr = base.advanced(by: offset) let ptr = base.advanced(by: offset)
let d = Data(bytes: ptr, count: n) let data = Data(bytes: ptr, count: n)
offset += n offset += n
return d return data
} }
// Version guard let version = read8(), version == 1 || version == 2 else { return nil }
guard let version = read8(), version == 1 else { return nil } let lengthFieldBytes = lengthFieldSize(for: version)
guard let type = read8() else { return nil } let minimumRequired = headerSize(for: version) + senderIDSize
guard let ttl = read8() else { return nil } guard raw.count >= minimumRequired else { return nil }
// Timestamp 8 bytes BE guard let type = read8(), let ttl = read8() else { return nil }
guard require(8) else { return nil }
var ts: UInt64 = 0 var timestamp: UInt64 = 0
for _ in 0..<8 { for _ in 0..<8 {
guard let b = read8() else { return nil } guard let byte = read8() else { return nil }
ts = (ts << 8) | UInt64(b) timestamp = (timestamp << 8) | UInt64(byte)
} }
// Flags
guard let flags = read8() else { return nil } guard let flags = read8() else { return nil }
let hasRecipient = (flags & Flags.hasRecipient) != 0 let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0 let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0 let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length let payloadLength: Int
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil } 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 } guard let senderID = readData(senderIDSize) else { return nil }
// Recipient
var recipientID: Data? = nil var recipientID: Data? = nil
if hasRecipient { if hasRecipient {
recipientID = readData(recipientIDSize) recipientID = readData(recipientIDSize)
if recipientID == nil { return nil } if recipientID == nil { return nil }
} }
// Payload
let payload: Data let payload: Data
if isCompressed { if isCompressed {
// Need original size (2 bytes) guard payloadLength >= lengthFieldBytes else { return nil }
guard let origSize16 = read16() else { return nil } let originalSize: Int
let originalSize = Int(origSize16) 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 } guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
let compSize = Int(payloadLen) - 2 let compressedSize = payloadLength - lengthFieldBytes
guard compSize >= 0, let compressed = readData(compSize) else { return nil } guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize), guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
decompressed.count == originalSize else { return nil } decompressed.count == originalSize else { return nil }
payload = decompressed payload = decompressed
} else { } else {
guard let p = readData(Int(payloadLen)) else { return nil } guard let rawPayload = readData(payloadLength) else { return nil }
payload = p payload = rawPayload
} }
// Signature
var signature: Data? = nil var signature: Data? = nil
if hasSignature { if hasSignature {
signature = readData(signatureSize) signature = readData(signatureSize)
@@ -320,10 +344,11 @@ struct BinaryProtocol {
type: type, type: type,
senderID: senderID, senderID: senderID,
recipientID: recipientID, recipientID: recipientID,
timestamp: ts, timestamp: timestamp,
payload: payload, payload: payload,
signature: signature, signature: signature,
ttl: ttl ttl: ttl,
version: version
) )
} }
} }
+151
View File
@@ -0,0 +1,151 @@
//
// BitchatFilePacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<T: FixedWidthInteger>(_ 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..<bytes {
result = (result << 8) | Int(data[cursor])
cursor = data.index(after: cursor)
}
return result
}
let length: Int?
if tlvType == .content {
let snapshot = cursor
let canonical = readBigEndianLength(bytes: 4)
if let canonical = canonical,
canonical <= data.distance(from: cursor, to: end) {
length = canonical
} else {
cursor = snapshot
length = readBigEndianLength(bytes: 2)
}
} else {
length = readBigEndianLength(bytes: 2)
}
guard let tlvLength = length, tlvLength >= 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..<cursor]
switch tlvType {
case .fileName:
fileName = String(data: Data(value), encoding: .utf8)
case .fileSize:
if tlvLength == 4 || tlvLength == 8 {
var size: UInt64 = 0
for byte in value {
size = (size << 8) | UInt64(byte)
}
if size > 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
)
}
}
+2
View File
@@ -79,6 +79,7 @@ enum MessageType: UInt8 {
// Fragmentation (simplified) // Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
var description: String { var description: String {
switch self { switch self {
@@ -89,6 +90,7 @@ enum MessageType: UInt8 {
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment" case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -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<Event, Never>()
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
private var states: [String: (sent: Int, total: Int)] = [:]
var publisher: AnyPublisher<Event, Never> {
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
}
}
+6
View File
@@ -50,6 +50,9 @@ protocol Transport: AnyObject {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce() func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: PeerID) 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) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -59,6 +62,9 @@ protocol Transport: AnyObject {
extension Transport { extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(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 { protocol TransportPeerEventsDelegate: AnyObject {
+15
View File
@@ -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
}
}
+462
View File
@@ -87,6 +87,7 @@ import Tor
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#endif #endif
import UniformTypeIdentifiers
/// Manages the application state and business logic for BitChat. /// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services, /// Acts as the primary coordinator between UI components and backend services,
@@ -430,6 +431,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Delivery tracking // Delivery tracking
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var transferIdToMessageID: [String: String] = [:]
private var messageIDToTransferId: [String: String] = [:]
// MARK: - QR Verification (pending state) // MARK: - QR Verification (pending state)
private struct PendingVerification { private struct PendingVerification {
@@ -644,6 +647,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Set up Noise encryption callbacks // Set up Noise encryption callbacks
setupNoiseCallbacks() setupNoiseCallbacks()
TransferProgressManager.shared.publisher
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
self?.handleTransferEvent(event)
}
.store(in: &cancellables)
// Observe location channel selection // Observe location channel selection
LocationChannelManager.shared.$selectedChannel LocationChannelManager.shared.$selectedChannel
.receive(on: DispatchQueue.main) .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 // MARK: - Geohash DMs initiation
@MainActor @MainActor
func startGeohashDM(withPubkeyHex hex: String) { func startGeohashDM(withPubkeyHex hex: String) {
@@ -3655,6 +4070,53 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return result 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 { func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
var result = AttributedString() var result = AttributedString()
File diff suppressed because it is too large Load Diff
@@ -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..<rows {
for column in 0..<columns {
if remaining <= 0 { return path }
let x = CGFloat(column) * blockWidth
let y = CGFloat(row) * blockHeight
path.addRect(CGRect(x: x, y: y, width: blockWidth, height: blockHeight))
remaining -= 1
}
}
return path
}
}
private extension Image {
init(platformImage: PlatformImage) {
#if os(iOS)
self.init(uiImage: platformImage)
#else
self.init(nsImage: platformImage)
#endif
}
}
@@ -0,0 +1,118 @@
import SwiftUI
struct FileAttachmentView: View {
private let url: URL
private let isSending: Bool
private let progress: Double?
private let onCancel: (() -> 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
+118
View File
@@ -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()
}
}
}
+67
View File
@@ -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)
}
}