mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:25:19 +00:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53826d5145 | ||
|
|
f58955f194 | ||
|
|
537737f521 | ||
|
|
b58c71f777 | ||
|
|
b4e2746be6 | ||
|
|
91040f7ed4 | ||
|
|
588d8fef0d | ||
|
|
8cd5a09a86 | ||
|
|
121e1d246a | ||
|
|
e5028e5e86 | ||
|
|
6681ca11c3 | ||
|
|
81b5dd15c1 | ||
|
|
098f223906 | ||
|
|
d31dd8300f | ||
|
|
85b627945d | ||
|
|
035ad175a7 | ||
|
|
b995a3fe4f | ||
|
|
fb26db3bf0 | ||
|
|
de4bf0a471 | ||
|
|
f493b50163 | ||
|
|
4aa12c08e7 | ||
|
|
c2a0c86542 | ||
|
|
073e22e126 | ||
|
|
6533293f75 | ||
|
|
757acef8d1 | ||
|
|
db52c9463b | ||
|
|
b179d99cf8 | ||
|
|
73d0867c18 | ||
|
|
97f822b88d | ||
|
|
3f91d6510b | ||
|
|
2bb55cbe1a | ||
|
|
7fb93eb522 | ||
|
|
cb8b34f8ea | ||
|
|
b872113a4b | ||
|
|
de3795289d | ||
|
|
bd37cc69a0 | ||
|
|
788e21c4ea | ||
|
|
c179e34c43 | ||
|
|
aa8b257e68 | ||
|
|
7b4aeb506e | ||
|
|
5d5ed94952 | ||
|
|
4945688eca | ||
|
|
22bd975059 | ||
|
|
d28b58ecb2 | ||
|
|
e17163b3da | ||
|
|
9346e62971 | ||
|
|
a89fd153ee | ||
|
|
25bc737919 | ||
|
|
8218c12f69 | ||
|
|
60c2263a46 | ||
|
|
e2fcb44982 | ||
|
|
ebbb7b356f | ||
|
|
2d0f55ae84 | ||
|
|
51e8e4e51a | ||
|
|
fb251a3fa8 | ||
|
|
4052ba581a | ||
|
|
235fefe4ab | ||
|
|
8389961269 | ||
|
|
a244c4084f | ||
|
|
ed320fb0ad | ||
|
|
2bdc1535c7 | ||
|
|
60a375469a | ||
|
|
640567b7e4 | ||
|
|
f58bcaf615 | ||
|
|
6e19995de2 | ||
|
|
78d72f5814 | ||
|
|
f145d13992 | ||
|
|
f607413caf | ||
|
|
47db836a22 | ||
|
|
aa7a5efe6a | ||
|
|
c8d196f106 | ||
|
|
2cd90ff813 | ||
|
|
5267489fa2 | ||
|
|
790dcda8e5 |
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.4
|
||||
MARKETING_VERSION = 1.5.0
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
enum ImageUtilsError: Error {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.85
|
||||
private static let targetImageBytes: Int = 60_000
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||
// Security H1: Check file size BEFORE reading into memory
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
// Allow up to 10MB source images (will be scaled down)
|
||||
guard fileSize <= 10 * 1024 * 1024 else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
|
||||
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 {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = scaled.jpegData(compressionQuality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#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,193 @@
|
||||
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()
|
||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||
}
|
||||
|
||||
func loadDuration() {
|
||||
guard duration == 0 else { return }
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||
let loadedDuration = player.duration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, self.duration == 0 else { return }
|
||||
self.duration = loadedDuration
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
func replaceURL(_ url: URL) {
|
||||
guard url != self.url else { return }
|
||||
stop()
|
||||
self.url = url
|
||||
player = nil
|
||||
duration = 0
|
||||
// Duration will be loaded on demand when needed
|
||||
}
|
||||
|
||||
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) {
|
||||
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopTimer()
|
||||
self.updateProgress()
|
||||
self.isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func preparePlayer(for url: URL) {
|
||||
// Prepare player synchronously (only called when playback is requested)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 20_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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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: (waveform: [Float], lastAccess: Date)] = [:]
|
||||
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||
|
||||
private init() {}
|
||||
|
||||
func cachedWaveform(for url: URL) -> [Float]? {
|
||||
queue.sync {
|
||||
guard let entry = cache[url] else { return nil }
|
||||
return entry.waveform
|
||||
}
|
||||
}
|
||||
|
||||
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check cache (read-only, no update needed on cache hit for performance)
|
||||
if let entry = self.cache[url] {
|
||||
DispatchQueue.main.async { completion(entry.waveform) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
return
|
||||
}
|
||||
|
||||
self.queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Evict oldest entry if cache is full
|
||||
if self.cache.count >= self.maxCacheSize {
|
||||
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||
self.cache.removeValue(forKey: oldest.key)
|
||||
}
|
||||
}
|
||||
|
||||
self.cache[url] = (computed, Date())
|
||||
}
|
||||
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 }
|
||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||
return autoreleasepool {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
+15420
-14522
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -80,7 +80,8 @@ struct BitchatPacket: Codable {
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil, // Remove signature for signing
|
||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
version: version
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
@@ -767,7 +767,7 @@ final class NoiseHandshakeState {
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
default:
|
||||
case .e, .s:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ final class NostrIdentityBridge {
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private var deviceSeedCache: Data?
|
||||
|
||||
// Cache derived identities to avoid repeated crypto during view rendering
|
||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private let cacheLock = NSLock()
|
||||
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
@@ -106,6 +109,14 @@ final class NostrIdentityBridge {
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||
cacheLock.lock()
|
||||
if let cached = derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
cacheLock.unlock()
|
||||
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
@@ -125,11 +136,22 @@ final class NostrIdentityBridge {
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
/// +----------+-------------+---------+------------+
|
||||
@@ -52,7 +52,7 @@
|
||||
/// ## Flag Bits
|
||||
/// - Bit 0: Has recipient ID (directed message)
|
||||
/// - Bit 1: Has signature (authenticated message)
|
||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
||||
/// - Bit 2: Is compressed (zlib compression applied)
|
||||
/// - Bits 3-7: Reserved for future use
|
||||
///
|
||||
/// ## Size Constraints
|
||||
@@ -89,6 +89,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
extension Data {
|
||||
func trimmingNullBytes() -> Data {
|
||||
@@ -105,10 +106,32 @@ 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
|
||||
|
||||
// Field offsets within packet header
|
||||
struct Offsets {
|
||||
static let version = 0
|
||||
static let type = 1
|
||||
static let ttl = 2
|
||||
static let timestamp = 3
|
||||
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||
}
|
||||
|
||||
static func headerSize(for version: UInt8) -> Int? {
|
||||
switch version {
|
||||
case 1: return v1HeaderSize
|
||||
case 2: return v2HeaderSize
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func lengthFieldSize(for version: UInt8) -> Int {
|
||||
return version == 2 ? 4 : 2
|
||||
}
|
||||
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
@@ -118,70 +141,69 @@ 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 }
|
||||
|
||||
guard let headerSize = headerSize(for: version) else { return nil }
|
||||
let estimatedHeader = headerSize + 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 +211,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 +249,112 @@ 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)
|
||||
guard let headerSize = headerSize(for: version) else { return nil }
|
||||
let minimumRequired = headerSize + 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 originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
||||
let compSize = Int(payloadLen) - 2
|
||||
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
||||
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 to sane BLE payload limits
|
||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
||||
let compressedSize = payloadLength - lengthFieldBytes
|
||||
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||
|
||||
// Validate compression ratio to prevent zip bomb attacks
|
||||
// Primary protection: originalSize capped at 1MB (line 336)
|
||||
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
|
||||
guard compressedSize > 0 else { return nil }
|
||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||
guard compressionRatio <= 50_000.0 else {
|
||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||
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 +367,11 @@ struct BinaryProtocol {
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
timestamp: ts,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl
|
||||
ttl: ttl,
|
||||
version: version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// BitchatFilePacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// 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 }
|
||||
// Use UInt64 to prevent integer overflow during shift operations
|
||||
var result: UInt64 = 0
|
||||
for _ in 0..<bytes {
|
||||
result = (result << 8) | UInt64(data[cursor])
|
||||
cursor = data.index(after: cursor)
|
||||
}
|
||||
// Safely convert to Int with overflow check
|
||||
guard result <= Int.max else { return nil }
|
||||
return Int(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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1433
-365
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,9 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
default:
|
||||
case .notDetermined, .restricted, .denied:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,19 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
struct NotificationStreamAssembler {
|
||||
private var buffer = Data()
|
||||
private var pendingFrameStartedAt: DispatchTime?
|
||||
private var pendingFrameExpectedLength: Int = 0
|
||||
|
||||
private mutating func resetState() {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
}
|
||||
|
||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||
guard !chunk.isEmpty else { return ([], [], false) }
|
||||
@@ -18,64 +27,107 @@ struct NotificationStreamAssembler {
|
||||
|
||||
var frames: [Data] = []
|
||||
var dropped: [UInt8] = []
|
||||
var reset = false
|
||||
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
||||
var didReset = false
|
||||
let now = DispatchTime.now()
|
||||
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
||||
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
||||
|
||||
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
||||
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
||||
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
||||
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
||||
resetState()
|
||||
return ([], [], true)
|
||||
}
|
||||
|
||||
while buffer.count >= minFramePrefix {
|
||||
guard let first = buffer.first else { break }
|
||||
if first != 1 {
|
||||
while buffer.count >= minimumFramePrefix {
|
||||
guard let version = buffer.first else { break }
|
||||
guard version == 1 || version == 2 else {
|
||||
dropped.append(buffer.removeFirst())
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
continue
|
||||
}
|
||||
|
||||
guard buffer.count >= minHeaderBytes else { break }
|
||||
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
|
||||
dropped.append(buffer.removeFirst())
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
continue
|
||||
}
|
||||
let framePrefix = headerSize + BinaryProtocol.senderIDSize
|
||||
guard buffer.count >= framePrefix else { break }
|
||||
|
||||
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
||||
guard headerBytes.count == minFramePrefix else { break }
|
||||
|
||||
let flags = headerBytes[11]
|
||||
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
||||
guard flagsIndex < buffer.endIndex else { break }
|
||||
let flags = buffer[flagsIndex]
|
||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
||||
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||
|
||||
var frameLength = minFramePrefix + payloadLen
|
||||
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 }
|
||||
if isCompressed {
|
||||
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||
if payloadLength < rawLengthFieldBytes {
|
||||
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||
buffer.removeAll()
|
||||
reset = true
|
||||
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
break
|
||||
}
|
||||
|
||||
if buffer.count < frameLength {
|
||||
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
||||
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
||||
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
||||
if dropCount > 0 {
|
||||
buffer.removeFirst(dropCount)
|
||||
dropped.append(1) // treat as dropped partial start
|
||||
let remaining = frameLength - buffer.count
|
||||
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
||||
pendingFrameStartedAt = now
|
||||
pendingFrameExpectedLength = frameLength
|
||||
} else if let started = pendingFrameStartedAt {
|
||||
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
|
||||
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
|
||||
if elapsed >= threshold {
|
||||
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
} else {
|
||||
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
|
||||
let frame = Data(buffer.prefix(frameLength))
|
||||
frames.append(frame)
|
||||
buffer.removeFirst(frameLength)
|
||||
}
|
||||
|
||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
resetState()
|
||||
}
|
||||
|
||||
return (frames, dropped, reset)
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
return (frames, dropped, didReset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,7 +30,11 @@ enum TransportConfig {
|
||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||
static let bleConnectionCandidatesMax: Int = 100
|
||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||
static let blePendingNotificationsCapCount: Int = 20
|
||||
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
|
||||
static let bleAssemblerStallResetMs: Int = 250
|
||||
static let blePendingNotificationsCapCount: Int = 128
|
||||
static let bleNotificationRetryDelayMs: Int = 25
|
||||
static let bleNotificationRetryMaxAttempts: Int = 80
|
||||
|
||||
// Nostr
|
||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||
|
||||
@@ -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 = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Voice notes stay small for low-latency relays.
|
||||
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
|
||||
static func isValidPayload(_ size: Int) -> Bool {
|
||||
size <= maxPayloadBytes
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -357,6 +358,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private var geoSubscriptionID: String? = nil
|
||||
private var geoDmSubscriptionID: String? = nil
|
||||
private var currentGeohash: String? = nil
|
||||
private var cachedGeohashIdentity: (geohash: String, identity: NostrIdentity)? = nil // Cache current geohash identity
|
||||
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||
// Show Tor status once per app launch
|
||||
private var torStatusAnnounced = false
|
||||
@@ -425,6 +427,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Delivery tracking
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var transferIdToMessageIDs: [String: [String]] = [:]
|
||||
private var messageIDToTransferId: [String: String] = [:]
|
||||
|
||||
// MARK: - QR Verification (pending state)
|
||||
private struct PendingVerification {
|
||||
@@ -639,6 +643,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)
|
||||
@@ -1392,7 +1403,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
|
||||
// Check for commands
|
||||
if content.hasPrefix("/") {
|
||||
Task { @MainActor in
|
||||
@@ -1400,20 +1411,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if selectedPrivateChatPeer != nil {
|
||||
// Update peer ID in case it changed due to reconnection
|
||||
updatePrivateChatPeerIfNeeded()
|
||||
|
||||
|
||||
if let selectedPeer = selectedPrivateChatPeer {
|
||||
sendPrivateMessage(content, to: selectedPeer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Parse mentions from the content (use original content for user intent)
|
||||
let mentions = parseMentions(from: content)
|
||||
|
||||
|
||||
// Add message to local display
|
||||
var displaySender = nickname
|
||||
var localSenderPeerID = meshService.myPeerID
|
||||
@@ -1432,14 +1443,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
senderPeerID: localSenderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
|
||||
// Add to main messages immediately for user feedback
|
||||
messages.append(message)
|
||||
|
||||
|
||||
// Update content LRU for near-dup detection
|
||||
let ckey = normalizedContentKey(message.content)
|
||||
recordContentKey(ckey, timestamp: message.timestamp)
|
||||
|
||||
|
||||
// Persist to channel-specific timelines
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
@@ -1453,14 +1464,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
|
||||
trimMessagesIfNeeded()
|
||||
|
||||
// Force immediate UI update for user's own messages
|
||||
objectWillChange.send()
|
||||
|
||||
// UI updates automatically via @Published var messages
|
||||
|
||||
updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions)
|
||||
}
|
||||
|
||||
|
||||
private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String]) {
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
@@ -1985,7 +1996,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return false
|
||||
}
|
||||
default:
|
||||
case .mesh:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -2363,6 +2374,460 @@ 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.lastPathComponent)", targetPeer: targetPeer?.id)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
// Security H1: Check file size BEFORE reading into memory
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int,
|
||||
fileSize <= FileTransferLimits.maxVoiceNoteBytes else {
|
||||
let size = (attrs[.size] as? Int) ?? 0
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run {
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: url.lastPathComponent,
|
||||
fileSize: UInt64(data.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: data
|
||||
)
|
||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||
await MainActor.run {
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
} else {
|
||||
self.meshService.sendFileBroadcast(packet, transferId: transferId)
|
||||
}
|
||||
}
|
||||
} 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, cleanup: (() -> Void)? = nil) {
|
||||
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 packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer?.id)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
} else {
|
||||
self.meshService.sendFileBroadcast(packet, transferId: transferId)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
cleanup?()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFileAttachment(from sourceURL: URL) {
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||
var destinationURL: URL?
|
||||
do {
|
||||
// Security H1: Check file size BEFORE reading into memory
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: sourceURL.path)
|
||||
guard let fileSize = attrs[.size] as? Int else {
|
||||
throw MediaSendError.encodingFailed
|
||||
}
|
||||
guard FileTransferLimits.isValidPayload(fileSize) else {
|
||||
throw MediaSendError.tooLarge
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: sourceURL)
|
||||
|
||||
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 packet.encode() != nil else {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
throw MediaSendError.encodingFailed
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[file] \(destination.lastPathComponent)", targetPeer: targetPeer?.id)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
} else {
|
||||
self.meshService.sendFileBroadcast(packet, transferId: transferId)
|
||||
}
|
||||
}
|
||||
} 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],
|
||||
let active = transferIdToMessageIDs[transferId]?.first,
|
||||
active == 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(str: peerID), default: []].append(message)
|
||||
privateChats = chats
|
||||
trimMessagesIfNeeded()
|
||||
} else {
|
||||
let (displayName, senderPeerID) = currentPublicSender()
|
||||
message = BitchatMessage(
|
||||
sender: displayName,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: PeerID(str: 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? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||
displaySender = nickname + "#" + suffix
|
||||
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
|
||||
senderPeerID = PeerID(str: "nostr:\(shortKey)")
|
||||
}
|
||||
return (displaySender, senderPeerID.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func nicknameForPeer(_ peerID: String) -> String {
|
||||
if let name = meshService.peerNickname(peerID: PeerID(str: peerID)) {
|
||||
return name
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: PeerID(str: 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) {
|
||||
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
||||
messageIDToTransferId[messageID] = transferId
|
||||
}
|
||||
|
||||
private func makeTransferID(messageID: String) -> String {
|
||||
"\(messageID)-\(UUID().uuidString)"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func clearTransferMapping(for messageID: String) {
|
||||
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
||||
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
||||
if !queue.isEmpty {
|
||||
if queue.first == messageID {
|
||||
queue.removeFirst()
|
||||
} else if let idx = queue.firstIndex(of: messageID) {
|
||||
queue.remove(at: idx)
|
||||
}
|
||||
}
|
||||
transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue
|
||||
}
|
||||
|
||||
@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 = transferIdToMessageIDs[id]?.first else { return }
|
||||
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
|
||||
case .updated(let id, let sent, let total):
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
|
||||
case .completed(let id, _):
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
updateMessageDeliveryStatus(messageID, status: .sent)
|
||||
clearTransferMapping(for: messageID)
|
||||
case .cancelled(let id, _, _):
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
clearTransferMapping(for: messageID)
|
||||
removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||
// Check both outgoing and incoming directories for thorough cleanup
|
||||
let prefixes = ["[voice] ", "[image] ", "[file] "]
|
||||
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
|
||||
"images/outgoing", "images/incoming",
|
||||
"files/outgoing", "files/incoming"]
|
||||
|
||||
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
|
||||
let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
|
||||
|
||||
// Security: Extract only the last path component to prevent directory traversal
|
||||
let safeFilename = (rawFilename as NSString).lastPathComponent
|
||||
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
|
||||
|
||||
// Try all possible locations (outgoing and incoming)
|
||||
for subdir in subdirs {
|
||||
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
||||
|
||||
// Security: Verify target is within expected directory before deletion
|
||||
guard target.path.hasPrefix(base.path) else { continue }
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: target)
|
||||
} catch CocoaError.fileNoSuchFile {
|
||||
// Expected - file not in this directory
|
||||
} catch {
|
||||
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -2630,7 +3095,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
switch sessionState {
|
||||
case .none, .failed:
|
||||
meshService.triggerHandshake(with: peerID)
|
||||
default:
|
||||
case .handshakeQueued, .handshaking, .established:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
@@ -2650,7 +3115,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
case .read, .delivered:
|
||||
sentReadReceipts.insert(message.id)
|
||||
privateChatManager.sentReadReceipts.insert(message.id)
|
||||
default:
|
||||
case .failed, .partiallyDelivered, .sending, .sent:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -2880,7 +3345,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
case .established:
|
||||
// Send the message directly without going through sendPrivateMessage to avoid local echo
|
||||
messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
|
||||
default:
|
||||
case .none, .failed, .handshakeQueued, .handshaking:
|
||||
// Don't send screenshot notification if no session exists
|
||||
SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security)
|
||||
}
|
||||
@@ -3143,7 +3608,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
nostrKeyMapping[convKey] = pub
|
||||
return convKey
|
||||
}
|
||||
default:
|
||||
case .mesh:
|
||||
break
|
||||
}
|
||||
// Fallback to mesh nickname resolution
|
||||
@@ -3234,9 +3699,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
nostrRelayManager?.connect()
|
||||
}
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
Task.detached(priority: .utility) {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
|
||||
// Delete the entire files directory and recreate it
|
||||
if FileManager.default.fileExists(atPath: filesDir.path) {
|
||||
try FileManager.default.removeItem(at: filesDir)
|
||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||
}
|
||||
|
||||
// Recreate empty directory structure
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate UI update for panic mode
|
||||
// UI updates immediately - no flushing needed
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Autocomplete
|
||||
@@ -3307,7 +3797,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if let spid = message.senderPeerID {
|
||||
// In geohash channels, compare against our per-geohash nostr short ID
|
||||
if case .location(let ch) = activeChannel, spid.isGeoChat {
|
||||
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
// Use cached identity to avoid crypto during rendering
|
||||
let myGeo: NostrIdentity? = {
|
||||
if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {
|
||||
return cached.identity
|
||||
}
|
||||
// Fallback: derive and cache (should rarely happen)
|
||||
if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
cachedGeohashIdentity = (ch.geohash, identity)
|
||||
return identity
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if let myGeo = myGeo {
|
||||
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||
}
|
||||
}
|
||||
@@ -3633,6 +4135,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.id.hasPrefix("nostr:") {
|
||||
if let myGeo = try? idBridge.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.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
|
||||
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()
|
||||
@@ -4174,6 +4723,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Clear the current public channel's timeline (visible + persistent buffer)
|
||||
@MainActor
|
||||
func clearCurrentPublicTimeline() {
|
||||
// Clear messages from current timeline
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
messages.removeAll()
|
||||
@@ -4182,6 +4732,32 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
messages.removeAll()
|
||||
geoTimelines[ch.geohash] = []
|
||||
}
|
||||
|
||||
// Delete associated media files (images, voice notes, files) in background
|
||||
// Only delete from current chat to avoid removing private chat media
|
||||
Task.detached(priority: .utility) {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
|
||||
// Only clear public media (mesh channel only - geohash media is separate)
|
||||
// Note: This is conservative - only clears outgoing since we authored those
|
||||
let outgoingDirs = [
|
||||
filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true),
|
||||
filesDir.appendingPathComponent("images/outgoing", isDirectory: true),
|
||||
filesDir.appendingPathComponent("files/outgoing", isDirectory: true)
|
||||
]
|
||||
|
||||
for dir in outgoingDirs {
|
||||
if FileManager.default.fileExists(atPath: dir.path) {
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to clear media files: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Management
|
||||
@@ -6134,7 +6710,7 @@ private func checkForMentions(_ message: BitchatMessage) {
|
||||
let d = String(id.publicKeyHex.suffix(4))
|
||||
tokens.append(nickname + "#" + d)
|
||||
}
|
||||
default:
|
||||
case .mesh:
|
||||
break
|
||||
}
|
||||
#endif
|
||||
|
||||
+918
-263
File diff suppressed because it is too large
Load Diff
@@ -599,7 +599,7 @@ extension LocationChannelsSheet {
|
||||
switch level {
|
||||
case .region:
|
||||
return ""
|
||||
default:
|
||||
case .building, .block, .neighborhood, .city, .province:
|
||||
return "~"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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(mainGesture)
|
||||
}
|
||||
|
||||
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 mainGesture: 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?()
|
||||
}
|
||||
}
|
||||
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
|
||||
guard !isSending else { return }
|
||||
let horizontal = value.translation.width
|
||||
let vertical = value.translation.height
|
||||
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
|
||||
if !isBlurred {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isBlurred = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
|
||||
}
|
||||
}
|
||||
|
||||
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.lastPathComponent)
|
||||
.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", comment: "Button to open attached file")
|
||||
.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
|
||||
@@ -0,0 +1,123 @@
|
||||
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)
|
||||
)
|
||||
.task {
|
||||
// Defer loading to let UI settle after view appears
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
||||
playback.loadDuration()
|
||||
await withCheckedContinuation { continuation in
|
||||
WaveformCache.shared.waveform(for: url, completion: { bins in
|
||||
waveform = bins
|
||||
continuation.resume()
|
||||
})
|
||||
}
|
||||
}
|
||||
.onChange(of: url) { newValue in
|
||||
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
||||
self.waveform = bins
|
||||
})
|
||||
playback.replaceURL(newValue)
|
||||
}
|
||||
.onDisappear {
|
||||
playback.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,19 @@
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.assets.pictures.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -153,7 +153,15 @@ final class MockBLEService: NSObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
|
||||
}
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
|
||||
}
|
||||
|
||||
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
|
||||
@@ -95,4 +95,56 @@ struct NotificationStreamAssemblerTests {
|
||||
let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop")
|
||||
#expect(decoded.timestamp == packet.timestamp)
|
||||
}
|
||||
|
||||
@Test func assemblesCompressedLargeFrame() throws {
|
||||
var assembler = NotificationStreamAssembler()
|
||||
|
||||
// Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression.
|
||||
let largeContent = Data(repeating: 0x41, count: 600_000)
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: "large.bin",
|
||||
fileSize: UInt64(largeContent.count),
|
||||
mimeType: "application/octet-stream",
|
||||
content: largeContent
|
||||
)
|
||||
let tlvPayload = try #require(filePacket.encode(), "Failed to encode file packet")
|
||||
let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: senderID,
|
||||
recipientID: nil,
|
||||
timestamp: 0x010203040506,
|
||||
payload: tlvPayload,
|
||||
signature: nil,
|
||||
ttl: 3,
|
||||
version: 2
|
||||
)
|
||||
|
||||
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet frame")
|
||||
|
||||
#expect(BinaryProtocol.Offsets.flags < frame.count)
|
||||
let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]
|
||||
#expect(flags & BinaryProtocol.Flags.isCompressed != 0, "Frame should be compressed for large payloads")
|
||||
|
||||
let splitIndex = min(4096, frame.count / 2)
|
||||
var result = assembler.append(frame.prefix(splitIndex))
|
||||
#expect(result.frames.isEmpty)
|
||||
|
||||
result = assembler.append(frame.suffix(from: splitIndex))
|
||||
#expect(result.frames.count == 1)
|
||||
#expect(result.droppedPrefixes.isEmpty)
|
||||
#expect(!result.reset)
|
||||
|
||||
let assembled = try #require(result.frames.first, "Missing assembled frame")
|
||||
#expect(assembled.count == frame.count)
|
||||
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(assembled), "Failed to decode compressed frame")
|
||||
#expect(decodedPacket.payload.count == tlvPayload.count)
|
||||
|
||||
let decodedFile = try #require(BitchatFilePacket.decode(decodedPacket.payload), "Failed to decode TLV payload")
|
||||
#expect(decodedFile.fileName == filePacket.fileName)
|
||||
#expect(decodedFile.mimeType == filePacket.mimeType)
|
||||
#expect(decodedFile.content.count == largeContent.count)
|
||||
#expect(decodedFile.content.prefix(32) == largeContent.prefix(32))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,9 @@ struct BinaryProtocolTests {
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
|
||||
|
||||
// The encoded size should be smaller than uncompressed due to compression
|
||||
let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
||||
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid version")
|
||||
|
||||
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
||||
#expect(encodedData.count < uncompressedSize)
|
||||
|
||||
// Decode and verify
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class BitchatFilePacketTests: XCTestCase {
|
||||
|
||||
func testRoundTripPreservesFields() throws {
|
||||
let content = Data((0..<4096).map { UInt8($0 % 251) })
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: "sample.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
|
||||
guard let encoded = packet.encode() else {
|
||||
return XCTFail("Failed to encode file packet")
|
||||
}
|
||||
guard let decoded = BitchatFilePacket.decode(encoded) else {
|
||||
return XCTFail("Failed to decode file packet")
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.fileName, packet.fileName)
|
||||
XCTAssertEqual(decoded.fileSize, packet.fileSize)
|
||||
XCTAssertEqual(decoded.mimeType, packet.mimeType)
|
||||
XCTAssertEqual(decoded.content, packet.content)
|
||||
}
|
||||
|
||||
func testDecodeFallsBackToContentSizeWhenFileSizeMissing() throws {
|
||||
let content = Data(repeating: 0x7F, count: 1024)
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: nil,
|
||||
fileSize: nil,
|
||||
mimeType: nil,
|
||||
content: content
|
||||
)
|
||||
|
||||
guard let encoded = packet.encode() else {
|
||||
return XCTFail("Failed to encode file packet")
|
||||
}
|
||||
guard let decoded = BitchatFilePacket.decode(encoded) else {
|
||||
return XCTFail("Failed to decode file packet")
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user