mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 16:25:22 +00:00
Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send. Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling. Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests).
72 lines
2.7 KiB
Swift
72 lines
2.7 KiB
Swift
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)
|
|
case rejected(id: String, reason: String)
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
/// Fails a preflight check while keeping the outgoing placeholder visible
|
|
/// with an actionable reason instead of treating policy/size rejection as
|
|
/// a user cancellation.
|
|
func rejectBeforeStart(id: String, reason: String) {
|
|
queue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
self.states.removeValue(forKey: id)
|
|
self.subject.send(.rejected(id: id, reason: reason))
|
|
}
|
|
}
|
|
|
|
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
|
var result: (sent: Int, total: Int)?
|
|
queue.sync {
|
|
result = states[id]
|
|
}
|
|
return result
|
|
}
|
|
}
|