From c079d2ab5d82ba2096452e83d721208c083fffa3 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:26:29 +0200 Subject: [PATCH] Harden what a locked or seized device gives away (#1484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Harden what a locked or seized device gives away The realistic compromise for many of the people this app is built for is not interception but a phone taken, and often unlocked under coercion. Content encryption is in good shape; these are the gaps around it. Hide notification previews, by default. Notification content is rendered by the system on the lock screen, so it was readable without unlocking: DM alerts carried the sender's nickname and the full message body, and geohash alerts put the geohash in the title. Alerts now state that a DM, mention, or location-channel activity arrived and withhold the rest until the app is opened. userInfo still carries the routing peer ID and deep link, neither of which the system displays, so taps land where they did. A settings toggle restores full previews for anyone who wants them. Default-on is the deliberate part: a phone face-up on a table should not narrate conversations, and someone who wants previews can say so. Cover the window on willResignActive, so the snapshot iOS stores for the app switcher shows a placeholder rather than an open conversation. Opaque rather than blurred, because blurred large text stays partly legible and the snapshot goes to disk. Added synchronously from a UIKit notification with queue: nil, since the capture follows shortly after and an OperationQueue hop or a SwiftUI state change can lose that race. Panic wipe already deleted snapshots already on disk; this stops new ones from being worth deleting. Bound media by age as well as size. The 100 MB quota only ever considered incoming files, so outgoing media had no lifetime at all and a received photo could outlive its conversation indefinitely. A launch-time sweep now deletes managed media older than seven days, incoming and outgoing, with the same exemptions quota eviction honors: in-flight live captures and files reserved by a delivery or deletion in progress. Make /clear tell the truth on the mesh timeline. It recorded an echo watermark and left the gossip archive on disk for up to 6 hours, so someone who cleared before a police stop had deleted nothing. Clearing now erases the archive too. The watermark still matters: it suppresses pre-clear messages this device hears again from peers. The cost is that the device stops serving recent public backlog until it hears fresh traffic, which is a fair reading of what clearing a timeline means. Documented in PRIVACY_POLICY.md and the privacy assessment, including a new section on what is deliberately NOT addressed: there is still no duress mechanism of any kind (no decoy passphrase, no wipe-on-failed-auth, no app lock), macOS gets no file-protection classes, and media is not sealed at the app layer. The duress question is a product decision as much as an engineering one, since in some jurisdictions destroying data on demand is itself an offence and hiding may protect someone better than destroying, so it is called out rather than guessed at. Three findings from the audit that prompted this work turned out to be already fixed on main and are not included: keychain accessibility is AfterFirstUnlockThisDeviceOnly with a retrying migration, the panic media wipe uses a two-location durable marker transaction, and panic already discards staged share-extension content. Co-Authored-By: Claude Opus 5 * Inject the previews preference instead of reading shared defaults CI caught a real problem this introduced. `NotificationServiceTests` already asserted the full-preview title and body, and both it and the new redaction tests read `NotificationPrivacySettings` from `UserDefaults.standard` in the same process. Whichever ran second depended on the other's cleanup, so it passed locally and failed in CI: XCTAssertEqual failed: ("🔒 new dm") is not equal to ("🔒 DM from Alice") Fixed at the source rather than by ordering or serialization. `NotificationService` now takes a `hidePreviewsProvider`, defaulting to the real preference, so each test states which behavior it asserts. The pre-existing test asks for previews shown and gains a redacted counterpart; the redaction tests no longer touch the shared store. `NotificationPrivacySettings` also gained store-injecting accessors so the default-value and round-trip assertions can use an isolated suite rather than mutating preferences other tests read. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: jack Co-authored-by: Claude Opus 5 --- PRIVACY_POLICY.md | 7 +- bitchat/App/AppRuntime.swift | 10 + bitchat/App/PrivacyScreen.swift | 100 ++ bitchat/BitchatApp.swift | 5 +- bitchat/Localizable.xcstrings | 1302 +++++++++++++++++ .../Services/BLE/BLEIncomingFileStore.swift | 81 + bitchat/Services/BLE/BLEService.swift | 6 + bitchat/Services/MeshEchoSettings.swift | 9 +- .../NotificationPrivacySettings.swift | 44 + bitchat/Services/NotificationService.swift | 59 +- bitchat/Services/Transport.swift | 4 + bitchat/Sync/GossipSyncManager.swift | 20 + .../ChatPublicConversationCoordinator.swift | 11 +- bitchat/ViewModels/ChatViewModel.swift | 5 + bitchat/Views/AppInfoView.swift | 25 + ...cConversationCoordinatorContextTests.swift | 34 + bitchatTests/GossipSyncManagerTests.swift | 39 + .../Services/MediaRetentionTests.swift | 117 ++ .../Services/NotificationRedactionTests.swift | 130 ++ .../Services/NotificationServiceTests.swift | 26 +- docs/privacy-assessment.md | 19 +- 21 files changed, 2031 insertions(+), 22 deletions(-) create mode 100644 bitchat/App/PrivacyScreen.swift create mode 100644 bitchat/Services/NotificationPrivacySettings.swift create mode 100644 bitchatTests/Services/MediaRetentionTests.swift create mode 100644 bitchatTests/Services/NotificationRedactionTests.swift diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 9ab1ac5b..637f8a61 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -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. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk. + - 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. 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. @@ -116,12 +116,15 @@ No cryptographic system can protect content after a recipient reads, copies, scr - **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first. - **Recent public mesh gossip:** up to 6 hours. - **Public board posts and tombstones:** until expiry, at most seven days. -- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal. +- **Media:** seven days, or sooner by quota eviction, panic wipe, or app removal. +- **Groups, favorites, preferences, identity keys, and bookmarks:** until removed by the feature, panic wipe, or app removal. - **Nostr data:** according to the policies of the relays that receive it. ## Your Controls - **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. +- **Notification previews:** Hidden by default, so lock-screen alerts do not show message text, sender names, or geohashes. Full previews can be turned on in settings. +- **Clearing a conversation:** Clearing the mesh timeline also deletes the recent public gossip this device had stored on disk. - **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. diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index bc21d4fe..0e922429 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -152,11 +152,21 @@ final class AppRuntime: ObservableObject { NetworkActivationService.shared.start() GeohashPresenceService.shared.start() checkForSharedContent() + expireAgedMedia() record(.launched) record(.startupCompleted) } + /// Drops media that has outlived the retention window. Off the main thread + /// and best-effort: the sweep walks the media tree, and nothing at launch + /// depends on its result. + private func expireAgedMedia() { + Task(priority: .utility) { + BLEIncomingFileStore().expireAgedMedia() + } + } + func handleOpenURL(_ url: URL) { record(.openedURL(url.absoluteString)) diff --git a/bitchat/App/PrivacyScreen.swift b/bitchat/App/PrivacyScreen.swift new file mode 100644 index 00000000..7e422057 --- /dev/null +++ b/bitchat/App/PrivacyScreen.swift @@ -0,0 +1,100 @@ +// +// PrivacyScreen.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if os(iOS) +import UIKit + +/// Covers the window while the app is not frontmost, so the snapshot iOS takes +/// for the app switcher shows a placeholder instead of the open conversation. +/// +/// The cover is added on `willResignActive` and removed on `didBecomeActive`. +/// Both are deliberately UIKit notifications rather than SwiftUI's `scenePhase`: +/// the snapshot is captured shortly after `willResignActive`, and adding an +/// opaque subview to the window synchronously in that callback is the only way +/// to guarantee it is in the render tree before the capture. A SwiftUI overlay +/// driven by state may not have been laid out yet. +/// +/// Panic wipe separately deletes any snapshots already on disk; this keeps new +/// ones from containing anything worth deleting. +final class PrivacyScreen { + static let shared = PrivacyScreen() + + private var cover: UIView? + private var observers: [NSObjectProtocol] = [] + + private init() {} + + /// Idempotent: repeated calls do not stack observers. + /// + /// `queue: nil` is required, not incidental. Passing an `OperationQueue` + /// would enqueue the handler to run in a later runloop turn, which the + /// snapshot can beat; with no queue the block runs synchronously on the + /// thread that posted the notification — the main thread, for UIApplication + /// lifecycle notifications. + func install() { + guard observers.isEmpty else { return } + let center = NotificationCenter.default + observers = [ + center.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: nil + ) { _ in + PrivacyScreen.shared.show() + }, + center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: nil + ) { _ in + PrivacyScreen.shared.hide() + } + ] + } + + private func show() { + guard cover == nil, let window = Self.activeWindow() else { return } + + // Opaque rather than a blur: blurred large text can stay partly + // legible, and the snapshot is stored on disk. + let view = UIView(frame: window.bounds) + view.backgroundColor = .systemBackground + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + let label = UILabel() + label.text = "bitchat" + label.font = .monospacedSystemFont(ofSize: 22, weight: .medium) + label.textColor = .secondaryLabel + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + window.addSubview(view) + cover = view + } + + private func hide() { + cover?.removeFromSuperview() + cover = nil + } + + private static func activeWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow } ?? + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first + } +} +#endif diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index d109eb55..cf2e1bd1 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -74,7 +74,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate { weak var runtime: AppRuntime? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - true + // Installed before the first resign-active so the app-switcher snapshot + // never captures an open conversation. + PrivacyScreen.shared.install() + return true } func applicationWillTerminate(_ application: UIApplication) { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index c5f6e84d..8c9f27b1 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -12468,6 +12468,564 @@ } } }, + "app_info.settings.hide_previews.subtitle" : { + "comment" : "Subtitle explaining what hiding notification message previews does", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تقول الإشعارات إن شيئًا ما وصل دون إظهار الرسالة ولا من أرسلها ولا قناة الموقع التي جاءت منها. من يحمل هاتفك المقفل لا يعرف شيئًا من شاشة القفل. مفعّل افتراضيًا." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিফিকেশন শুধু জানায় যে কিছু এসেছে — বার্তা, কে পাঠিয়েছে বা কোন লোকেশন চ্যানেল থেকে এসেছে তা দেখায় না। আপনার লক করা ফোন যার হাতেই থাকুক, লক স্ক্রিন থেকে কিছুই জানতে পারবে না। ডিফল্টভাবে চালু।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "benachrichtigungen sagen nur, dass etwas angekommen ist — ohne die nachricht, ohne wer sie geschickt hat und ohne den standortkanal, aus dem sie kam. wer dein gesperrtes handy in der hand hält, erfährt vom sperrbildschirm nichts. standardmäßig an." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "las notificaciones dicen que ha llegado algo sin mostrar el mensaje, quién lo envió ni de qué canal de ubicación viene. quien tenga tu teléfono bloqueado en la mano no aprende nada de la pantalla de bloqueo. activado por defecto." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلان‌ها فقط می‌گویند چیزی رسیده است، بدون نشان دادن متن پیام، فرستنده یا کانال موقعیتی که از آن آمده. هر کسی گوشی قفل‌شده‌ات را در دست بگیرد، از صفحهٔ قفل چیزی نمی‌فهمد. به‌طور پیش‌فرض روشن." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "sinasabi lang ng mga notification na may dumating, hindi ang mensahe, hindi kung sino ang nagpadala, at hindi kung saang channel ng lokasyon ito nanggaling. kahit sino ang may hawak ng naka-lock na telepono mo, wala siyang malalaman sa lock screen. naka-on bilang default." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "les notifications disent que quelque chose est arrivé sans montrer le message, qui l'a envoyé, ni de quel canal de localisation il vient. quiconque tient ton téléphone verrouillé n'apprend rien depuis l'écran de verrouillage. activé par défaut." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ההתראות מודיעות שמשהו הגיע בלי להציג את ההודעה, מי שלח אותה או מאיזה ערוץ מיקום היא באה. מי שמחזיק את הטלפון הנעול שלך לא לומד דבר ממסך הנעילה. פעיל כברירת מחדל." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नोटिफिकेशन सिर्फ बताते हैं कि कुछ आया है — न संदेश, न भेजने वाला, न वह लोकेशन चैनल जहाँ से वह आया। आपका लॉक किया हुआ फोन किसी के हाथ में हो, लॉक स्क्रीन से उसे कुछ पता नहीं चलेगा। डिफ़ॉल्ट रूप से चालू।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "notifikasi hanya bilang ada sesuatu yang masuk, tanpa menampilkan pesannya, siapa pengirimnya, atau dari kanal lokasi mana. siapa pun yang memegang ponselmu yang terkunci tidak mendapat apa pun dari layar kunci. aktif secara bawaan." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "le notifiche dicono che è arrivato qualcosa senza mostrare il messaggio, chi l'ha inviato o da quale canale posizione arriva. chi tiene in mano il tuo telefono bloccato non scopre nulla dalla schermata di blocco. attivo per impostazione predefinita." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知は何か届いたことだけを伝え、メッセージの内容も、送り主も、どのロケーションチャンネルから来たかも表示しません。ロックしたスマホを誰が手にしても、ロック画面からは何もわかりません。初期設定はオンです。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림은 무언가 도착했다는 사실만 알리고 메시지 내용, 보낸 사람, 어느 위치 채널에서 왔는지는 보여주지 않습니다. 잠긴 휴대폰을 누가 들고 있어도 잠금 화면에서 알 수 있는 것은 없습니다. 기본값은 켜짐입니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "notifikasi hanya memberitahu ada sesuatu masuk, tanpa menunjukkan mesejnya, siapa yang menghantar, atau dari kanal lokasi mana. sesiapa yang memegang telefonmu yang berkunci tidak tahu apa-apa daripada skrin kunci. hidup secara lalai." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरूले केवल कुनै कुरा आयो भन्छन्, सन्देश, पठाउने व्यक्ति वा कुन स्थान च्यानलबाट आयो भन्ने देखाउँदैनन्। तिम्रो लक भएको फोन जोसुकैको हातमा परे पनि लक स्क्रिनबाट केही थाहा पाउँदैन। पूर्वनिर्धारित रूपमा अन।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "meldingen zeggen alleen dat er iets is binnengekomen, zonder het bericht, de afzender of het locatiekanaal waaruit het komt. wie je vergrendelde telefoon vasthoudt, komt niets te weten via het vergrendelscherm. standaard aan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "powiadomienia mówią tylko, że coś przyszło — bez treści, bez nadawcy i bez kanału lokalizacji, z którego przyszło. ktokolwiek trzyma twój zablokowany telefon, nie dowie się niczego z ekranu blokady. domyślnie włączone." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "as notificações dizem que chegou algo sem mostrar a mensagem, quem a enviou nem de que canal de localização vem. quem tem o teu telemóvel bloqueado na mão não fica a saber nada pelo ecrã de bloqueio. ligado por predefinição." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "as notificações dizem que algo chegou sem mostrar a mensagem, quem enviou nem de qual canal de localização veio. quem estiver com seu celular bloqueado na mão não descobre nada pela tela de bloqueio. ligado por padrão." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "уведомления сообщают только, что что-то пришло — без текста, без отправителя и без канала локации, откуда оно. кто бы ни держал твой заблокированный телефон, с экрана блокировки он не узнает ничего. по умолчанию включено." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "aviseringar säger bara att något kommit in, utan meddelandet, vem som skickade det eller vilken platskanal det kom från. den som håller i din låsta telefon får inget veta från låsskärmen. på som standard." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள் ஏதோ வந்ததை மட்டும் சொல்லும் — செய்தியையும், அனுப்பியவரையும், எந்த இட சேனலிலிருந்து வந்ததையும் காட்டாது. உங்கள் பூட்டிய தொலைபேசியை யார் கையில் வைத்திருந்தாலும், பூட்டுத் திரையிலிருந்து எதுவும் தெரியாது. இயல்பாக இயக்கத்தில் உள்ளது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การแจ้งเตือนจะบอกแค่ว่ามีอะไรเข้ามา โดยไม่แสดงข้อความ ไม่บอกว่าใครส่ง และไม่บอกว่ามาจากช่องตามตำแหน่งใด ใครถือโทรศัพท์ที่ล็อกอยู่ของคุณก็ไม่รู้อะไรจากหน้าจอล็อก เปิดไว้เป็นค่าเริ่มต้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bildirimler yalnızca bir şeyin geldiğini söyler; mesajı, kimin gönderdiğini ya da hangi konum kanalından geldiğini göstermez. kilitli telefonunu elinde tutan kişi kilit ekranından hiçbir şey öğrenemez. varsayılan olarak açık." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "повідомлення лише кажуть, що щось надійшло — без тексту, без відправника й без каналу локації, звідки воно. хто б не тримав твій заблокований телефон, з екрана блокування він не дізнається нічого. типово ввімкнено." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطلاعات صرف یہ بتاتی ہیں کہ کچھ آیا ہے، نہ پیغام دکھاتی ہیں، نہ بھیجنے والا، نہ یہ کہ کس لوکیشن چینل سے آیا۔ آپ کا مقفل فون جس کے ہاتھ میں بھی ہو، لاک اسکرین سے اسے کچھ معلوم نہیں ہوتا۔ بطور طے شدہ آن ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thông báo chỉ cho biết có gì vừa đến, không hiện nội dung tin nhắn, ai gửi, hay đến từ kênh vị trí nào. ai đang giữ chiếc điện thoại đã khoá của bạn cũng không biết được gì từ màn hình khoá. bật theo mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知只告诉你有新消息抵达,不显示内容、发送者,也不显示来自哪个位置频道。谁拿着你锁定的手机,都无法从锁屏上得知任何信息。默认开启。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知只告訴你有新訊息抵達,不顯示內容、發送者,也不顯示來自哪個位置頻道。誰拿著你鎖定的手機,都無法從鎖定畫面得知任何資訊。預設開啟。" + } + } + } + }, + "app_info.settings.hide_previews.title" : { + "comment" : "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إخفاء معاينات الرسائل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বার্তার প্রিভিউ লুকান" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nachrichtenvorschau ausblenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hide message previews" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ocultar vistas previas de mensajes" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پنهان کردن پیش‌نمایش پیام‌ها" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "itago ang preview ng mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "masquer les aperçus de messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הסתרת תצוגה מקדימה של הודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "संदेश प्रीव्यू छुपाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sembunyikan pratinjau pesan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nascondi le anteprime dei messaggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メッセージのプレビューを隠す" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메시지 미리보기 숨기기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sembunyikan pratonton mesej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सन्देशको प्रिभ्यु लुकाउ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "berichtvoorbeelden verbergen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ukryj podglądy wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ocultar pré-visualizações de mensagens" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ocultar prévias de mensagens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "скрывать превью сообщений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dölj förhandsvisning av meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "செய்தி முன்தோற்றத்தை மறை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ซ่อนตัวอย่างข้อความ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesaj önizlemelerini gizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "приховувати попередній перегляд повідомлень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پیغام کی جھلک چھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ẩn nội dung xem trước tin nhắn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏消息预览" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱藏訊息預覽" + } + } + } + }, + "app_info.settings.privacy.title" : { + "comment" : "Section header (uppercase) for privacy settings such as hiding notification previews", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خصوصية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "গোপনীয়তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVATSPHÄRE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACY" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACIDAD" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حریم خصوصی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIBASYA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONFIDENTIALITÉ" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פרטיות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "गोपनीयता" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVASI" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACY" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プライバシー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개인정보 보호" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVASI" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "गोपनीयता" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACY" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRYWATNOŚĆ" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACIDADE" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACIDADE" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "КОНФИДЕНЦИАЛЬНОСТЬ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVAT" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தனியுரிமை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ความเป็นส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GİZLİLİK" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "КОНФІДЕНЦІЙНІСТЬ" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "رازداری" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "QUYỀN RIÊNG TƯ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐私" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱私" + } + } + } + }, "app_info.tab.info" : { "extractionState" : "manual", "localizations" : { @@ -60847,6 +61405,750 @@ } } }, + "notification.redacted.body" : { + "comment" : "Lock-screen notification body shown in place of the message text when message previews are hidden", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "افتح bitchat للقراءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পড়তে bitchat খুলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "öffne bitchat zum lesen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open bitchat to read" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre bitchat para leer" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "برای خواندن bitchat را باز کن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "buksan ang bitchat para makita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvre bitchat pour lire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פתח את bitchat כדי לקרוא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पढ़ने के लिए bitchat खोलें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "buka bitchat untuk membaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "apri bitchat per leggere" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "読むには bitchat を開いてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "읽으려면 bitchat을 열어보세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "buka bitchat untuk membaca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पढ्न bitchat खोल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "open bitchat om te lezen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "otwórz bitchat, aby przeczytać" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre o bitchat para ler" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "abra o bitchat para ler" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "открой bitchat, чтобы прочитать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "öppna bitchat för att läsa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படிக்க bitchat ஐத் திறக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิด bitchat เพื่ออ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "okumak için bitchat'i aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "відкрий bitchat, щоб прочитати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پڑھنے کے لیے bitchat کھولیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mở bitchat để đọc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 bitchat 查看" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟 bitchat 查看" + } + } + } + }, + "notification.redacted.dm.title" : { + "comment" : "Lock-screen notification title for a received direct message when message previews are hidden; deliberately names neither the sender nor the content", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 رسالة خاصة جديدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 নতুন ডিএম" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 neue pn" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 new dm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nuevo md" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 پیام خصوصی جدید" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 bagong dm" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nouveau mp" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 הודעה פרטית חדשה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 नया dm" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 dm baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nuovo dm" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 新しいdm" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 새 dm" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 dm baru" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 नयाँ dm" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nieuwe dm" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nowy dm" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nova mensagem privada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 novo dm" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 новое личное сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nytt dm" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 புதிய dm" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 dm ใหม่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 yeni dm" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 нове приватне повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 نیا نجی پیغام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 dm mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 新的 dm" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 新的 dm" + } + } + } + }, + "notification.redacted.geohash.title" : { + "comment" : "Lock-screen notification title for activity in a location channel when message previews are hidden; deliberately omits the geohash", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 نشاط جديد قريب منك" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 আশেপাশে নতুন কার্যকলাপ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 neue aktivität in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 new activity nearby" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nueva actividad cerca" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 فعالیت جدید در نزدیکی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 may bagong aktibidad sa paligid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nouvelle activité à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 פעילות חדשה בסביבה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 आसपास नई गतिविधि" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 aktivitas baru di sekitar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nuova attività nelle vicinanze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 近くで新しい動き" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 근처에 새 활동" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 aktiviti baru berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 नजिकै नयाँ गतिविधि" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nieuwe activiteit in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nowa aktywność w okolicy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nova atividade por perto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nova atividade por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 новая активность рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 ny aktivitet i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 அருகில் புதிய செயல்பாடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 มีความเคลื่อนไหวใหม่ใกล้คุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 yakınında yeni hareket" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 нова активність поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 قریب نئی سرگرمی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 hoạt động mới gần bạn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 附近有新动态" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 附近有新動態" + } + } + } + }, + "notification.redacted.mention.title" : { + "comment" : "Lock-screen notification title telling someone they were mentioned when message previews are hidden; deliberately omits who mentioned them", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 تمّت الإشارة إليك" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 আপনাকে মেনশন করা হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 du wurdest erwähnt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 you were mentioned" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 te han mencionado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 منشن شدی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 nabanggit ka" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 on t'a mentionné" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 הוזכרת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 आपको मेंशन किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 kamu disebut" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 ti hanno menzionato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 メンションされました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 멘션되었습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 kamu disebut" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 तिमीलाई उल्लेख गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 je bent vermeld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 wspomniano o tobie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 mencionaram-te" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 mencionaram você" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 тебя упомянули" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 du har omnämnts" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 உங்களைக் குறிப்பிட்டுள்ளனர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 คุณถูกกล่าวถึง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 senden bahsedildi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 тебе згадали" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 آپ کا ذکر ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 bạn được nhắc tới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 你被提及" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 你被提及" + } + } + } + }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index 5fea056b..826a391c 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -114,6 +114,9 @@ struct BLEIncomingFileStore: @unchecked Sendable { } private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024 + /// How long managed media may stay on disk. Bounds by age what the quota + /// only bounds by size; see `expireAgedMedia(retention:)`. + static let defaultMediaRetention: TimeInterval = 7 * 24 * 60 * 60 /// Kept outside `files/` so deleting the media tree cannot erase the /// fail-closed startup decision before the full panic has committed. private static let panicRecoveryPendingMarkerFileName = @@ -569,6 +572,84 @@ 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. + /// + /// Honors the same exclusions as quota eviction: in-flight live captures + /// and files reserved by an in-progress delivery or deletion are left + /// alone regardless of age. + @discardableResult + func expireAgedMedia(retention: TimeInterval = Self.defaultMediaRetention) -> Int { + guard retention > 0 else { return 0 } + + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + + let cutoff = dateProvider().addingTimeInterval(-retention) + let activeDeletionPaths = payloadCoordination + .deletionReservations.values.reduce(into: Set()) { + $0.formUnion($1) + } + let protectedPaths = activeDeletionPaths.union( + payloadCoordination.pendingDeliveryPaths + ) + + var removed = 0 + do { + let base = try filesDirectory() + for subdirectory in Self.mediaSubdirectories { + let dir = base.appendingPathComponent(subdirectory, isDirectory: true) + guard fileManager.fileExists(atPath: dir.path) else { continue } + guard let contents = try? fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for fileURL in contents { + guard let modified = try? fileURL.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate else { continue } + guard modified < cutoff else { continue } + guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } + guard !protectedPaths.contains( + fileURL.standardizedFileURL.path + ) else { continue } + + do { + try fileManager.removeItem(at: fileURL) + removed += 1 + } catch { + SecureLogger.warning( + "⚠️ Failed to expire aged media file: \(error)", + category: .security + ) + } + } + } + } catch { + SecureLogger.warning( + "⚠️ Could not expire aged media: \(error)", + category: .security + ) + return removed + } + + if removed > 0 { + SecureLogger.info( + "🗑️ Expired \(removed) media file(s) older than the retention window", + category: .security + ) + } + return removed + } + private func filesDirectory() throws -> URL { let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true) try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index b04cce5a..919c3820 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2574,6 +2574,12 @@ final class BLEService: NSObject { gossipSyncManager?.removePublicMessages(from: peerID) } + /// Clearing the mesh timeline erases the archive behind it, so the cleared + /// history is gone from disk rather than merely hidden from the timeline. + func purgeAllArchivedPublicMessages() { + gossipSyncManager?.removeAllPublicMessages() + } + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { guard let generation = capturePanicLifecycleGeneration() else { return diff --git a/bitchat/Services/MeshEchoSettings.swift b/bitchat/Services/MeshEchoSettings.swift index 347c178a..824f78c7 100644 --- a/bitchat/Services/MeshEchoSettings.swift +++ b/bitchat/Services/MeshEchoSettings.swift @@ -10,9 +10,12 @@ import Foundation /// Watermark for "heard here earlier" echoes: clearing the mesh timeline /// (triple-tap or /clear) records the moment, and the next launch only -/// re-seeds archived messages heard after it. The archive itself is left -/// alone — the device keeps carrying those messages for peers; the user -/// just doesn't want to see them again. +/// re-seeds archived messages heard after it. +/// +/// Clearing also erases the archive itself, so cleared history is gone from +/// disk rather than merely hidden from the timeline. The watermark still +/// matters afterwards: it keeps messages this device hears again from peers, +/// which predate the clear, from reappearing as echoes. enum MeshEchoSettings { private static let clearedThroughKey = "meshEchoes.clearedThrough" diff --git a/bitchat/Services/NotificationPrivacySettings.swift b/bitchat/Services/NotificationPrivacySettings.swift new file mode 100644 index 00000000..da67c30b --- /dev/null +++ b/bitchat/Services/NotificationPrivacySettings.swift @@ -0,0 +1,44 @@ +// +// NotificationPrivacySettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Controls how much a delivered notification says while the device is locked. +/// +/// Notification content is rendered by the system on the lock screen, so it is +/// readable by anyone holding the phone without unlocking it. With previews +/// hidden, alerts still say that something arrived and stay tappable, but the +/// message body, the sender's nickname, and the geohash are withheld until the +/// app is opened. +/// +/// Defaults to hidden: a locked phone lying on a table or taken at a protest +/// should not narrate conversations, and someone who wants previews can say so. +enum NotificationPrivacySettings { + private static let hidePreviewsKey = "notifications.hideMessagePreviews" + + static var hideMessagePreviews: Bool { + get { hideMessagePreviews(in: .standard) } + set { setHideMessagePreviews(newValue, in: .standard) } + } + + /// Store-injecting forms, so tests can assert the default and both settings + /// without touching the shared preferences other tests read. + static func hideMessagePreviews(in defaults: UserDefaults) -> Bool { + defaults.object(forKey: hidePreviewsKey) as? Bool ?? true + } + + static func setHideMessagePreviews(_ hide: Bool, in defaults: UserDefaults) { + defaults.set(hide, forKey: hidePreviewsKey) + } + + /// Panic-wipe hook. Removing the key restores the hidden default, so a + /// wiped device cannot come back louder than a fresh install. + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: hidePreviewsKey) + } +} diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 4b437798..dc8ef193 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -95,6 +95,35 @@ final class NotificationService { static let nearbyCategoryID = "chat.bitchat.category.nearby" static let waveActionID = "chat.bitchat.action.wave" + /// Copy used when `NotificationPrivacySettings.hideMessagePreviews` is on. + /// These say that something arrived without naming who sent it, quoting it, + /// or disclosing which geohash it came from. + private enum Redacted { + static var directMessageTitle: String { + String(localized: "notification.redacted.dm.title", defaultValue: "🔒 new dm", comment: "Lock-screen notification title for a received direct message when message previews are hidden; deliberately names neither the sender nor the content") + } + static var mentionTitle: String { + String(localized: "notification.redacted.mention.title", defaultValue: "🫵 you were mentioned", comment: "Lock-screen notification title telling someone they were mentioned when message previews are hidden; deliberately omits who mentioned them") + } + static var geohashActivityTitle: String { + String(localized: "notification.redacted.geohash.title", defaultValue: "📍 new activity nearby", comment: "Lock-screen notification title for activity in a location channel when message previews are hidden; deliberately omits the geohash") + } + static var body: String { + String(localized: "notification.redacted.body", defaultValue: "open bitchat to read", comment: "Lock-screen notification body shown in place of the message text when message previews are hidden") + } + } + + /// Whether delivered alerts must withhold sender, content, and geohash. + /// + /// Injected rather than read from the preference directly so tests state + /// which behavior they are asserting instead of inheriting whatever the + /// shared preference happens to hold when they run. + private let hidePreviewsProvider: () -> Bool + + private var hidePreviews: Bool { + hidePreviewsProvider() + } + private let isRunningTestsProvider: () -> Bool private let authorizer: NotificationAuthorizing private let requestDeliverer: NotificationRequestDelivering @@ -106,6 +135,7 @@ final class NotificationService { } private init() { + self.hidePreviewsProvider = { NotificationPrivacySettings.hideMessagePreviews } self.isRunningTestsProvider = { let env = ProcessInfo.processInfo.environment return NSClassFromString("XCTestCase") != nil || @@ -130,12 +160,14 @@ final class NotificationService { isRunningTestsProvider: @escaping () -> Bool, authorizer: NotificationAuthorizing, requestDeliverer: NotificationRequestDelivering, - categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar() + categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar(), + hidePreviewsProvider: @escaping () -> Bool = { NotificationPrivacySettings.hideMessagePreviews } ) { self.isRunningTestsProvider = isRunningTestsProvider self.authorizer = authorizer self.requestDeliverer = requestDeliverer self.categoryRegistrar = categoryRegistrar + self.hidePreviewsProvider = hidePreviewsProvider } func requestAuthorization() { @@ -197,29 +229,34 @@ final class NotificationService { } func sendMentionNotification(from sender: String, message: String) { - let title = "🫵 you were mentioned by \(sender)" - let body = message + let title = hidePreviews ? Redacted.mentionTitle : "🫵 you were mentioned by \(sender)" + let body = hidePreviews ? Redacted.body : message let identifier = "mention-\(UUID().uuidString)" - + sendLocalNotification(title: title, body: body, identifier: identifier) } - + func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) { - let title = "🔒 DM from \(sender)" - let body = message + let title = hidePreviews ? Redacted.directMessageTitle : "🔒 DM from \(sender)" + let body = hidePreviews ? Redacted.body : message let identifier = "private-\(UUID().uuidString)" + // Routing payload, not display copy: `userInfo` never reaches the lock + // screen, and the conversation to open still has to be identifiable. let userInfo = ["peerID": peerID.id, "senderName": sender] - + sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) } - + // Geohash public chat notification with deep link to a specific geohash func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) { - let title = "\(titlePrefix)\(geohash)" + // The geohash itself is location data, so hiding previews withholds it + // from the alert while leaving the deep link intact for the tap. + let title = hidePreviews ? Redacted.geohashActivityTitle : "\(titlePrefix)\(geohash)" + let body = hidePreviews ? Redacted.body : bodyPreview let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)" let deeplink = "bitchat://geohash/\(geohash)" let userInfo: [String: Any] = ["deeplink": deeplink] - sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo) + sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) } func sendNetworkAvailableNotification(peerCount: Int) { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index ad4d3cec..76e6406f 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -293,6 +293,9 @@ protocol Transport: AnyObject { /// Drops any carried public messages from a (newly blocked) sender so /// they can't resurface as archived echoes on a later launch. func purgeArchivedPublicMessages(from peerID: PeerID) + /// Erases the whole carried public-message archive, on disk included, so + /// clearing the mesh timeline deletes that history rather than hiding it. + func purgeAllArchivedPublicMessages() } /// A carried public mesh message from the store-and-forward window, decoded @@ -403,6 +406,7 @@ extension Transport { } func purgeArchivedPublicMessages(from peerID: PeerID) {} + func purgeAllArchivedPublicMessages() {} } protocol TransportPeerEventsDelegate: AnyObject { diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 62f6394e..eeb1ff97 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -733,6 +733,26 @@ final class GossipSyncManager { } } + /// Drop every carried public message and clear the archive on disk. + /// + /// Used when someone clears the mesh timeline: the watermark already stops + /// cleared messages from being shown again, so anything left in the + /// archive is retained purely to serve other peers — and a person who + /// clears a timeline reasonably reads that as "this is gone from my + /// phone". The cost is that this device stops offering the recent public + /// backlog to peers until it hears fresh traffic. + func removeAllPublicMessages() { + queue.async { [weak self] in + guard let self else { return } + self.messages.remove { _ in true } + self.archiveDirty = true + // Persist now rather than waiting for maintenance: a relaunch in + // the gap would restore the purged messages from disk. + self.persistArchiveIfDirty() + self.archive?.wipe() + } + } + private func removeState(for peerID: PeerID) { // Deliberately keeps the peer's prekey bundle: bundles exist to reach // owners who left the mesh, and they age out on their own schedule. diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 2cd1b4a9..301c4bee 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -44,6 +44,9 @@ protocol ChatPublicConversationContext: AnyObject { func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) /// Empties a public conversation's timeline (`/clear`). func clearPublicConversation(_ conversationID: ConversationID) + /// Erases the on-disk archive of carried public mesh messages, so clearing + /// the mesh timeline deletes that history instead of only hiding it. + func purgeArchivedPublicMessages() /// Queues a system message for the next geohash channel visit. func queueGeohashSystemMessage(_ content: String) @@ -289,12 +292,14 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) // Clearing the mesh timeline also dismisses its archived echoes for - // good: the watermark stops the next launch from re-seeding them - // (the archive itself keeps carrying the messages for peers), and - // the dedup keys go so a cleared message arriving live shows again. + // good: the watermark stops the next launch from re-seeding them, the + // archive on disk is erased so the cleared history is actually gone + // rather than merely hidden, and the dedup keys go so a cleared + // message arriving live shows again. if case .mesh = context.activeChannel { MeshEchoSettings.clearedThrough = Date() archivedEchoKeys.removeAll() + context.purgeArchivedPublicMessages() } // The SPM test process shares the real Application Support tree, so this diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 79fabd87..565f5070 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1066,6 +1066,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage conversations.clear(conversationID) } + func purgeArchivedPublicMessages() { + meshService.purgeAllArchivedPublicMessages() + } + /// Queues a system message for the next geohash channel visit. (Tiny /// UI-flow queue formerly on `PublicTimelineStore`; it is notice text, /// not conversation state, so it stays on the owner.) @@ -1628,6 +1632,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage GeohashChatActivityTracker.shared.clear() MeshSightingsTracker.shared.clear() MeshEchoSettings.reset() + NotificationPrivacySettings.reset() // Drop private group keys and rosters (keychain + disk) groupStore.wipe() diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index d29c2888..62ec5f9d 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -21,6 +21,7 @@ struct AppInfoView: View { @State private var showTopology = false @State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled @State private var locationNotesEnabled = LocationNotesSettings.enabled + @State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews @ObservedObject private var locationManager = LocationChannelManager.shared /// Sticky across opens: first-ever open lands on Info (the gentler /// introduction), and afterwards the sheet reopens wherever it was left. @@ -83,6 +84,10 @@ struct AppInfoView: View { static let toggleOn: LocalizedStringKey = "common.toggle.on" static let toggleOff: LocalizedStringKey = "common.toggle.off" + static let privacyTitle = String(localized: "app_info.settings.privacy.title", defaultValue: "PRIVACY", comment: "Section header (uppercase) for privacy settings such as hiding notification previews") + static let hidePreviewsTitle = String(localized: "app_info.settings.hide_previews.title", defaultValue: "hide message previews", comment: "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications") + static let hidePreviewsSubtitle = String(localized: "app_info.settings.hide_previews.subtitle", defaultValue: "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default.", comment: "Subtitle explaining what hiding notification message previews does") + static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings") static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation") static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut") @@ -477,6 +482,26 @@ struct AppInfoView: View { } } + // Privacy: what a locked, seized, or borrowed phone gives away + // without being unlocked. + VStack(alignment: .leading, spacing: 12) { + SectionHeader(verbatim: Strings.Settings.privacyTitle) + + settingsCard { + settingToggle( + title: Text(verbatim: Strings.Settings.hidePreviewsTitle), + subtitle: Text(verbatim: Strings.Settings.hidePreviewsSubtitle), + isOn: Binding( + get: { hideMessagePreviews }, + set: { newValue in + hideMessagePreviews = newValue + NotificationPrivacySettings.hideMessagePreviews = newValue + } + ) + ) + } + } + // Danger zone if onPanicWipe != nil { VStack(alignment: .leading, spacing: 12) { diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 54858217..8a9aec64 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -79,12 +79,17 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon } private(set) var clearedConversations: [ConversationID] = [] + private(set) var archivePurgeCount = 0 func clearPublicConversation(_ conversationID: ConversationID) { clearedConversations.append(conversationID) conversations[conversationID] = [] } + func purgeArchivedPublicMessages() { + archivePurgeCount += 1 + } + func queueGeohashSystemMessage(_ content: String) { queuedGeohashSystemMessages.append(content) } @@ -345,6 +350,35 @@ struct ChatPublicConversationCoordinatorContextTests { #expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash)) } + /// `/clear` on the mesh timeline used to only record a watermark, leaving + /// the archive on disk for up to its freshness window — so someone who + /// cleared before a police stop had deleted nothing. It must now erase the + /// archive behind the timeline. + @Test @MainActor + func clearCurrentPublicTimeline_onMesh_erasesTheArchive() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + context.activeChannel = .mesh + + coordinator.clearCurrentPublicTimeline() + + #expect(context.clearedConversations == [.mesh]) + #expect(context.archivePurgeCount == 1) + } + + /// Geohash timelines are Nostr-backed and carry no mesh gossip archive, so + /// clearing one must not reach for it. + @Test @MainActor + func clearCurrentPublicTimeline_onGeohash_leavesTheArchiveAlone() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) + + coordinator.clearCurrentPublicTimeline() + + #expect(context.archivePurgeCount == 0) + } + @Test @MainActor func blockGeohashUser_purgesMessagesMappingsAndPrivateChats() async { let context = MockChatPublicConversationContext() diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 2d39ea88..ff2c406d 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -810,6 +810,45 @@ struct GossipSyncManagerTests { #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0) } + /// Clearing the mesh timeline must leave nothing behind on disk: a + /// relaunch that restored the archive would undo the clear. + @Test func removeAllPublicMessagesErasesTheArchiveOnDisk() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let senderID = try #require(Data(hexString: "1122334455667788")) + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: senderID, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01, 0x02]), + signature: nil, + ttl: 1 + ) + + let manager = GossipSyncManager( + myPeerID: myPeerID, + requestSyncManager: RequestSyncManager(), + archive: GossipMessageArchive(fileURL: fileURL) + ) + manager.onPublicPacketSeen(packet) + manager._performMaintenanceSynchronously(now: Date()) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + manager.removeAllPublicMessages() + + let erased = await TestHelpers.waitUntil( + { + !FileManager.default.fileExists(atPath: fileURL.path) + && manager._messageCount(for: PeerID(hexData: senderID)) == 0 + }, + timeout: TestConstants.shortTimeout + ) + #expect(erased) + } + } private final class RecordingDelegate: GossipSyncManager.Delegate { diff --git a/bitchatTests/Services/MediaRetentionTests.swift b/bitchatTests/Services/MediaRetentionTests.swift new file mode 100644 index 00000000..18b33ca7 --- /dev/null +++ b/bitchatTests/Services/MediaRetentionTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +@testable import bitchat + +/// Media used to be bounded only by a 100 MB incoming quota, so a received +/// photo or a sent voice note could sit on disk indefinitely — outliving the +/// conversation it belonged to, which is exactly what a seized device gives up. +/// These cover the age-based sweep that bounds it in time as well. +struct MediaRetentionTests { + private func makeRoot() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("media-retention-\(UUID().uuidString)", isDirectory: true) + } + + private func write( + _ name: String, + in directory: URL, + modified: Date + ) throws -> URL { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let url = directory.appendingPathComponent(name) + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: modified], + ofItemAtPath: url.path + ) + return url + } + + @Test + func expiresOutgoingMediaPastRetentionAndKeepsFreshMedia() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let outgoing = root.appendingPathComponent("files/images/outgoing", isDirectory: true) + + // Outgoing media had no lifetime at all before this sweep: the quota + // only ever considered incoming directories. + let stale = try write( + "sent_old.jpg", + in: outgoing, + modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60) + ) + let fresh = try write( + "sent_new.jpg", + in: outgoing, + modified: Date(timeIntervalSinceNow: -60) + ) + + let removed = store.expireAgedMedia() + + #expect(removed == 1) + #expect(!FileManager.default.fileExists(atPath: stale.path)) + #expect(FileManager.default.fileExists(atPath: fresh.path)) + } + + @Test + func expiresIncomingMediaPastRetention() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") + + let stale = try write( + "received.m4a", + in: incoming, + modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60) + ) + + #expect(store.expireAgedMedia() == 1) + #expect(!FileManager.default.fileExists(atPath: stale.path)) + } + + @Test + func retentionSweepSkipsInFlightLiveCaptures() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") + + // Deleting a live capture mid-stream unlinks the inode under the + // coordinator's open FileHandle, so age must not override the guard. + let inFlight = try write( + "\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac", + in: incoming, + modified: Date(timeIntervalSinceNow: -30 * 24 * 60 * 60) + ) + + #expect(store.expireAgedMedia() == 0) + #expect(FileManager.default.fileExists(atPath: inFlight.path)) + } + + @Test + func nonPositiveRetentionIsANoOp() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let incoming = try store.incomingDirectory(subdirectory: "images/incoming") + + let file = try write( + "received.jpg", + in: incoming, + modified: Date(timeIntervalSinceNow: -365 * 24 * 60 * 60) + ) + + #expect(store.expireAgedMedia(retention: 0) == 0) + #expect(FileManager.default.fileExists(atPath: file.path)) + } + + @Test + func defaultRetentionIsSevenDays() { + #expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60) + } +} diff --git a/bitchatTests/Services/NotificationRedactionTests.swift b/bitchatTests/Services/NotificationRedactionTests.swift new file mode 100644 index 00000000..be5c8109 --- /dev/null +++ b/bitchatTests/Services/NotificationRedactionTests.swift @@ -0,0 +1,130 @@ +import BitFoundation +import Foundation +import Testing +import UserNotifications +@testable import bitchat + +/// Lock-screen notifications used to carry the DM body and the sender's +/// nickname verbatim, and the geohash in the title, so a locked phone lying on +/// a table narrated conversations to anyone looking at it. These cover the +/// redaction that is now the default. +/// +/// The preference is injected rather than written to shared preferences. These +/// run in the same process as `NotificationServiceTests`, and mutating the +/// global store made whichever ran second depend on the other's cleanup — which +/// passed locally and failed in CI. +struct NotificationRedactionTests { + private final class RecordingDeliverer: NotificationRequestDelivering { + var requests: [UNNotificationRequest] = [] + + func add(_ request: UNNotificationRequest) { + requests.append(request) + } + } + + private struct StubAuthorizer: NotificationAuthorizing { + func requestAuthorization( + options: UNAuthorizationOptions, + completionHandler: @escaping (Bool, Error?) -> Void + ) { + completionHandler(true, nil) + } + } + + private func makeService( + hidePreviews: Bool + ) -> (NotificationService, RecordingDeliverer) { + let deliverer = RecordingDeliverer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: StubAuthorizer(), + requestDeliverer: deliverer, + hidePreviewsProvider: { hidePreviews } + ) + return (service, deliverer) + } + + private func isolatedDefaults() -> UserDefaults { + UserDefaults(suiteName: "bitchat.tests.notifications.\(UUID().uuidString)")! + } + + @Test func previewsAreHiddenByDefault() { + // A fresh install must start quiet rather than opt-in quiet. + #expect(NotificationPrivacySettings.hideMessagePreviews(in: isolatedDefaults())) + } + + @Test func theSettingRoundTrips() { + let defaults = isolatedDefaults() + + NotificationPrivacySettings.setHideMessagePreviews(false, in: defaults) + #expect(!NotificationPrivacySettings.hideMessagePreviews(in: defaults)) + + // Panic wipe restores the safe default rather than the last choice. + NotificationPrivacySettings.reset(in: defaults) + #expect(NotificationPrivacySettings.hideMessagePreviews(in: defaults)) + } + + @Test func redactedDirectMessageWithholdsSenderAndBody() throws { + let (service, deliverer) = makeService(hidePreviews: true) + + service.sendPrivateMessageNotification( + from: "alice", + message: "meet at the north gate", + peerID: PeerID(str: "00112233445566ff") + ) + + let content = try #require(deliverer.requests.first).content + #expect(!content.title.contains("alice")) + #expect(!content.body.contains("north gate")) + #expect(!content.title.isEmpty) + // Still routable: userInfo is never rendered on the lock screen. + #expect(content.userInfo["peerID"] as? String == "00112233445566ff") + } + + @Test func redactedMentionWithholdsSenderAndBody() throws { + let (service, deliverer) = makeService(hidePreviews: true) + + service.sendMentionNotification(from: "bob", message: "regroup now") + + let content = try #require(deliverer.requests.first).content + #expect(!content.title.contains("bob")) + #expect(!content.body.contains("regroup")) + } + + @Test func redactedGeohashActivityWithholdsTheGeohash() throws { + let (service, deliverer) = makeService(hidePreviews: true) + + service.sendGeohashActivityNotification( + geohash: "u4pruyd", + bodyPreview: "someone said something" + ) + + let content = try #require(deliverer.requests.first).content + #expect(!content.title.contains("u4pruyd")) + #expect(!content.body.contains("someone said")) + // The deep link still carries it: tapping must land in the channel. + #expect(content.userInfo["deeplink"] as? String == "bitchat://geohash/u4pruyd") + } + + @Test func previewsShownWhenTheSettingIsOff() { + let (service, deliverer) = makeService(hidePreviews: false) + + service.sendPrivateMessageNotification( + from: "alice", + message: "meet at the north gate", + peerID: PeerID(str: "00112233445566ff") + ) + service.sendGeohashActivityNotification( + geohash: "u4pruyd", + bodyPreview: "someone said something" + ) + + #expect(deliverer.requests.count == 2) + let dm = deliverer.requests[0].content + #expect(dm.title.contains("alice")) + #expect(dm.body == "meet at the north gate") + let geo = deliverer.requests[1].content + #expect(geo.title.contains("u4pruyd")) + #expect(geo.body == "someone said something") + } +} diff --git a/bitchatTests/Services/NotificationServiceTests.swift b/bitchatTests/Services/NotificationServiceTests.swift index 9ffad1f4..384f0075 100644 --- a/bitchatTests/Services/NotificationServiceTests.swift +++ b/bitchatTests/Services/NotificationServiceTests.swift @@ -56,12 +56,15 @@ final class NotificationServiceTests: XCTestCase { XCTAssertNil(request?.trigger) } + /// Previews shown: the opt-in behavior. Stated explicitly rather than + /// inherited from the shared preference, which now defaults to hidden. func test_sendPrivateMessageNotification_populatesPeerMetadata() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( isRunningTestsProvider: { false }, authorizer: RecordingNotificationAuthorizer(), - requestDeliverer: deliverer + requestDeliverer: deliverer, + hidePreviewsProvider: { false } ) let peerID = PeerID(str: "deadbeefdeadbeef") @@ -74,6 +77,27 @@ final class NotificationServiceTests: XCTestCase { XCTAssertEqual(request?.content.userInfo["senderName"] as? String, "Alice") } + /// Previews hidden: the default. The routing payload has to survive + /// redaction, or tapping the alert would not open the conversation. + func test_sendPrivateMessageNotification_withPreviewsHidden_keepsRoutingButDropsContent() { + let deliverer = RecordingNotificationRequestDeliverer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: RecordingNotificationAuthorizer(), + requestDeliverer: deliverer, + hidePreviewsProvider: { true } + ) + let peerID = PeerID(str: "deadbeefdeadbeef") + + service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID) + + let request = deliverer.requests.singleValue + XCTAssertFalse(request?.content.title.contains("Alice") ?? true) + XCTAssertFalse(request?.content.body.contains("hi") ?? true) + XCTAssertFalse(request?.content.title.isEmpty ?? true) + XCTAssertEqual(request?.content.userInfo["peerID"] as? String, peerID.id) + } + func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index db6f8db8..16cb73c0 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -18,6 +18,7 @@ The user-facing contract is `PRIVACY_POLICY.md`. This document records implement - Private payloads are end-to-end encrypted, but public mesh, board, bridge, and geohash content is intentionally visible to its participants. - Local storage is bounded where practical and included in panic wipe, but it is not wholly ephemeral. The app persists the stores listed below. - The app and share extension each bundle a privacy manifest declaring their actual required-reason API use. +- A locked device discloses less than an unlocked one, but not nothing. Notification previews are hidden by default and the app-switcher snapshot is covered; see "Locked and Seized Devices". ## BLE Discovery and Metadata @@ -50,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; 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. +- 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. 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,6 +89,21 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu `bitchatShareExtension/PrivacyInfo.xcprivacy` declares app-group UserDefaults reason `1C8F.1`. Both manifests declare no tracking domains and no data collection by the app developer. They must remain bundled in their respective executable bundles. +## Locked and Seized Devices + +The realistic compromise for many of the people this app is built for is not interception but a phone taken and, often, unlocked under coercion. + +- Notification content is rendered by the system on the lock screen, so it is readable without unlocking. Message previews are therefore hidden by default: alerts state that a direct message, mention, or location-channel activity arrived, and withhold the message body, the sender's nickname, and the geohash until the app is opened. `userInfo` still carries the routing peer ID and deep link, neither of which the system displays. The preference is `notifications.hideMessagePreviews`; turning it off restores full previews. +- The window is covered on `willResignActive`, so the snapshot iOS stores for the app switcher shows a placeholder rather than an open conversation. The cover is opaque rather than blurred, and is added synchronously because the capture follows shortly after that notification. Panic wipe separately deletes snapshots already on disk. +- Clearing a mesh timeline erases the on-disk gossip archive behind it, so cleared public history is deleted rather than hidden. The echo watermark still suppresses pre-clear messages this device hears again from peers. +- Managed media is bounded by age as well as size, so a received photo does not outlive its conversation indefinitely. + +Not addressed, and deliberately out of scope here: + +- **No duress mechanism.** There is no decoy passphrase, no wipe-on-failed-authentication, and no biometric or passcode lock on the app itself. A coerced unlock discloses everything the device still holds. Adding one is a product decision as much as an engineering one: in some jurisdictions destroying data on demand is itself an offence, so a mode that *hides* may protect someone better than one that *destroys*, and the choice should be made deliberately rather than by default. +- **macOS gets no file-protection classes.** Every `FileProtectionType` application is inside `#if os(iOS)`; Data Protection on macOS additionally requires an entitlement. The Mac app also has no app-switcher equivalent. +- **Media is not sealed at the app layer.** It relies on the platform default protection class, which is readable once the device has been unlocked since boot. Sealing under a key with the same accessibility would not change that; only a key gated on user authentication would, and that conflicts with receiving media while locked. + ## 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. 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. @@ -99,3 +115,4 @@ The panic action clears identity/session state, preferences, location state, gro - Verify panic wipe reaches any newly added persistent store. - Treat geohash precision, bridge-cell changes, new relay tags, and announce fields as privacy-surface changes. - Re-run real-device Bluetooth, background/locked-device recovery, location revocation, and audio-route checks; simulators cannot validate the physical side of those behaviors. +- Check what a new notification discloses on a locked screen, and that the app-switcher snapshot is covered, whenever notification or scene-lifecycle code changes.