mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 00:05:25 +00:00
Harden PTT audio and the 1.7.1 release (#1423)
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage. Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
This commit is contained in:
@@ -10,6 +10,9 @@ import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Trust level of a courier deposit, decided by the caller's policy.
|
||||
/// Favorites get the larger quota and are never evicted to make room for
|
||||
@@ -120,13 +123,45 @@ final class CourierStore {
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
|
||||
private let fileURL: URL?
|
||||
private let now: () -> Date
|
||||
private let readData: (URL) throws -> Data
|
||||
/// A protected file can be present but unreadable during an iOS
|
||||
/// background restoration before first unlock. Keep that distinct from
|
||||
/// an absent file: mutations may proceed in memory, but must not replace
|
||||
/// the unreadable durable snapshot until it can be merged.
|
||||
private var diskLoadDeferred = false
|
||||
#if os(iOS)
|
||||
private var protectedDataObserver: NSObjectProtocol?
|
||||
#endif
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||
init(
|
||||
persistsToDisk: Bool = true,
|
||||
fileURL: URL? = nil,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) }
|
||||
) {
|
||||
self.now = now
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
self.readData = readData
|
||||
loadFromDisk()
|
||||
#if os(iOS)
|
||||
protectedDataObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { [weak self] _ in
|
||||
self?.retryDeferredPersistence()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
deinit {
|
||||
#if os(iOS)
|
||||
if let protectedDataObserver {
|
||||
NotificationCenter.default.removeObserver(protectedDataObserver)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Depositing (courier side)
|
||||
@@ -154,10 +189,16 @@ final class CourierStore {
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
|
||||
// Identical ciphertext is the same envelope; accept idempotently,
|
||||
// keeping the larger spray budget (bounded by maxCopies either way).
|
||||
// Identical ciphertext is the same envelope. Before any spray,
|
||||
// a carry-only copy may legitimately arrive ahead of the original
|
||||
// higher-budget copy, so keep the larger initial budget. Once a
|
||||
// branch has sprayed, however, replaying the depositor's original
|
||||
// packet must never replenish spent copies: that would defeat
|
||||
// spray-and-wait and let `sprayedTo` grow without bound.
|
||||
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
|
||||
if envelopes[existing].sprayedTo.isEmpty {
|
||||
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
|
||||
}
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
@@ -207,20 +248,52 @@ final class CourierStore {
|
||||
// MARK: - Handover (on encountering a peer)
|
||||
|
||||
/// Remove and return all envelopes addressed to the given peer, matching
|
||||
/// the rotating recipient tag across adjacent days. Envelopes are removed
|
||||
/// optimistically: handover happens over a live link, and the depositor's
|
||||
/// outbox still retains the original for direct delivery.
|
||||
/// the rotating recipient tag across adjacent days. This compatibility
|
||||
/// helper accepts every offer; transport callers should use
|
||||
/// `handoverEnvelopes(for:accepting:)` so failed sends remain durable.
|
||||
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
|
||||
var handedOver: [CourierEnvelope] = []
|
||||
handoverEnvelopes(for: noiseStaticKey) { envelope in
|
||||
handedOver.append(envelope)
|
||||
return true
|
||||
}
|
||||
return handedOver
|
||||
}
|
||||
|
||||
/// Attempts direct handover without retiring the durable carried copy
|
||||
/// until the transport accepts it onto the intended peer's physical link.
|
||||
/// A failed encode, stale binding, or backpressure rejection leaves the
|
||||
/// envelope unchanged for the next authenticated encounter.
|
||||
@discardableResult
|
||||
func handoverEnvelopes(
|
||||
for noiseStaticKey: Data,
|
||||
accepting: (CourierEnvelope) -> Bool
|
||||
) -> Int {
|
||||
let date = now()
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
|
||||
return queue.sync {
|
||||
let offered = queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
|
||||
guard !matched.isEmpty else { return [] }
|
||||
envelopes.removeAll { stored in matched.contains(stored) }
|
||||
persistLocked()
|
||||
return matched.map(\.envelope)
|
||||
return envelopes
|
||||
.filter { candidates.contains($0.recipientTag) }
|
||||
.map(\.envelope)
|
||||
}
|
||||
|
||||
var acceptedCount = 0
|
||||
for envelope in offered where accepting(envelope) {
|
||||
// Do not hold the store queue while the acceptance closure enters
|
||||
// BLE/collections queues. Commit in a second short critical
|
||||
// section, rechecking that another handover did not win first.
|
||||
let committed = queue.sync {
|
||||
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
|
||||
return false
|
||||
}
|
||||
envelopes.remove(at: index)
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
if committed { acceptedCount += 1 }
|
||||
}
|
||||
return acceptedCount
|
||||
}
|
||||
|
||||
/// Envelopes addressed to a recipient we heard from via a *relayed*
|
||||
@@ -247,27 +320,33 @@ final class CourierStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelopes to park on relays as bridge courier drops. Non-destructive
|
||||
/// like remote handover — the relay copy is speculative, so the carried
|
||||
/// copy stays until direct handover or expiry. The per-envelope cooldown
|
||||
/// keeps relay churn from republishing the same mail; publishing relays
|
||||
/// dedup identical events by ID anyway.
|
||||
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||
/// offering one does not start its cooldown: the caller commits that only
|
||||
/// after a relay explicitly accepts the event via NIP-20 OK.
|
||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var matched: [CourierEnvelope] = []
|
||||
for index in envelopes.indices {
|
||||
if let last = envelopes[index].lastBridgePublishAt,
|
||||
return envelopes.compactMap { stored in
|
||||
if let last = stored.lastBridgePublishAt,
|
||||
date.timeIntervalSince(last) < cooldown {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
envelopes[index].lastBridgePublishAt = date
|
||||
// The relay copy carries no spray budget.
|
||||
matched.append(envelopes[index].envelope.withCopies(1))
|
||||
return stored.envelope.withCopies(1)
|
||||
}
|
||||
if !matched.isEmpty { persistLocked() }
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the bridge-publish cooldown only for a relay-confirmed copy.
|
||||
func markBridgePublished(_ envelope: CourierEnvelope) {
|
||||
let date = now()
|
||||
queue.sync {
|
||||
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
|
||||
return
|
||||
}
|
||||
envelopes[index].lastBridgePublishAt = date
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,25 +357,60 @@ final class CourierStore {
|
||||
/// deposited, envelopes addressed to them (those ride the handover path),
|
||||
/// carry-only envelopes, and couriers already sprayed.
|
||||
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
|
||||
var sprayed: [CourierEnvelope] = []
|
||||
transferSprayCopies(to: courierNoiseKey) { envelope in
|
||||
sprayed.append(envelope)
|
||||
return true
|
||||
}
|
||||
return sprayed
|
||||
}
|
||||
|
||||
/// Offers binary-spray copies one at a time and commits the reduced local
|
||||
/// budget plus `sprayedTo` marker only after the directed transport accepts
|
||||
/// that copy. A false result is a rollback: retrying the same courier sees
|
||||
/// the original budget and eligibility.
|
||||
@discardableResult
|
||||
func transferSprayCopies(
|
||||
to courierNoiseKey: Data,
|
||||
accepting: (CourierEnvelope) -> Bool
|
||||
) -> Int {
|
||||
let date = now()
|
||||
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
|
||||
return queue.sync {
|
||||
let offered = queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var sprayed: [CourierEnvelope] = []
|
||||
for index in envelopes.indices {
|
||||
let stored = envelopes[index]
|
||||
return envelopes.compactMap { stored -> CourierEnvelope? in
|
||||
guard stored.copies > 1,
|
||||
stored.depositorNoiseKey != courierNoiseKey,
|
||||
!stored.sprayedTo.contains(courierNoiseKey),
|
||||
!courierTags.contains(stored.recipientTag) else { continue }
|
||||
let given = stored.copies / 2
|
||||
envelopes[index].copies = stored.copies - given
|
||||
envelopes[index].sprayedTo.insert(courierNoiseKey)
|
||||
sprayed.append(stored.envelope.withCopies(given))
|
||||
!courierTags.contains(stored.recipientTag) else { return nil }
|
||||
return stored.envelope.withCopies(stored.copies / 2)
|
||||
}
|
||||
if !sprayed.isEmpty { persistLocked() }
|
||||
return sprayed
|
||||
}
|
||||
|
||||
var acceptedCount = 0
|
||||
for copy in offered where accepting(copy) {
|
||||
// As with direct handover, BLE acceptance runs outside the store
|
||||
// queue. Revalidate and commit the exact budget that left this
|
||||
// device; a competing successful transfer makes this a no-op.
|
||||
let committed = queue.sync {
|
||||
guard let index = envelopes.firstIndex(where: { $0.ciphertext == copy.ciphertext }) else {
|
||||
return false
|
||||
}
|
||||
let stored = envelopes[index]
|
||||
guard stored.copies > copy.copies,
|
||||
stored.depositorNoiseKey != courierNoiseKey,
|
||||
!stored.sprayedTo.contains(courierNoiseKey),
|
||||
!courierTags.contains(stored.recipientTag) else {
|
||||
return false
|
||||
}
|
||||
envelopes[index].copies = stored.copies - copy.copies
|
||||
envelopes[index].sprayedTo.insert(courierNoiseKey)
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
if committed { acceptedCount += 1 }
|
||||
}
|
||||
return acceptedCount
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
@@ -305,6 +419,7 @@ final class CourierStore {
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
envelopes.removeAll()
|
||||
diskLoadDeferred = false
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
@@ -312,6 +427,16 @@ final class CourierStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries a protected-data read and merges any envelopes accepted while
|
||||
/// the file was unavailable. Internal so persistence tests can drive the
|
||||
/// same transition as iOS's protected-data notification.
|
||||
func retryDeferredPersistence() {
|
||||
queue.sync {
|
||||
guard diskLoadDeferred, resolveDeferredLoadLocked() else { return }
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func pruneExpiredLocked(at date: Date) {
|
||||
@@ -332,6 +457,12 @@ final class CourierStore {
|
||||
private func persistLocked() {
|
||||
publishCountLocked()
|
||||
guard let fileURL else { return }
|
||||
// Never turn a transient protected-data read failure into an
|
||||
// authoritative empty/new file. Once readable, resolve first by
|
||||
// merging the durable and in-memory snapshots.
|
||||
if diskLoadDeferred, !resolveDeferredLoadLocked() {
|
||||
return
|
||||
}
|
||||
do {
|
||||
if envelopes.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
@@ -344,7 +475,7 @@ final class CourierStore {
|
||||
let data = try JSONEncoder().encode(envelopes)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
@@ -355,16 +486,135 @@ final class CourierStore {
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL else { return }
|
||||
queue.sync {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
diskLoadDeferred = false
|
||||
return
|
||||
}
|
||||
envelopes = stored
|
||||
do {
|
||||
let data = try readData(fileURL)
|
||||
do {
|
||||
envelopes = try JSONDecoder().decode([StoredEnvelope].self, from: data)
|
||||
diskLoadDeferred = false
|
||||
pruneExpiredLocked(at: now())
|
||||
publishCountLocked()
|
||||
Self.migrateFileProtectionIfNeeded(at: fileURL)
|
||||
} catch {
|
||||
// The bytes were readable, so this is corruption/schema
|
||||
// failure rather than protected-data unavailability.
|
||||
diskLoadDeferred = false
|
||||
SecureLogger.error("Failed to decode courier store: \(error)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
diskLoadDeferred = true
|
||||
SecureLogger.warning("Courier store unavailable; deferring load until protected data is available: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Must be called on `queue`.
|
||||
private func resolveDeferredLoadLocked() -> Bool {
|
||||
guard diskLoadDeferred, let fileURL else { return true }
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
diskLoadDeferred = false
|
||||
return true
|
||||
}
|
||||
do {
|
||||
let data = try readData(fileURL)
|
||||
let durable = try JSONDecoder().decode([StoredEnvelope].self, from: data)
|
||||
envelopes = Self.merge(durable: durable, inMemory: envelopes)
|
||||
diskLoadDeferred = false
|
||||
pruneExpiredLocked(at: now())
|
||||
publishCountLocked()
|
||||
Self.migrateFileProtectionIfNeeded(at: fileURL)
|
||||
return true
|
||||
} catch let error as DecodingError {
|
||||
// Readability returned but the snapshot is corrupt. Do not pin
|
||||
// persistence forever; retain the valid in-memory snapshot.
|
||||
diskLoadDeferred = false
|
||||
SecureLogger.error("Failed to decode deferred courier store: \(error)", category: .session)
|
||||
return true
|
||||
} catch {
|
||||
SecureLogger.warning("Courier store still unavailable: \(error)", category: .session)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func merge(durable: [StoredEnvelope], inMemory: [StoredEnvelope]) -> [StoredEnvelope] {
|
||||
var merged = durable
|
||||
for candidate in inMemory {
|
||||
if let index = merged.firstIndex(where: { $0.ciphertext == candidate.ciphertext }) {
|
||||
// Union progress before deciding the budget. Once either copy
|
||||
// has sprayed, the lower remaining budget is authoritative;
|
||||
// an older durable/original snapshot must not replenish it.
|
||||
let durableHasProgress = !merged[index].sprayedTo.isEmpty
|
||||
let memoryHasProgress = !candidate.sprayedTo.isEmpty
|
||||
let combinedSprayedTo = merged[index].sprayedTo.union(candidate.sprayedTo)
|
||||
switch (durableHasProgress, memoryHasProgress) {
|
||||
case (false, false):
|
||||
merged[index].copies = max(merged[index].copies, candidate.copies)
|
||||
case (true, false):
|
||||
break // durable progress owns its remaining budget
|
||||
case (false, true):
|
||||
merged[index].copies = candidate.copies
|
||||
case (true, true):
|
||||
// Concurrent progress can only spend budget; choosing the
|
||||
// lower branch prevents a merge from minting copies.
|
||||
merged[index].copies = min(merged[index].copies, candidate.copies)
|
||||
}
|
||||
merged[index].sprayedTo = combinedSprayedTo
|
||||
if candidate.tier == .favorite { merged[index].tier = .favorite }
|
||||
merged[index].lastRemoteHandoverAt = [merged[index].lastRemoteHandoverAt, candidate.lastRemoteHandoverAt]
|
||||
.compactMap { $0 }
|
||||
.max()
|
||||
merged[index].lastBridgePublishAt = [merged[index].lastBridgePublishAt, candidate.lastBridgePublishAt]
|
||||
.compactMap { $0 }
|
||||
.max()
|
||||
} else {
|
||||
merged.append(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// A long locked wake can accept new mail alongside a full durable
|
||||
// store. Re-apply deposit quotas: existing per-depositor mail keeps
|
||||
// its slot, while total-cap eviction sheds oldest verified mail first.
|
||||
merged.sort { $0.storedAt < $1.storedAt }
|
||||
var perDepositorCounts: [Data: Int] = [:]
|
||||
merged = merged.filter { envelope in
|
||||
let limit = envelope.tier == .favorite
|
||||
? Limits.maxPerFavoriteDepositor
|
||||
: Limits.maxPerVerifiedDepositor
|
||||
let count = perDepositorCounts[envelope.depositorNoiseKey, default: 0]
|
||||
guard count < limit else { return false }
|
||||
perDepositorCounts[envelope.depositorNoiseKey] = count + 1
|
||||
return true
|
||||
}
|
||||
while merged.filter({ $0.tier == .verified }).count > Limits.maxVerifiedEnvelopes {
|
||||
guard let victim = merged.firstIndex(where: { $0.tier == .verified }) else { break }
|
||||
merged.remove(at: victim)
|
||||
}
|
||||
while merged.count > Limits.maxEnvelopes {
|
||||
if let victim = merged.firstIndex(where: { $0.tier == .verified }) {
|
||||
merged.remove(at: victim)
|
||||
} else {
|
||||
merged.removeFirst()
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
|
||||
#if os(iOS)
|
||||
do {
|
||||
try FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: fileURL.path
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.warning("Failed to migrate courier store file protection: \(error)", category: .session)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
|
||||
Reference in New Issue
Block a user