mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:25:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4daba71eb2 | ||
|
|
917c477012 |
+3
-3
@@ -17,8 +17,8 @@ bitchat is designed for private, account-free communication. This policy describ
|
||||
|
||||
1. **Identity and cryptographic keys**
|
||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
|
||||
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
|
||||
|
||||
2. **Nickname, preferences, and relationships**
|
||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||
@@ -121,7 +121,7 @@ No cryptographic system can protect content after a recipient reads, copies, scr
|
||||
|
||||
## Your Controls
|
||||
|
||||
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
||||
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
||||
- **No account:** The project operates no account record for you to request or export.
|
||||
|
||||
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
@Published private(set) var isUnread: Bool = false
|
||||
|
||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
||||
/// instead of rewriting every surviving dictionary entry. This matters
|
||||
/// after the 1337-message cap is reached, when every steady-state tail
|
||||
/// append evicts one old row.
|
||||
///
|
||||
/// Out-of-order inserts and middle removals still reindex only the
|
||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
||||
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||
/// delivery-status lookup. Kept in sync on every mutation:
|
||||
/// - tail append: single insert
|
||||
/// - out-of-order insert: suffix reindex from the insertion point
|
||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||
/// rebuild does not change the asymptotics, and trim only happens once
|
||||
/// the cap (1337) is reached. Simple and correct beats the
|
||||
/// offset-tracking alternative here.
|
||||
private var indexByMessageID: [String: Int] = [:]
|
||||
private var indexOffset = 0
|
||||
|
||||
fileprivate init(id: ConversationID, cap: Int) {
|
||||
self.id = id
|
||||
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
}
|
||||
|
||||
func message(withID messageID: String) -> BitchatMessage? {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
reindex(from: index)
|
||||
} else {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// timeline position (in-place updates like media progress reuse the
|
||||
/// original timestamp); a new message goes through ordered insertion.
|
||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||
if let index = physicalIndex(forMessageID: message.id) {
|
||||
if let index = indexByMessageID[message.id] {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||
/// Returns `true` when the status was applied.
|
||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
let message = messages[index]
|
||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||
|
||||
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// observers still need an @Published emission to re-render.
|
||||
@discardableResult
|
||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
messages[index] = messages[index]
|
||||
return true
|
||||
}
|
||||
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// Removes a single message by ID. Returns the removed message, or
|
||||
/// `nil` when no message with that ID exists.
|
||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
let removed = messages.remove(at: index)
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
if index == 0 {
|
||||
indexOffset += 1
|
||||
} else {
|
||||
reindex(from: index)
|
||||
}
|
||||
reindex(from: index)
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
for id in removedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
indexOffset = 0
|
||||
reindex(from: 0)
|
||||
return removedIDs
|
||||
}
|
||||
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
fileprivate func clearMessages() {
|
||||
messages.removeAll()
|
||||
indexByMessageID.removeAll()
|
||||
indexOffset = 0
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
let message = messages[position]
|
||||
// Count equality + every message resolving to its own position
|
||||
// proves the index is exactly the inverse map (no stale extras).
|
||||
if let logicalIndex = indexByMessageID[message.id] {
|
||||
let expectedIndex = indexOffset + position
|
||||
if logicalIndex != expectedIndex {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
||||
if let index = indexByMessageID[message.id] {
|
||||
if index != position {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||
}
|
||||
} else {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
|
||||
private func reindex(from start: Int) {
|
||||
for index in start..<messages.count {
|
||||
indexByMessageID[messages[index].id] = indexOffset + index
|
||||
indexByMessageID[messages[index].id] = index
|
||||
}
|
||||
}
|
||||
|
||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
||||
let index = logicalIndex - indexOffset
|
||||
guard messages.indices.contains(index) else { return nil }
|
||||
return index
|
||||
}
|
||||
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
indexOffset += overflow
|
||||
reindex(from: 0)
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
@@ -860,8 +844,8 @@ extension Conversation {
|
||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||
func _testCorruptIndexEntries() {
|
||||
guard messages.count >= 2 else { return }
|
||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
||||
indexByMessageID[messages[1].id] = indexOffset
|
||||
indexByMessageID[messages[0].id] = 1
|
||||
indexByMessageID[messages[1].id] = 0
|
||||
}
|
||||
|
||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||
@@ -875,8 +859,8 @@ extension Conversation {
|
||||
func _testCorruptOrderingPreservingIndex() {
|
||||
guard messages.count >= 2 else { return }
|
||||
messages.swapAt(0, messages.count - 1)
|
||||
indexByMessageID[messages[0].id] = indexOffset
|
||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[messages[0].id] = 0
|
||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,7 +900,7 @@ extension ConversationStore {
|
||||
extension Conversation {
|
||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -4,6 +4,14 @@ import Foundation
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
private static let mediaSubdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
|
||||
/// Name prefix of in-flight live voice captures (progressively written by
|
||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||
@@ -24,6 +32,23 @@ struct BLEIncomingFileStore {
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
/// returning. Recreating the directory tree keeps later capture/receive
|
||||
/// paths usable without allowing a detached cleanup task to race them.
|
||||
func panicWipe() throws {
|
||||
let filesDirectory = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: filesDirectory.path) {
|
||||
try fileManager.removeItem(at: filesDirectory)
|
||||
}
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
try fileManager.createDirectory(
|
||||
at: filesDirectory.appendingPathComponent(subdirectory, isDirectory: true),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves (and creates) an incoming-media directory for callers that
|
||||
/// write progressively instead of via `save` (live voice captures).
|
||||
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||
@@ -113,15 +138,18 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func rootDirectory() throws -> URL {
|
||||
try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
|
||||
@@ -11,6 +11,13 @@ import BitFoundation
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainInstallLifecycleAction: Equatable {
|
||||
case markerPresent
|
||||
case bootstrapMarker
|
||||
case clearStaleKeys
|
||||
case retryLater
|
||||
}
|
||||
|
||||
final class KeychainManager: KeychainManagerProtocol {
|
||||
/// Default keychain for components that construct their own rather than
|
||||
/// having one injected. Under test this is an in-memory keychain: the
|
||||
@@ -41,27 +48,80 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
// Use consistent service name for all keychain items
|
||||
private let service = BitchatApp.bundleID
|
||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||
|
||||
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||
// device locked (identity-cache saves failed with -25308 throughout
|
||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||
// restoration must be able to read the noise keys before the user
|
||||
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
||||
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
||||
// migrating through device backups onto a second device.
|
||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
reconcileInstallLifecycle()
|
||||
migrateAccessibilityIfNeeded()
|
||||
#endif
|
||||
}
|
||||
|
||||
static func installLifecycleAction(
|
||||
containerKnowsMarker: Bool,
|
||||
markerRead: KeychainReadResult
|
||||
) -> KeychainInstallLifecycleAction {
|
||||
switch markerRead {
|
||||
case .success:
|
||||
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
|
||||
case .itemNotFound:
|
||||
return .bootstrapMarker
|
||||
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
|
||||
return .retryLater
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
private static let installMarkerAccount = "install_lifecycle_marker"
|
||||
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
|
||||
|
||||
/// Keychain items can survive app removal while the app container and its
|
||||
/// UserDefaults do not. The first version carrying this marker bootstraps
|
||||
/// without deleting existing users' identities. On a later reinstall, a
|
||||
/// surviving keychain marker plus a missing defaults marker proves the app
|
||||
/// container was replaced, so stale secrets are removed before use.
|
||||
private func reconcileInstallLifecycle() {
|
||||
let defaults = UserDefaults.standard
|
||||
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
|
||||
switch Self.installLifecycleAction(
|
||||
containerKnowsMarker: containerKnowsMarker,
|
||||
markerRead: markerRead
|
||||
) {
|
||||
case .markerPresent:
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
case .bootstrapMarker:
|
||||
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
}
|
||||
|
||||
case .clearStaleKeys:
|
||||
_ = deleteAllKeychainData()
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
case .retryLater:
|
||||
// Do not guess that a temporarily unreadable marker is absent.
|
||||
// Leaving the defaults flag unchanged lets a later construction
|
||||
// retry once protected keychain data is available.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||
/// the right class on their own (saves are delete-then-add), but the
|
||||
/// long-lived identity keys are written once and would otherwise stay
|
||||
/// unreadable while the device is locked.
|
||||
private func migrateAccessibilityIfNeeded() {
|
||||
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
||||
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||
|
||||
let query: [String: Any] = [
|
||||
@@ -76,7 +136,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
case errSecSuccess, errSecItemNotFound:
|
||||
// Nothing to migrate on a fresh install; both are terminal.
|
||||
UserDefaults.standard.set(true, forKey: flag)
|
||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
|
||||
default:
|
||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||
// leave the flag unset so the next launch retries.
|
||||
@@ -491,6 +551,15 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
}
|
||||
|
||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
||||
|
||||
#if os(iOS)
|
||||
// The non-secret marker is intentionally recreated after a panic so a
|
||||
// later uninstall/reinstall can still be distinguished from an in-place
|
||||
// upgrade. It never restores any wiped identity or relationship data.
|
||||
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
|
||||
UserDefaults.standard.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
}
|
||||
#endif
|
||||
|
||||
return totalDeleted > 0
|
||||
}
|
||||
@@ -526,18 +595,39 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||
var query: [String: Any] = [
|
||||
let primaryKeyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
var addQuery = primaryKeyQuery
|
||||
addQuery.merge([
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
||||
kSecAttrSynchronizable as String: false
|
||||
]) { _, new in new }
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
// Delete by the item's primary key only. Value/accessibility fields
|
||||
// are add attributes, not valid selectors for replacing an existing
|
||||
// item; including them can leave the old item in place and make the
|
||||
// subsequent add fail as a duplicate.
|
||||
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(deleteStatus)),
|
||||
context: "Unable to replace custom-service keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
return
|
||||
}
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(addStatus)),
|
||||
context: "Unable to save custom-service keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load data from a custom service
|
||||
|
||||
@@ -226,6 +226,21 @@ final class ChatLiveVoiceCoordinator {
|
||||
assemblies.values.contains { $0.messageID == message.id }
|
||||
}
|
||||
|
||||
/// Stop every live file handle/player before the panic media directory is
|
||||
/// removed. This prevents an in-flight assembly from continuing to write
|
||||
/// through an unlinked file after the wipe returns.
|
||||
func resetForPanic() {
|
||||
for assembly in Array(assemblies.values) {
|
||||
cancelAssembly(assembly)
|
||||
}
|
||||
for player in drainingPlayers.values {
|
||||
player.stop()
|
||||
}
|
||||
drainingPlayers.removeAll(keepingCapacity: false)
|
||||
finishedBursts.removeAll(keepingCapacity: false)
|
||||
updatePublicTalkerIndicator()
|
||||
}
|
||||
|
||||
/// Called for every inbound private message: when it is the finalized
|
||||
/// voice note of a burst we assembled (matched by burst ID in the file
|
||||
/// name), swap it into the existing live bubble and report `true` so the
|
||||
|
||||
@@ -78,6 +78,7 @@ final class ChatMediaTransferCoordinator {
|
||||
|
||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||
private var preparationGeneration: UInt64 = 0
|
||||
|
||||
init(context: any ChatMediaTransferContext) {
|
||||
self.context = context
|
||||
@@ -98,13 +99,14 @@ final class ChatMediaTransferCoordinator {
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
let generation = preparationGeneration
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
do {
|
||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
@@ -116,13 +118,13 @@ final class ChatMediaTransferCoordinator {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
@@ -132,11 +134,15 @@ final class ChatMediaTransferCoordinator {
|
||||
#if os(iOS)
|
||||
func processThenSendImage(_ image: UIImage?) {
|
||||
guard let image else { return }
|
||||
let generation = preparationGeneration
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: processedURL)
|
||||
return
|
||||
}
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
@@ -147,11 +153,15 @@ final class ChatMediaTransferCoordinator {
|
||||
#elseif os(macOS)
|
||||
func processThenSendImage(from url: URL?) {
|
||||
guard let url else { return }
|
||||
let generation = preparationGeneration
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: processedURL)
|
||||
return
|
||||
}
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
@@ -170,6 +180,7 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
let targetPeer = context.selectedPrivateChatPeer
|
||||
let generation = preparationGeneration
|
||||
|
||||
do {
|
||||
try ImageUtils.validateImageSource(at: sourceURL)
|
||||
@@ -184,7 +195,10 @@ final class ChatMediaTransferCoordinator {
|
||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: prepared.outputURL)
|
||||
return
|
||||
}
|
||||
let message = self.enqueueMediaMessage(
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||
targetPeer: targetPeer
|
||||
@@ -201,13 +215,13 @@ final class ChatMediaTransferCoordinator {
|
||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.context.addSystemMessage("Image is too large to send.")
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||
}
|
||||
}
|
||||
@@ -341,6 +355,19 @@ final class ChatMediaTransferCoordinator {
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
/// Invalidates detached preparation work and cancels every transfer that
|
||||
/// reached the transport. Stale tasks check the generation before they
|
||||
/// can recreate a message or send after a panic wipe.
|
||||
func resetForPanic() {
|
||||
preparationGeneration &+= 1
|
||||
let transferIDs = Set(transferIdToMessageIDs.keys)
|
||||
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
||||
messageIDToTransferId.removeAll(keepingCapacity: false)
|
||||
for transferID in transferIDs {
|
||||
context.cancelTransfer(transferID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatMediaTransferCoordinator {
|
||||
|
||||
@@ -177,7 +177,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
|
||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
|
||||
context: self,
|
||||
sweepsOnInit: !TestEnvironment.isRunningTests
|
||||
)
|
||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||
@@ -292,6 +295,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
var nostrRelayManager: NostrRelayManager?
|
||||
private let userDefaults = UserDefaults.standard
|
||||
let keychain: KeychainManagerProtocol
|
||||
private let panicMediaWipe: () throws -> Void
|
||||
/// Private group membership: keys in the keychain, metadata on disk.
|
||||
let groupStore: GroupStore
|
||||
private let nicknameKey = "bitchat.nickname"
|
||||
@@ -799,7 +803,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
locationManager: LocationChannelManager = .shared,
|
||||
readReceiptsDefaults: UserDefaults? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
sfMetrics: StoreAndForwardMetrics? = nil
|
||||
sfMetrics: StoreAndForwardMetrics? = nil,
|
||||
panicMediaWipe: (() throws -> Void)? = nil
|
||||
) {
|
||||
let conversations = conversations ?? ConversationStore()
|
||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||
@@ -814,6 +819,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
|
||||
self.keychain = keychain
|
||||
self.panicMediaWipe = panicMediaWipe ?? {
|
||||
// Unit tests share the developer's real Application Support
|
||||
// directory. Production uses the managed store; tests that need
|
||||
// to exercise the wipe inject a temporary-directory closure.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
try BLEIncomingFileStore().panicWipe()
|
||||
}
|
||||
self.groupStore = GroupStore(keychain: keychain)
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
@@ -1156,6 +1168,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
func panicClearAllData() {
|
||||
// Messages are processed immediately - nothing to flush
|
||||
|
||||
// Invalidate detached media preparation and close live capture file
|
||||
// handles before clearing state or removing the media directory.
|
||||
mediaTransferCoordinator.resetForPanic()
|
||||
liveVoiceCoordinator.resetForPanic()
|
||||
|
||||
// Clear all messages (public timelines and private chats live in the
|
||||
// single-writer ConversationStore; the derived `messages` view and
|
||||
// the legacy mirror empty with it)
|
||||
@@ -1278,44 +1295,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
}
|
||||
}
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
Task.detached(priority: .utility) {
|
||||
// Skipped under tests: the test process shares the user's real
|
||||
// ~/Library/Application Support/files tree, and this detached
|
||||
// utility-priority wipe fires at a nondeterministic time —
|
||||
// deleting media that concurrently running tests (e.g. the
|
||||
// sendImage flow) just wrote there, and the developer's real
|
||||
// app data with it.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
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)
|
||||
}
|
||||
|
||||
// BCH-01-013: Clear iOS app switcher snapshots
|
||||
// These are stored in Library/Caches/Snapshots/<bundle_id>/
|
||||
#if os(iOS)
|
||||
Self.clearAppSwitcherSnapshots()
|
||||
#endif
|
||||
// The wipe must finish before this security action returns. A detached
|
||||
// task could otherwise lose a race with a new capture or app exit and
|
||||
// leave pre-panic media behind.
|
||||
do {
|
||||
try panicMediaWipe()
|
||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||
}
|
||||
|
||||
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
|
||||
// the host user's real cache tree just as the default media wipe does.
|
||||
#if os(iOS)
|
||||
if !TestEnvironment.isRunningTests {
|
||||
Self.clearAppSwitcherSnapshots()
|
||||
}
|
||||
#endif
|
||||
|
||||
// Force immediate UI update for panic mode
|
||||
// UI updates immediately - no flushing needed
|
||||
|
||||
|
||||
@@ -188,6 +188,21 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
||||
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
|
||||
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
|
||||
|
||||
coordinator.resetForPanic()
|
||||
|
||||
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
|
||||
@@ -15,7 +15,9 @@ import BitFoundation
|
||||
|
||||
/// Creates a ChatViewModel with mock dependencies for testing
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
private func makeTestableViewModel(
|
||||
panicMediaWipe: (() throws -> Void)? = nil
|
||||
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
@@ -26,7 +28,8 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
transport: transport,
|
||||
panicMediaWipe: panicMediaWipe
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
@@ -1097,6 +1100,18 @@ struct ChatViewModelBluetoothTests {
|
||||
|
||||
struct ChatViewModelPanicTests {
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
||||
var wipeFinished = false
|
||||
let (viewModel, _) = makeTestableViewModel {
|
||||
wipeFinished = true
|
||||
}
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
#expect(wipeFinished)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_delegatesToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
@@ -45,154 +45,6 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
||||
))
|
||||
}
|
||||
|
||||
/// Deliberately simple O(n) model used to differentially test the store's
|
||||
/// optimized logical-index bookkeeping. It models observable behavior only;
|
||||
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
||||
private struct ReferenceConversationTimeline {
|
||||
struct Message: Equatable {
|
||||
let id: String
|
||||
let timestamp: Date
|
||||
let content: String
|
||||
var deliveryStatus: DeliveryStatus?
|
||||
|
||||
init(_ message: BitchatMessage) {
|
||||
id = message.id
|
||||
timestamp = message.timestamp
|
||||
content = message.content
|
||||
deliveryStatus = message.deliveryStatus
|
||||
}
|
||||
}
|
||||
|
||||
struct AppendResult {
|
||||
let inserted: Bool
|
||||
let trimmedCount: Int
|
||||
}
|
||||
|
||||
let cap: Int
|
||||
private(set) var messages: [Message] = []
|
||||
|
||||
func contains(_ id: String) -> Bool {
|
||||
messages.contains { $0.id == id }
|
||||
}
|
||||
|
||||
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
||||
guard !contains(message.id) else {
|
||||
return AppendResult(inserted: false, trimmedCount: 0)
|
||||
}
|
||||
|
||||
let snapshot = Message(message)
|
||||
var low = 0
|
||||
var high = messages.count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if messages[mid].timestamp <= snapshot.timestamp {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
messages.insert(snapshot, at: low)
|
||||
|
||||
let overflow = max(0, messages.count - cap)
|
||||
if overflow > 0 {
|
||||
messages.removeFirst(overflow)
|
||||
}
|
||||
return AppendResult(inserted: true, trimmedCount: overflow)
|
||||
}
|
||||
|
||||
mutating func upsert(_ message: BitchatMessage) -> Int {
|
||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||
messages[index] = Message(message)
|
||||
return 0
|
||||
}
|
||||
return append(message).trimmedCount
|
||||
}
|
||||
|
||||
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
||||
guard let index = messages.firstIndex(where: { $0.id == id }),
|
||||
messages[index].deliveryStatus != status else {
|
||||
return false
|
||||
}
|
||||
// The differential stream uses only unique `.delivered` values (or
|
||||
// an exact repeat), so no-downgrade policy is intentionally outside
|
||||
// this index-focused reference model.
|
||||
messages[index].deliveryStatus = status
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func remove(at index: Int) -> Message {
|
||||
messages.remove(at: index)
|
||||
}
|
||||
|
||||
mutating func removeAll(where predicate: (Message) -> Bool) {
|
||||
messages.removeAll(where: predicate)
|
||||
}
|
||||
|
||||
mutating func clear() {
|
||||
messages.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConversationStoreDifferentialRNG {
|
||||
private var state: UInt64
|
||||
|
||||
init(seed: UInt64) {
|
||||
state = seed
|
||||
}
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
state &+= 0x9E37_79B9_7F4A_7C15
|
||||
var value = state
|
||||
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||
return value ^ (value >> 31)
|
||||
}
|
||||
|
||||
mutating func index(upperBound: Int) -> Int {
|
||||
precondition(upperBound > 0)
|
||||
return Int(next() % UInt64(upperBound))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func expectStore(
|
||||
_ store: ConversationStore,
|
||||
matches reference: ReferenceConversationTimeline,
|
||||
issuedIDs: [String],
|
||||
checkpoint: String
|
||||
) {
|
||||
let conversation = store.conversation(for: .mesh)
|
||||
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
||||
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
||||
|
||||
let lookupSnapshot = reference.messages.compactMap { expected in
|
||||
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
||||
}
|
||||
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
||||
#expect(
|
||||
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
||||
"per-conversation ID set mismatch at \(checkpoint)"
|
||||
)
|
||||
|
||||
if !reference.messages.isEmpty {
|
||||
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
||||
let id = reference.messages[index].id
|
||||
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
||||
}
|
||||
}
|
||||
|
||||
let activeIDs = Set(reference.messages.map(\.id))
|
||||
var checkedStaleIDs = 0
|
||||
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
||||
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
||||
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
||||
checkedStaleIDs += 1
|
||||
if checkedStaleIDs == 16 { break }
|
||||
}
|
||||
|
||||
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
||||
}
|
||||
|
||||
@Suite("ConversationStore")
|
||||
struct ConversationStoreTests {
|
||||
|
||||
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
|
||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||
}
|
||||
|
||||
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
||||
@MainActor
|
||||
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
||||
let store = ConversationStore()
|
||||
let conversation = store.conversation(for: .mesh)
|
||||
let overflow = 64
|
||||
|
||||
for i in 0..<(conversation.cap + overflow) {
|
||||
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
||||
}
|
||||
|
||||
#expect(conversation.messages.first?.id == "m\(overflow)")
|
||||
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
||||
|
||||
// Exercise a suffix reindex after the head offset has advanced, then
|
||||
// trim the old head. The late row becomes the new first element.
|
||||
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
||||
#expect(store.append(late, to: .mesh))
|
||||
#expect(conversation.messages.first?.id == "late")
|
||||
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
||||
|
||||
// Head and middle removals, an in-place upsert, and a status update
|
||||
// must all resolve through the same logical index representation.
|
||||
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
||||
let middleID = "m\(overflow + conversation.cap / 2)"
|
||||
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
||||
|
||||
let probeID = "m\(overflow + 10)"
|
||||
store.upsertByID(
|
||||
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
||||
in: .mesh
|
||||
)
|
||||
#expect(conversation.message(withID: probeID)?.content == "edited")
|
||||
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||
#expect(store.auditInvariants().isEmpty)
|
||||
|
||||
// Clearing resets the logical offset as well as the maps.
|
||||
store.clear(.mesh)
|
||||
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
||||
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
||||
#expect(store.auditInvariants().isEmpty)
|
||||
}
|
||||
|
||||
@Test("logical index offset matches a reference model under adversarial mutations")
|
||||
@MainActor
|
||||
func logicalIndexOffsetDifferentialStress() async {
|
||||
let store = ConversationStore()
|
||||
let cap = store.conversation(for: .mesh).cap
|
||||
var reference = ReferenceConversationTimeline(cap: cap)
|
||||
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
||||
var issuedIDs: [String] = []
|
||||
var nextID = 0
|
||||
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
||||
var trimmedCount = 0
|
||||
|
||||
var tailAppendCount = 0
|
||||
var outOfOrderCount = 0
|
||||
var duplicateOrReuseCount = 0
|
||||
var headRemovalCount = 0
|
||||
var middleRemovalCount = 0
|
||||
var upsertCount = 0
|
||||
var deliveryUpdateCount = 0
|
||||
var filterCount = 0
|
||||
var clearCount = 0
|
||||
|
||||
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
||||
let number = nextID
|
||||
nextID += 1
|
||||
let id = "diff-\(number)"
|
||||
issuedIDs.append(id)
|
||||
let resolvedTimestamp: TimeInterval
|
||||
if let timestamp {
|
||||
resolvedTimestamp = timestamp
|
||||
} else {
|
||||
resolvedTimestamp = nextTailTimestamp
|
||||
nextTailTimestamp += 1
|
||||
}
|
||||
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
||||
return makeMessage(
|
||||
id: id,
|
||||
timestamp: resolvedTimestamp,
|
||||
content: "\(tag) \(number)\(dropMarker)"
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
||||
let expected = reference.append(message)
|
||||
let actual = store.append(message, to: .mesh)
|
||||
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
||||
trimmedCount += expected.trimmedCount
|
||||
return expected
|
||||
}
|
||||
|
||||
func refill(extra: Int, checkpoint: String) async {
|
||||
let appendCount = max(0, cap - reference.messages.count) + extra
|
||||
for index in 0..<appendCount {
|
||||
appendAndCompare(
|
||||
issueMessage(tag: "refill"),
|
||||
checkpoint: "\(checkpoint)-\(index)"
|
||||
)
|
||||
if index.isMultiple(of: 64) {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
||||
}
|
||||
|
||||
// Start well into steady state so the offset is already non-zero
|
||||
// before any mixed operations begin.
|
||||
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
||||
|
||||
for step in 0..<1_200 {
|
||||
if step == 300 || step == 900 {
|
||||
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
||||
reference.removeAll { $0.content.contains("[drop]") }
|
||||
filterCount += 1
|
||||
expectStore(
|
||||
store,
|
||||
matches: reference,
|
||||
issuedIDs: issuedIDs,
|
||||
checkpoint: "filter at step \(step)"
|
||||
)
|
||||
}
|
||||
|
||||
if step == 600 {
|
||||
store.clear(.mesh)
|
||||
reference.clear()
|
||||
clearCount += 1
|
||||
expectStore(
|
||||
store,
|
||||
matches: reference,
|
||||
issuedIDs: issuedIDs,
|
||||
checkpoint: "clear at step \(step)"
|
||||
)
|
||||
}
|
||||
|
||||
switch rng.index(upperBound: 100) {
|
||||
case 0..<35:
|
||||
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
||||
tailAppendCount += 1
|
||||
|
||||
case 35..<55:
|
||||
if reference.messages.isEmpty {
|
||||
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
||||
} else {
|
||||
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
||||
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
||||
appendAndCompare(
|
||||
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
||||
checkpoint: "out-of-order append \(step)"
|
||||
)
|
||||
outOfOrderCount += 1
|
||||
}
|
||||
|
||||
case 55..<65:
|
||||
if issuedIDs.isEmpty {
|
||||
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
||||
} else {
|
||||
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
||||
let message = makeMessage(
|
||||
id: reusedID,
|
||||
timestamp: nextTailTimestamp,
|
||||
content: "duplicate-or-trimmed-reuse \(step)"
|
||||
)
|
||||
nextTailTimestamp += 1
|
||||
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
||||
duplicateOrReuseCount += 1
|
||||
}
|
||||
|
||||
case 65..<73:
|
||||
if !reference.messages.isEmpty {
|
||||
let expected = reference.remove(at: 0)
|
||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||
.map(ReferenceConversationTimeline.Message.init)
|
||||
#expect(actual == expected, "head removal mismatch at step \(step)")
|
||||
headRemovalCount += 1
|
||||
}
|
||||
|
||||
case 73..<81:
|
||||
if !reference.messages.isEmpty {
|
||||
let middleStart = reference.messages.count / 4
|
||||
let middleWidth = max(1, reference.messages.count / 2)
|
||||
let index = min(
|
||||
reference.messages.count - 1,
|
||||
middleStart + rng.index(upperBound: middleWidth)
|
||||
)
|
||||
let expected = reference.remove(at: index)
|
||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||
.map(ReferenceConversationTimeline.Message.init)
|
||||
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
||||
middleRemovalCount += 1
|
||||
}
|
||||
|
||||
case 81..<90:
|
||||
let message: BitchatMessage
|
||||
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
||||
let timestamp = reference.messages.isEmpty
|
||||
? nil
|
||||
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||
.timestamp.timeIntervalSince1970
|
||||
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
||||
} else {
|
||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||
message = makeMessage(
|
||||
id: current.id,
|
||||
timestamp: current.timestamp.timeIntervalSince1970,
|
||||
content: "upsert-existing \(step)",
|
||||
deliveryStatus: current.deliveryStatus
|
||||
)
|
||||
}
|
||||
trimmedCount += reference.upsert(message)
|
||||
store.upsertByID(message, in: .mesh)
|
||||
upsertCount += 1
|
||||
|
||||
default:
|
||||
let id: String
|
||||
let repeatedStatus: DeliveryStatus?
|
||||
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
||||
id = "missing-\(step)"
|
||||
repeatedStatus = nil
|
||||
} else {
|
||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||
id = current.id
|
||||
repeatedStatus = current.deliveryStatus
|
||||
}
|
||||
let status: DeliveryStatus
|
||||
if step.isMultiple(of: 4), let repeatedStatus {
|
||||
status = repeatedStatus
|
||||
} else {
|
||||
status = .delivered(
|
||||
to: "peer",
|
||||
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
||||
)
|
||||
}
|
||||
let expected = reference.applyDeliveryStatus(status, to: id)
|
||||
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
||||
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
||||
deliveryUpdateCount += 1
|
||||
}
|
||||
|
||||
expectStore(
|
||||
store,
|
||||
matches: reference,
|
||||
issuedIDs: issuedIDs,
|
||||
checkpoint: "mixed operation \(step)"
|
||||
)
|
||||
|
||||
// This intentionally expensive MainActor stress test runs beside
|
||||
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
||||
// release the actor so their bounded waits can make progress.
|
||||
await Task.yield()
|
||||
|
||||
if (step + 1).isMultiple(of: 100) {
|
||||
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee another long run of one-row evictions after every other
|
||||
// mutation family has perturbed and rebuilt the offset/index state.
|
||||
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
||||
|
||||
#expect(trimmedCount > 1_200)
|
||||
#expect(tailAppendCount > 300)
|
||||
#expect(outOfOrderCount > 150)
|
||||
#expect(duplicateOrReuseCount > 75)
|
||||
#expect(headRemovalCount > 50)
|
||||
#expect(middleRemovalCount > 50)
|
||||
#expect(upsertCount > 75)
|
||||
#expect(deliveryUpdateCount > 75)
|
||||
#expect(filterCount == 2)
|
||||
#expect(clearCount == 1)
|
||||
}
|
||||
|
||||
// MARK: - Upsert
|
||||
|
||||
@Test("upsertByID replaces in place and appends when absent")
|
||||
|
||||
@@ -501,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||
}
|
||||
|
||||
// MARK: - 7b. ConversationStore append at the retention cap
|
||||
|
||||
/// Steady-state public timeline traffic after the 1337-message retention
|
||||
/// cap has been reached. Every tail append evicts the oldest row, which is
|
||||
/// the long-lived workload the cold `store.append` benchmark does not
|
||||
/// exercise.
|
||||
func testConversationStoreSteadyStateAppend() {
|
||||
let store = ConversationStore()
|
||||
let cap = TransportConfig.meshTimelineCap
|
||||
let messagesPerPass = 500
|
||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
for i in 0..<cap {
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "perf-steady-seed-\(i)",
|
||||
sender: "perfsender",
|
||||
content: "steady-state seed \(i)",
|
||||
timestamp: base.addingTimeInterval(Double(i)),
|
||||
isRelay: false
|
||||
),
|
||||
to: .mesh
|
||||
)
|
||||
}
|
||||
|
||||
var pass = 0
|
||||
var samples: [TimeInterval] = []
|
||||
measure {
|
||||
let startIndex = cap + pass * messagesPerPass
|
||||
let start = Date()
|
||||
for offset in 0..<messagesPerPass {
|
||||
let i = startIndex + offset
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "perf-steady-\(i)",
|
||||
sender: "perfsender",
|
||||
content: "steady-state message \(i)",
|
||||
timestamp: base.addingTimeInterval(Double(i)),
|
||||
isRelay: false
|
||||
),
|
||||
to: .mesh
|
||||
)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
pass += 1
|
||||
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
||||
}
|
||||
|
||||
reportThroughput(
|
||||
"store.steadyStateAppend",
|
||||
samples: samples,
|
||||
operations: messagesPerPass,
|
||||
unit: "messages"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||
|
||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
"store.append": 213201,
|
||||
"store.audit": 362
|
||||
},
|
||||
"_reference_local_numbers_2026_07": {
|
||||
"store.steadyStateAppend_before": 2315,
|
||||
"store.steadyStateAppend": 53976
|
||||
},
|
||||
"floors": {
|
||||
"nostrInbound.fresh": 450,
|
||||
"nostrInbound.duplicate": 250000,
|
||||
@@ -45,7 +41,6 @@
|
||||
"pipeline.privateIngest": 3000,
|
||||
"pipeline.publicIngest": 2400,
|
||||
"store.append": 48000,
|
||||
"store.steadyStateAppend": 10000,
|
||||
"store.audit": 70
|
||||
},
|
||||
"_slowest_observed_ci_numbers_2026_06": {
|
||||
@@ -61,4 +56,4 @@
|
||||
"store.append": 97423,
|
||||
"store.audit": 140
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,31 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("PreviewKeychainManager Tests")
|
||||
struct PreviewKeychainManagerTests {
|
||||
|
||||
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
|
||||
func installLifecycleDecision() {
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: true,
|
||||
markerRead: .success(Data([1]))
|
||||
) == .markerPresent)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .success(Data([1]))
|
||||
) == .clearStaleKeys)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .itemNotFound
|
||||
) == .bootstrapMarker)
|
||||
#expect(KeychainManager.installLifecycleAction(
|
||||
containerKnowsMarker: false,
|
||||
markerRead: .deviceLocked
|
||||
) == .retryLater)
|
||||
}
|
||||
|
||||
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||
func previewKeychainManagerRoundTripsData() {
|
||||
let manager = PreviewKeychainManager()
|
||||
|
||||
@@ -370,6 +370,46 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
let subdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
|
||||
for subdirectory in subdirectories {
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
|
||||
}
|
||||
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
|
||||
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data("legacy".utf8).write(to: unmanaged)
|
||||
|
||||
try store.panicWipe()
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
|
||||
for subdirectory in subdirectories {
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
var isDirectory: ObjCBool = false
|
||||
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
|
||||
#expect(isDirectory.boolValue)
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
|
||||
@@ -48,7 +48,7 @@ Residual risk: private-message metadata such as timing, radio adjacency, ciphert
|
||||
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
||||
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
||||
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
||||
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal.
|
||||
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
||||
|
||||
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
||||
|
||||
@@ -88,7 +88,7 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu
|
||||
|
||||
## Panic Wipe Coverage
|
||||
|
||||
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test.
|
||||
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test.
|
||||
|
||||
## Release Review Checklist
|
||||
|
||||
|
||||
Reference in New Issue
Block a user