mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
Add outgoing media size quota on shared file store.
Rebase onto current main deletion coordination: express incoming/outgoing scope inside enforceQuota (lock, protected paths, voice_live_ exclusion), route writers through BLEIncomingFileStore.shared, refresh privacy docs after the 7-day age retention change, and add tests for pending-delivery/live-capture protection plus deletion-reservation isolation.
This commit is contained in:
parent
1f59e814f9
commit
4ba72e55df
@ -40,7 +40,7 @@ bitchat is designed for private, account-free communication. This policy describ
|
||||
|
||||
6. **Media attachments**
|
||||
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
|
||||
- Incoming media is subject to a 100 MB quota with oldest-file eviction. All stored media, sent and received, is also deleted once it is more than seven days old, and immediately by panic wipe or app removal.
|
||||
- Incoming and outgoing media each have a separate 100 MB oldest-file quota. All stored media, sent and received, is also deleted once it is more than seven days old, and immediately by panic wipe or app removal.
|
||||
|
||||
7. **Optional location-channel state**
|
||||
- Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them.
|
||||
|
||||
@ -163,10 +163,12 @@ final class AppRuntime: ObservableObject {
|
||||
/// one. Expiry runs first so the migration never touches files the
|
||||
/// sweep is about to delete. Detached because `AppRuntime` is
|
||||
/// main-actor and both passes go file by file through the media tree;
|
||||
/// best-effort, nothing at launch depends on their results.
|
||||
/// best-effort, nothing at launch depends on their results. Uses the
|
||||
/// process-wide store so age expiry sees the same coordination state as
|
||||
/// quota eviction / writers.
|
||||
private func performMediaMaintenance() {
|
||||
Task.detached(priority: .utility) {
|
||||
let store = BLEIncomingFileStore()
|
||||
let store = BLEIncomingFileStore.shared
|
||||
store.expireAgedMedia()
|
||||
store.migrateFileProtectionIfNeeded()
|
||||
}
|
||||
|
||||
@ -73,7 +73,10 @@ enum ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
let outputURL = try makeOutputURL(
|
||||
outputDirectory: outputDirectory,
|
||||
reservingBytes: jpegData.count
|
||||
)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
@ -151,7 +154,10 @@ enum ImageUtils {
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
let outputURL = try makeOutputURL(
|
||||
outputDirectory: outputDirectory,
|
||||
reservingBytes: jpegData.count
|
||||
)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
@ -195,7 +201,7 @@ enum ImageUtils {
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil, reservingBytes: Int = 0) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||
@ -204,6 +210,9 @@ enum ImageUtils {
|
||||
if let outputDirectory {
|
||||
directory = outputDirectory
|
||||
} else {
|
||||
// Default Application Support outgoing tree shares the process-wide
|
||||
// store with BLE deletion/delivery so quota exclusions stay live.
|
||||
BLEIncomingFileStore.shared.enforceOutgoingQuota(reservingBytes: reservingBytes)
|
||||
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
|
||||
@ -241,6 +241,9 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
BLEIncomingFileStore.shared.enforceOutgoingQuota(
|
||||
reservingBytes: FileTransferLimits.maxVoiceNoteBytes
|
||||
)
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import BitFoundation
|
||||
|
||||
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
|
||||
/// it behind a protocol lets lifecycle races be tested without opening the
|
||||
@ -298,9 +299,21 @@ actor VoiceRecorder {
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
|
||||
|
||||
let baseDirectory = try outputDirectory
|
||||
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
let baseDirectory: URL
|
||||
if let outputDirectory {
|
||||
baseDirectory = outputDirectory
|
||||
} else {
|
||||
BLEIncomingFileStore.shared.enforceOutgoingQuota(
|
||||
reservingBytes: FileTransferLimits.maxVoiceNoteBytes
|
||||
)
|
||||
baseDirectory = try applicationFilesDirectory()
|
||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: baseDirectory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: BLEIncomingFileStore.mediaProtectionAttributes
|
||||
)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ struct PanicRecoveryOperations {
|
||||
}
|
||||
|
||||
static func live(
|
||||
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||
fileStore: BLEIncomingFileStore = .shared,
|
||||
defaults: UserDefaults = .standard
|
||||
) -> PanicRecoveryOperations {
|
||||
let defaultsKey = "bitchat.panicResetPending"
|
||||
@ -154,6 +154,44 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Process-wide store for the default Application Support media tree.
|
||||
/// `PayloadCoordination` is per-instance, so writers (image/voice capture)
|
||||
/// and BLE deletion/delivery must share one store or eviction exclusions
|
||||
/// become vacuous. Tests that inject a temp `baseDirectory` should still
|
||||
/// construct their own instance.
|
||||
static let shared = BLEIncomingFileStore()
|
||||
|
||||
/// Which managed media tree a size quota applies to. Incoming and outgoing
|
||||
/// keep separate 100 MB budgets; age retention still covers both.
|
||||
enum MediaQuotaScope {
|
||||
case incoming
|
||||
case outgoing
|
||||
|
||||
var subdirectories: [String] {
|
||||
switch self {
|
||||
case .incoming:
|
||||
return [
|
||||
"voicenotes/incoming",
|
||||
"images/incoming",
|
||||
"files/incoming"
|
||||
]
|
||||
case .outgoing:
|
||||
return [
|
||||
"voicenotes/outgoing",
|
||||
"images/outgoing",
|
||||
"files/outgoing"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
var logLabel: String {
|
||||
switch self {
|
||||
case .incoming: return "incoming"
|
||||
case .outgoing: return "outgoing"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposed so callers that write progressively into the store's
|
||||
/// directories (live voice captures) share the same file manager.
|
||||
let fileManager: FileManager
|
||||
@ -519,25 +557,34 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||
/// Frees least-recently-modified files in `scope` until `reservingBytes`
|
||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||
/// captures) are never evicted regardless of who triggers enforcement —
|
||||
/// a finalized transfer can arrive at quota while a burst is still
|
||||
/// streaming — but they still count toward usage.
|
||||
/// streaming — but they still count toward usage. Paths reserved for an
|
||||
/// in-flight delivery or private-media deletion are also skipped.
|
||||
func enforceQuota(reservingBytes: Int) {
|
||||
enforceQuota(reservingBytes: reservingBytes, scope: .incoming)
|
||||
}
|
||||
|
||||
/// Same oldest-first eviction as incoming, applied to the outgoing media
|
||||
/// directories (user-created voice notes, images, and files).
|
||||
func enforceOutgoingQuota(reservingBytes: Int) {
|
||||
enforceQuota(reservingBytes: reservingBytes, scope: .outgoing)
|
||||
}
|
||||
|
||||
func enforceQuota(reservingBytes: Int, scope: MediaQuotaScope) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
let incomingDirs = [
|
||||
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
||||
base.appendingPathComponent("images/incoming", isDirectory: true),
|
||||
base.appendingPathComponent("files/incoming", isDirectory: true)
|
||||
]
|
||||
let dirs = scope.subdirectories.map {
|
||||
base.appendingPathComponent($0, isDirectory: true)
|
||||
}
|
||||
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
||||
|
||||
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
||||
for dir in dirs where fileManager.fileExists(atPath: dir.path) {
|
||||
guard let contents = try? fileManager.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
||||
@ -576,14 +623,20 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
freedSpace += file.size
|
||||
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
||||
SecureLogger.debug(
|
||||
"🗑️ BCH-01-002: Deleted old \(scope.logLabel) file to free space: \(file.url.lastPathComponent)",
|
||||
category: .security
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
if freedSpace > 0 {
|
||||
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
||||
SecureLogger.info(
|
||||
"📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within \(scope.logLabel) files quota",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
||||
@ -593,11 +646,9 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
/// Deletes managed media older than `retention`, across both incoming and
|
||||
/// outgoing directories, and reports how many files went away.
|
||||
///
|
||||
/// The quota sweep above only bounds *size*, and only for incoming files,
|
||||
/// so a received photo or a sent voice note could sit on disk unbounded in
|
||||
/// time — long outliving the conversation it belonged to, which is what a
|
||||
/// seized device gives up. This bounds media by age instead, on the same
|
||||
/// principle as the courier envelope and gossip archive lifetimes.
|
||||
/// Size quotas bound each tree separately; this bounds *all* managed media
|
||||
/// by age as well — the same principle as courier envelope and gossip
|
||||
/// archive lifetimes.
|
||||
///
|
||||
/// Honors the same exclusions as quota eviction: in-flight live captures
|
||||
/// and files reserved by an in-progress delivery or deletion are left
|
||||
|
||||
@ -505,7 +505,7 @@ final class BLEService: NSObject {
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
initializeBluetoothManagers: Bool = true,
|
||||
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||
incomingFileStore: BLEIncomingFileStore = .shared,
|
||||
startSuspendedForPanicRecovery: Bool = false,
|
||||
noiseResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
|
||||
@ -158,7 +158,7 @@ final class ChatLiveVoiceCoordinator {
|
||||
/// `sweepsOnInit` exists for tests whose coordinator shares the real
|
||||
/// application-support directory: they pass `false` so parallel test
|
||||
/// runs never sweep each other's in-flight capture files.
|
||||
init(context: any ChatLiveVoiceContext, fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), sweepsOnInit: Bool = true) {
|
||||
init(context: any ChatLiveVoiceContext, fileStore: BLEIncomingFileStore = .shared, sweepsOnInit: Bool = true) {
|
||||
self.context = context
|
||||
self.fileStore = fileStore
|
||||
// Orphaned partial captures from a previous session (live-only bursts
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLEIncomingFileStore outgoing quotas")
|
||||
struct BLEIncomingFileStoreOutgoingQuotaTests {
|
||||
private func makeTempStore() throws -> (store: BLEIncomingFileStore, root: URL, cleanup: () -> Void) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bitchat-outgoing-quota-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
return (store, root, { try? FileManager.default.removeItem(at: root) })
|
||||
}
|
||||
|
||||
private func setModificationDate(_ date: Date, at url: URL) throws {
|
||||
try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
private func writeBytes(_ count: Int, to url: URL, modified: Date) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data(count: count).write(to: url)
|
||||
try setModificationDate(modified, at: url)
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaEvictsOldestAcrossMediaKinds() throws {
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let oldURL = root.appendingPathComponent("files/voicenotes/outgoing/voice_old.m4a")
|
||||
let newURL = root.appendingPathComponent("files/images/outgoing/img_new.jpg")
|
||||
try writeBytes(60 * 1024 * 1024, to: oldURL, modified: Date(timeIntervalSinceNow: -3600))
|
||||
try writeBytes(45 * 1024 * 1024, to: newURL, modified: Date(timeIntervalSinceNow: -60))
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: oldURL.path))
|
||||
#expect(FileManager.default.fileExists(atPath: newURL.path))
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaDoesNotEvictIncomingFiles() throws {
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let incomingURL = root.appendingPathComponent("files/voicenotes/incoming/voice_incoming.m4a")
|
||||
let outgoingOld = root.appendingPathComponent("files/voicenotes/outgoing/voice_out_old.m4a")
|
||||
let outgoingNew = root.appendingPathComponent("files/voicenotes/outgoing/voice_out_new.m4a")
|
||||
try writeBytes(80 * 1024 * 1024, to: incomingURL, modified: Date(timeIntervalSinceNow: -7200))
|
||||
try writeBytes(60 * 1024 * 1024, to: outgoingOld, modified: Date(timeIntervalSinceNow: -3600))
|
||||
try writeBytes(45 * 1024 * 1024, to: outgoingNew, modified: Date(timeIntervalSinceNow: -60))
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: incomingURL.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: outgoingOld.path))
|
||||
#expect(FileManager.default.fileExists(atPath: outgoingNew.path))
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaHonorsPendingDeliveryReservationAndLiveCapturePrefix() throws {
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
// Unprotected oldest candidate — should be the one that yields.
|
||||
let unprotectedOld = root.appendingPathComponent(
|
||||
"files/images/outgoing/unprotected_old.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
70 * 1024 * 1024,
|
||||
to: unprotectedOld,
|
||||
modified: Date(timeIntervalSinceNow: -7200)
|
||||
)
|
||||
|
||||
// In-flight live capture in the outgoing tree must never be unlinked
|
||||
// under an open FileHandle, even when it is the LRU-oldest file.
|
||||
let liveCapture = root.appendingPathComponent(
|
||||
"files/voicenotes/outgoing/\(BLEIncomingFileStore.liveCapturePrefix)aabbccddeeff0011.aac"
|
||||
)
|
||||
try writeBytes(
|
||||
20 * 1024 * 1024,
|
||||
to: liveCapture,
|
||||
modified: Date(timeIntervalSinceNow: -10_000)
|
||||
)
|
||||
|
||||
// save() registers pendingDeliveryPaths on this instance — the same
|
||||
// coordination BLE deletion/delivery uses. Eviction must skip it.
|
||||
let pending = try #require(store.save(
|
||||
data: Data(count: 15 * 1024 * 1024),
|
||||
preferredName: "pending_outgoing.jpg",
|
||||
subdirectory: "images/outgoing",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
))
|
||||
try setModificationDate(Date(timeIntervalSinceNow: -8000), at: pending)
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: unprotectedOld.path))
|
||||
#expect(FileManager.default.fileExists(atPath: liveCapture.path))
|
||||
#expect(FileManager.default.fileExists(atPath: pending.path))
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaHonorsIncomingDeletionReservationIsolation() throws {
|
||||
// Deletion reservations are registered on incoming receipt paths.
|
||||
// Prove that an active reservation on the *same store instance*
|
||||
// still protects that path when the incoming quota runs, while
|
||||
// outgoing eviction continues to free unprotected outgoing bytes.
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let reservedIncoming = try #require(store.save(
|
||||
data: Data(count: 40 * 1024 * 1024),
|
||||
preferredName: "reserved.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
))
|
||||
try setModificationDate(Date(timeIntervalSinceNow: -7200), at: reservedIncoming)
|
||||
#expect(store.commitPrivateMediaFile(
|
||||
messageID: "media-aabbccddeeff00112233445566778899",
|
||||
storedURL: reservedIncoming
|
||||
))
|
||||
// Finish delivery so pendingDeliveryPaths no longer protects it —
|
||||
// only the deletion reservation should.
|
||||
store.finishIncomingFileDelivery(at: reservedIncoming)
|
||||
|
||||
let reservation = try #require(store.reservePrivateMediaDeletion(
|
||||
messageIDs: ["media-aabbccddeeff00112233445566778899"],
|
||||
payloadRelativePaths: [
|
||||
"media-aabbccddeeff00112233445566778899":
|
||||
"images/incoming/\(reservedIncoming.lastPathComponent)"
|
||||
]
|
||||
))
|
||||
_ = reservation
|
||||
|
||||
let otherIncoming = root.appendingPathComponent(
|
||||
"files/images/incoming/other_old.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
70 * 1024 * 1024,
|
||||
to: otherIncoming,
|
||||
modified: Date(timeIntervalSinceNow: -3600)
|
||||
)
|
||||
|
||||
let outgoingOld = root.appendingPathComponent(
|
||||
"files/images/outgoing/out_old.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
60 * 1024 * 1024,
|
||||
to: outgoingOld,
|
||||
modified: Date(timeIntervalSinceNow: -3600)
|
||||
)
|
||||
let outgoingNew = root.appendingPathComponent(
|
||||
"files/images/outgoing/out_new.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
45 * 1024 * 1024,
|
||||
to: outgoingNew,
|
||||
modified: Date(timeIntervalSinceNow: -60)
|
||||
)
|
||||
|
||||
store.enforceQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: reservedIncoming.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: otherIncoming.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: outgoingOld.path))
|
||||
#expect(FileManager.default.fileExists(atPath: outgoingNew.path))
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,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 6 hours 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, and all managed media — incoming and outgoing — is additionally bounded by age: a launch-time sweep deletes anything older than seven days. In-flight live captures and files reserved by a delivery or deletion in progress are exempt regardless of age. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
||||
- Voice notes and images are stored in Application Support. Incoming and outgoing media each have a separate 100 MB oldest-first size quota, and all managed media is additionally bounded by age: a launch-time sweep deletes anything older than seven days. In-flight live captures and files reserved by a delivery or deletion in progress are exempt from both size eviction and age expiry. 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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user