From 19c06f360d478730a086b763bb6dcdca457eac8c Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 20:34:59 +0200 Subject: [PATCH] Require review before importing shared content --- PRIVACY_POLICY.md | 2 +- bitchat.xcodeproj/project.pbxproj | 1 + bitchat/App/AppArchitecture.swift | 7 +- bitchat/App/AppChromeModel.swift | 9 +- bitchat/App/AppRuntime.swift | 68 +++--- bitchat/App/SharedContentImportModel.swift | 119 ++++++++++ bitchat/BitchatApp.swift | 1 + bitchat/Localizable.xcstrings | 105 +++++++++ bitchat/Services/SharedContentHandoff.swift | 221 ++++++++++++++++++ bitchat/Services/TransportConfig.swift | 1 - bitchat/Views/ContentView.swift | 42 ++++ .../Localization/Localizable.xcstrings | 76 +++++- .../ShareViewController.swift | 48 ++-- bitchatTests/SharedContentHandoffTests.swift | 173 ++++++++++++++ bitchatTests/ViewSmokeTests.swift | 5 +- 15 files changed, 801 insertions(+), 77 deletions(-) create mode 100644 bitchat/App/SharedContentImportModel.swift create mode 100644 bitchat/Services/SharedContentHandoff.swift create mode 100644 bitchatTests/SharedContentHandoffTests.swift diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 93edc3b6..ca029c46 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -22,7 +22,7 @@ bitchat is designed for private, account-free communication. This policy describ 2. **Nickname, preferences, and relationships** - Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally. - - The share extension briefly places content you choose to share in the app-group preferences so the main app can import it. + - The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires. 3. **Private group state** - Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support. diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index fe3543c2..1cf1096b 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Services/SharedContentHandoff.swift, Services/TransportConfig.swift, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index 72b43b7d..05b8b5e2 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -2,11 +2,6 @@ import BitFoundation import Combine import Foundation -enum SharedContentKind: String, Sendable, Equatable { - case text - case url -} - enum RuntimeScenePhase: String, Sendable, Equatable { case active case inactive @@ -25,7 +20,7 @@ enum AppEvent: Sendable, Equatable { case startupCompleted case scenePhaseChanged(RuntimeScenePhase) case openedURL(String) - case sharedContentAccepted(SharedContentKind) + case sharedContentReadyForReview(SharedContentKind) case notificationOpened(peerID: PeerID?) case deepLinkOpened(String) case torLifecycleChanged(TorLifecycleEvent) diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 9db9e51e..36859ad6 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -20,13 +20,19 @@ final class AppChromeModel: ObservableObject { @Published var showScreenshotPrivacyWarning = false private let chatViewModel: ChatViewModel + private let onPanicWipe: () -> Void private var cancellables = Set() /// Bulletin-board coordinator, created on first use of the board sheet. private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) - init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) { + init( + chatViewModel: ChatViewModel, + privateInboxModel: PrivateInboxModel, + onPanicWipe: @escaping () -> Void = {} + ) { self.chatViewModel = chatViewModel + self.onPanicWipe = onPanicWipe self.nickname = chatViewModel.nickname bind(privateInboxModel: privateInboxModel) @@ -98,6 +104,7 @@ final class AppChromeModel: ObservableObject { } func panicClearAllData() { + onPanicWipe() chatViewModel.panicClearAllData() } diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 4c1e20a0..90974e8b 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -27,6 +27,7 @@ final class AppRuntime: ObservableObject { let peerListModel: PeerListModel let appChromeModel: AppChromeModel let boardAlertsModel: BoardAlertsModel + let sharedContentImportModel: SharedContentImportModel private let idBridge: NostrIdentityBridge private var cancellables = Set() @@ -41,7 +42,8 @@ final class AppRuntime: ObservableObject { init( keychain: KeychainManagerProtocol = KeychainManager.makeDefault(), - idBridge: NostrIdentityBridge = NostrIdentityBridge() + idBridge: NostrIdentityBridge = NostrIdentityBridge(), + sharedContentStore: SharedContentStore? = nil ) { self.idBridge = idBridge let conversations = ConversationStore() @@ -84,9 +86,20 @@ final class AppRuntime: ObservableObject { peerIdentityStore: peerIdentityStore, locationPresenceStore: locationPresenceStore ) + let resolvedSharedContentStore: SharedContentStore? + if let sharedContentStore { + resolvedSharedContentStore = sharedContentStore + } else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) { + resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults) + } else { + resolvedSharedContentStore = nil + } + let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore) + self.sharedContentImportModel = sharedContentImportModel self.appChromeModel = AppChromeModel( chatViewModel: self.chatViewModel, - privateInboxModel: self.privateInboxModel + privateInboxModel: self.privateInboxModel, + onPanicWipe: { sharedContentImportModel.discardAll() } ) let chatViewModel = self.chatViewModel self.boardAlertsModel = BoardAlertsModel( @@ -106,7 +119,6 @@ final class AppRuntime: ObservableObject { } ) ) - GeoRelayDirectory.shared.prefetchIfNeeded() bindRuntimeObservers() NotificationDelegate.shared.runtime = self @@ -313,44 +325,22 @@ private extension AppRuntime { } func checkForSharedContent() { - guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return } - let clearSharedContent = { - userDefaults.removeObject(forKey: "sharedContent") - userDefaults.removeObject(forKey: "sharedContentType") - userDefaults.removeObject(forKey: "sharedContentDate") + let previousID = sharedContentImportModel.offer?.id + guard let payload = sharedContentImportModel.refresh( + destination: currentSharedContentDestination + ) else { return } + + if previousID != payload.id { + record(.sharedContentReadyForReview(payload.kind)) } + } - guard let sharedContent = userDefaults.string(forKey: "sharedContent"), - let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { - // A partial or malformed handoff must not linger in the shared - // app-group container indefinitely. - clearSharedContent() - return - } - - guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else { - clearSharedContent() - return - } - - let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text - - clearSharedContent() - - switch contentKind { - case .url: - if let data = sharedContent.data(using: .utf8), - let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String], - let url = urlData["url"] { - chatViewModel.sendMessage(url) - } else { - chatViewModel.sendMessage(sharedContent) - } - case .text: - chatViewModel.sendMessage(sharedContent) - } - - record(.sharedContentAccepted(contentKind)) + var currentSharedContentDestination: SharedContentDestination { + SharedContentDestination.resolve( + selectedPrivatePeerID: privateConversationModel.selectedPeerID, + privateDisplayName: privateConversationModel.selectedHeaderState?.displayName, + activeChannel: locationChannelsModel.selectedChannel + ) } func handleNostrRelayConnectionChanged(_ isConnected: Bool) { diff --git a/bitchat/App/SharedContentImportModel.swift b/bitchat/App/SharedContentImportModel.swift new file mode 100644 index 00000000..68e20aa1 --- /dev/null +++ b/bitchat/App/SharedContentImportModel.swift @@ -0,0 +1,119 @@ +import BitFoundation +import Combine +import Foundation + +enum SharedContentDestination: Sendable, Equatable { + case mesh + case geohash(String) + case privateConversation(peerID: PeerID, displayName: String) + + static func resolve( + selectedPrivatePeerID: PeerID?, + privateDisplayName: String?, + activeChannel: ChannelID + ) -> SharedContentDestination { + if let selectedPrivatePeerID { + let fallback = String(selectedPrivatePeerID.id.prefix(12)) + return .privateConversation( + peerID: selectedPrivatePeerID, + displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback + ) + } + + switch activeChannel { + case .mesh: + return .mesh + case .location(let channel): + return .geohash(channel.geohash.lowercased()) + } + } + + var displayName: String { + switch self { + case .mesh: + return "#mesh" + case .geohash(let geohash): + return "#\(geohash)" + case .privateConversation(_, let displayName): + return displayName + } + } +} + +struct SharedContentOffer: Identifiable, Sendable, Equatable { + let payload: SharedContentPayload + let destination: SharedContentDestination + + var id: UUID { payload.id } +} + +/// Holds a pending extension handoff until the user chooses a destination and +/// explicitly adds it to the composer. This type has no send dependency by +/// design: confirming an import can never transmit a message. +@MainActor +final class SharedContentImportModel: ObservableObject { + @Published private(set) var offer: SharedContentOffer? + + private let store: SharedContentStore? + + init(store: SharedContentStore?) { + self.store = store + } + + @discardableResult + func refresh( + destination: SharedContentDestination, + now: Date = Date() + ) -> SharedContentPayload? { + guard let payload = store?.pending(now: now) else { + offer = nil + return nil + } + + let nextOffer = SharedContentOffer(payload: payload, destination: destination) + if offer != nextOffer { + offer = nextOffer + } + return payload + } + + func updateDestination(_ destination: SharedContentDestination) { + guard let offer, offer.destination != destination else { return } + self.offer = SharedContentOffer(payload: offer.payload, destination: destination) + } + + /// Returns composer text only when the currently displayed destination is + /// still current and the reviewed envelope is still the stored envelope. + /// A destination change updates the prompt and requires another tap. + func confirm( + destination: SharedContentDestination, + now: Date = Date() + ) -> String? { + guard let offer else { return nil } + guard offer.destination == destination else { + updateDestination(destination) + return nil + } + guard let payload = store?.consume(id: offer.id, now: now) else { + _ = refresh(destination: destination, now: now) + return nil + } + + self.offer = nil + return payload.composerText + } + + func cancel(destination: SharedContentDestination, now: Date = Date()) { + guard let offer else { return } + store?.discard(id: offer.id) + self.offer = nil + // If a newer share replaced the reviewed envelope, surface it rather + // than losing it with the older cancellation. + _ = refresh(destination: destination, now: now) + } + + func discardAll() { + store?.discardAll() + offer = nil + } +} diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index c1a39fff..d109eb55 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -41,6 +41,7 @@ struct BitchatApp: App { .environmentObject(runtime.peerListModel) .environmentObject(runtime.appChromeModel) .environmentObject(runtime.boardAlertsModel) + .environmentObject(runtime.sharedContentImportModel) .onAppear { appDelegate.runtime = runtime runtime.start() diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 80c3c11d..bcf7f1a3 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -69564,6 +69564,111 @@ } } } + }, + "share_import.review.message" : { + "comment" : "Explains that shared content replaces the named destination's composer and is not sent automatically", + "extractionState" : "manual", + "localizations" : { + "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "هل تريد استبدال مسودة %@ بهذا المحتوى؟ لن يتم إرسال أي شيء تلقائيًا." } }, + "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "%@-এর খসড়াটি এই কনটেন্ট দিয়ে বদলাবেন? কিছুই স্বয়ংক্রিয়ভাবে পাঠানো হবে না।" } }, + "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Entwurf für %@ durch diesen Inhalt ersetzen? Es wird nichts automatisch gesendet." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Replace the draft for %@ with this content? Nothing will be sent automatically." } }, + "es" : { "stringUnit" : { "state" : "needs_review", "value" : "¿Reemplazar el borrador de %@ con este contenido? No se enviará nada automáticamente." } }, + "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Palitan ng content na ito ang draft para sa %@? Walang awtomatikong ipapadala." } }, + "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Remplacer le brouillon pour %@ par ce contenu ? Rien ne sera envoyé automatiquement." } }, + "he" : { "stringUnit" : { "state" : "needs_review", "value" : "להחליף את הטיוטה עבור %@ בתוכן הזה? שום דבר לא יישלח אוטומטית." } }, + "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ का ड्राफ़्ट इस सामग्री से बदलें? कुछ भी अपने आप नहीं भेजा जाएगा।" } }, + "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Ganti draf untuk %@ dengan konten ini? Tidak ada yang akan dikirim otomatis." } }, + "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Sostituire la bozza per %@ con questo contenuto? Nulla verrà inviato automaticamente." } }, + "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ の下書きをこの内容で置き換えますか?自動的には送信されません。" } }, + "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "%@의 초안을 이 콘텐츠로 바꾸시겠습니까? 자동으로 전송되지 않습니다." } }, + "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gantikan draf untuk %@ dengan kandungan ini? Tiada apa-apa akan dihantar secara automatik." } }, + "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ को मस्यौदा यो सामग्रीले बदल्ने? केही पनि स्वचालित रूपमा पठाइने छैन।" } }, + "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Concept voor %@ vervangen door deze inhoud? Er wordt niets automatisch verzonden." } }, + "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Zastąpić szkic dla %@ tą treścią? Nic nie zostanie wysłane automatycznie." } }, + "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho para %@ por este conteúdo? Nada será enviado automaticamente." } }, + "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho de %@ por este conteúdo? Nada será enviado automaticamente." } }, + "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Заменить черновик для %@ этим содержимым? Ничего не будет отправлено автоматически." } }, + "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Ersätta utkastet för %@ med detta innehåll? Inget skickas automatiskt." } }, + "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ க்கான வரைவைக் இந்த உள்ளடக்கத்தால் மாற்றவா? எதுவும் தானாக அனுப்பப்படாது." } }, + "th" : { "stringUnit" : { "state" : "needs_review", "value" : "แทนที่ฉบับร่างสำหรับ %@ ด้วยเนื้อหานี้หรือไม่? จะไม่มีการส่งอัตโนมัติ" } }, + "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ taslağı bu içerikle değiştirilsin mi? Hiçbir şey otomatik olarak gönderilmez." } }, + "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Замінити чернетку для %@ цим вмістом? Нічого не буде надіслано автоматично." } }, + "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ کا مسودہ اس مواد سے بدلیں؟ کچھ بھی خودکار طور پر نہیں بھیجا جائے گا۔" } }, + "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Thay bản nháp cho %@ bằng nội dung này? Không có gì được tự động gửi." } }, + "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此内容替换 %@ 的草稿吗?内容不会自动发送。" } }, + "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此內容取代 %@ 的草稿嗎?內容不會自動傳送。" } } + } + }, + "share_import.review.title" : { + "comment" : "Title for reviewing content received from the share extension", + "extractionState" : "manual", + "localizations" : { + "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "مراجعة المحتوى المشترك" } }, + "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "শেয়ার করা কনটেন্ট পর্যালোচনা করুন" } }, + "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Geteilte Inhalte prüfen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Review shared content" } }, + "es" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar contenido compartido" } }, + "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Suriin ang ibinahaging content" } }, + "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Vérifier le contenu partagé" } }, + "he" : { "stringUnit" : { "state" : "needs_review", "value" : "בדיקת תוכן משותף" } }, + "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "शेयर की गई सामग्री की समीक्षा करें" } }, + "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tinjau konten yang dibagikan" } }, + "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Controlla il contenuto condiviso" } }, + "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "共有コンテンツを確認" } }, + "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "공유 콘텐츠 검토" } }, + "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Semak kandungan yang dikongsi" } }, + "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "साझा सामग्री समीक्षा गर्नुहोस्" } }, + "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Gedeelde inhoud bekijken" } }, + "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Sprawdź udostępnioną treść" } }, + "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Rever conteúdo partilhado" } }, + "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar conteúdo compartilhado" } }, + "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Проверить общий контент" } }, + "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Granska delat innehåll" } }, + "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "பகிரப்பட்ட உள்ளடக்கத்தை மதிப்பாய்வு செய்" } }, + "th" : { "stringUnit" : { "state" : "needs_review", "value" : "ตรวจสอบเนื้อหาที่แชร์" } }, + "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Paylaşılan içeriği gözden geçir" } }, + "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Переглянути спільний вміст" } }, + "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "شیئر کردہ مواد کا جائزہ لیں" } }, + "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Xem lại nội dung được chia sẻ" } }, + "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "查看共享内容" } }, + "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "查看分享內容" } } + } + }, + "share_import.review.use_in_composer" : { + "comment" : "Action that places reviewed shared content in the composer without sending it", + "extractionState" : "manual", + "localizations" : { + "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "استخدام في مسودة الرسالة" } }, + "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "বার্তার খসড়ায় ব্যবহার করুন" } }, + "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Im Nachrichtenentwurf verwenden" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Use in composer" } }, + "es" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar en el borrador" } }, + "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Gamitin sa draft" } }, + "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Utiliser dans le brouillon" } }, + "he" : { "stringUnit" : { "state" : "needs_review", "value" : "שימוש בטיוטה" } }, + "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "संदेश के ड्राफ़्ट में उपयोग करें" } }, + "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan di draf" } }, + "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Usa nella bozza" } }, + "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "下書きで使用" } }, + "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "초안에 사용" } }, + "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan dalam draf" } }, + "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "सन्देशको मस्यौदामा प्रयोग गर्नुहोस्" } }, + "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "In concept gebruiken" } }, + "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Użyj w szkicu" } }, + "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } }, + "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } }, + "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Использовать в черновике" } }, + "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Använd i utkast" } }, + "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "செய்தி வரைவில் பயன்படுத்து" } }, + "th" : { "stringUnit" : { "state" : "needs_review", "value" : "ใช้ในฉบับร่าง" } }, + "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Taslakta kullan" } }, + "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Використати в чернетці" } }, + "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "پیغام کے مسودے میں استعمال کریں" } }, + "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Dùng trong bản nháp" } }, + "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "用于草稿" } }, + "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "用於草稿" } } + } } }, "version" : "1.1" diff --git a/bitchat/Services/SharedContentHandoff.swift b/bitchat/Services/SharedContentHandoff.swift new file mode 100644 index 00000000..0db94a19 --- /dev/null +++ b/bitchat/Services/SharedContentHandoff.swift @@ -0,0 +1,221 @@ +import Foundation + +enum SharedContentKind: String, Codable, Sendable, Equatable { + case text + case url +} + +/// The single, bounded payload handed from the share extension to the app. +/// +/// The app-group store intentionally contains at most one envelope. A newer +/// share replaces an older one, which prevents unbounded shared-container +/// growth while still surviving suspension and a later app launch. +struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable { + static let currentVersion = 1 + static let maxContentBytes = 16_000 + static let maxTitleBytes = 512 + static let maxEnvelopeBytes = 24_000 + static let retentionSeconds: TimeInterval = 24 * 60 * 60 + static let allowedFutureSkewSeconds: TimeInterval = 5 * 60 + + let version: Int + let id: UUID + let kind: SharedContentKind + let content: String + let title: String? + let createdAt: Date + + init( + version: Int = Self.currentVersion, + id: UUID = UUID(), + kind: SharedContentKind, + content: String, + title: String? = nil, + createdAt: Date = Date() + ) { + self.version = version + self.id = id + self.kind = kind + self.content = content + self.title = title + self.createdAt = createdAt + } + + static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload { + SharedContentPayload(kind: .text, content: content, createdAt: createdAt) + } + + static func url(_ url: URL, title: String?, createdAt: Date = Date()) -> SharedContentPayload { + SharedContentPayload(kind: .url, content: url.absoluteString, title: title, createdAt: createdAt) + } + + var composerText: String { content } + + var preview: String { + let normalized = content + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + guard normalized.count > 240 else { return normalized } + return String(normalized.prefix(240)) + "…" + } + + func validate(now: Date = Date()) throws { + guard version == Self.currentVersion else { + throw SharedContentHandoffError.unsupportedVersion + } + + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw SharedContentHandoffError.emptyContent + } + guard content.utf8.count <= Self.maxContentBytes else { + throw SharedContentHandoffError.contentTooLarge + } + if let title { + guard title.utf8.count <= Self.maxTitleBytes else { + throw SharedContentHandoffError.titleTooLarge + } + guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else { + throw SharedContentHandoffError.invalidCharacters + } + } + + let age = now.timeIntervalSince(createdAt) + guard age >= -Self.allowedFutureSkewSeconds, + age <= Self.retentionSeconds else { + throw SharedContentHandoffError.expired + } + + switch kind { + case .text: + guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else { + throw SharedContentHandoffError.invalidCharacters + } + case .url: + guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false), + let components = URLComponents(string: content), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host?.isEmpty == false else { + throw SharedContentHandoffError.unsupportedURL + } + } + } + + private static func containsDisallowedControl( + in value: String, + allowsTextLayout: Bool + ) -> Bool { + value.unicodeScalars.contains { scalar in + guard CharacterSet.controlCharacters.contains(scalar) else { return false } + if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" { + return false + } + return true + } + } +} + +enum SharedContentHandoffError: Error, Equatable { + case unsupportedVersion + case emptyContent + case contentTooLarge + case titleTooLarge + case invalidCharacters + case expired + case unsupportedURL + case envelopeTooLarge + case encodingFailed +} + +/// Durable, single-item app-group storage used by both the extension and app. +final class SharedContentStore { + static let storageKey = "sharedContentEnvelopeV1" + + private static let legacyKeys = [ + "sharedContent", + "sharedContentType", + "sharedContentDate" + ] + + private let defaults: UserDefaults + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(defaults: UserDefaults) { + self.defaults = defaults + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + } + + convenience init?(suiteName: String) { + guard let defaults = UserDefaults(suiteName: suiteName) else { return nil } + self.init(defaults: defaults) + } + + /// Replaces any older pending share with a validated, bounded envelope. + func stage(_ payload: SharedContentPayload, now: Date = Date()) throws { + try payload.validate(now: now) + guard let encoded = try? encoder.encode(payload) else { + throw SharedContentHandoffError.encodingFailed + } + guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else { + throw SharedContentHandoffError.envelopeTooLarge + } + + defaults.set(encoded, forKey: Self.storageKey) + clearLegacyKeys() + } + + /// Reads the pending share without consuming it. Invalid and expired data + /// is removed immediately so malformed app-group state cannot linger. + func pending(now: Date = Date()) -> SharedContentPayload? { + clearLegacyKeys() + + guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil } + guard encoded.count <= SharedContentPayload.maxEnvelopeBytes, + let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else { + defaults.removeObject(forKey: Self.storageKey) + return nil + } + + do { + try payload.validate(now: now) + return payload + } catch { + defaults.removeObject(forKey: Self.storageKey) + return nil + } + } + + /// Consumes only the envelope the user actually reviewed. If a newer share + /// already replaced it, the newer content remains pending. + func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? { + guard let payload = pending(now: now), payload.id == id else { return nil } + defaults.removeObject(forKey: Self.storageKey) + return payload + } + + /// Explicit cancellation has the same identity guard as consumption so it + /// can never discard a newer share that arrived while a prompt was open. + func discard(id: UUID) { + guard let encoded = defaults.data(forKey: Self.storageKey), + encoded.count <= SharedContentPayload.maxEnvelopeBytes, + let payload = try? decoder.decode(SharedContentPayload.self, from: encoded), + payload.id == id else { + return + } + defaults.removeObject(forKey: Self.storageKey) + } + + func discardAll() { + defaults.removeObject(forKey: Self.storageKey) + clearLegacyKeys() + } + + private func clearLegacyKeys() { + for key in Self.legacyKeys { + defaults.removeObject(forKey: key) + } + } +} diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index f8e4f8c3..1b69eb93 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -314,7 +314,6 @@ enum TransportConfig { // Share extension static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 - static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 // Gossip Sync Configuration diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index fc1a6766..a597a093 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -36,6 +36,7 @@ struct ContentView: View { @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var sharedContentImportModel: SharedContentImportModel @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @State private var messageText = "" @@ -69,6 +70,14 @@ struct ContentView: View { privateConversationModel.selectedPeerID } + private var sharedContentDestination: SharedContentDestination { + SharedContentDestination.resolve( + selectedPrivatePeerID: selectedPrivatePeerID, + privateDisplayName: privateConversationModel.selectedHeaderState?.displayName, + activeChannel: locationChannelsModel.selectedChannel + ) + } + private var usesGlassLayout: Bool { appTheme.usesGlassChrome } var body: some View { @@ -85,6 +94,7 @@ struct ContentView: View { isTextFieldFocused = true } #endif + sharedContentImportModel.updateDestination(sharedContentDestination) } .onChange(of: colorScheme) { newValue in conversationUIModel.setCurrentColorScheme(newValue) @@ -101,6 +111,10 @@ struct ContentView: View { if newValue != nil { showSidebar = true } + sharedContentImportModel.updateDestination(sharedContentDestination) + } + .onChange(of: locationChannelsModel.selectedChannel) { _ in + sharedContentImportModel.updateDestination(sharedContentDestination) } .sheet( isPresented: Binding( @@ -227,6 +241,34 @@ struct ContentView: View { } message: { Text(appChromeModel.bluetoothAlertMessage) } + .alert( + String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"), + isPresented: Binding( + get: { sharedContentImportModel.offer != nil }, + set: { _ in } + ), + presenting: sharedContentImportModel.offer + ) { _ in + Button("common.cancel", role: .cancel) { + sharedContentImportModel.cancel(destination: sharedContentDestination) + } + Button("share_import.review.use_in_composer") { + guard let importedText = sharedContentImportModel.confirm( + destination: sharedContentDestination + ) else { return } + // Replacing is deliberate and called out in the prompt. It + // avoids combining a stale draft from another conversation + // with newly shared content. + messageText = importedText + isTextFieldFocused = true + } + } message: { offer in + let format = String( + localized: "share_import.review.message", + comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically" + ) + Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview) + } .onDisappear { autocompleteDebounceTimer?.invalidate() } diff --git a/bitchatShareExtension/Localization/Localizable.xcstrings b/bitchatShareExtension/Localization/Localizable.xcstrings index 6ffa096c..65c8a490 100644 --- a/bitchatShareExtension/Localization/Localizable.xcstrings +++ b/bitchatShareExtension/Localization/Localizable.xcstrings @@ -197,7 +197,7 @@ } }, "share.status.failed_to_encode" : { - "extractionState" : "manual", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -782,7 +782,7 @@ } }, "share.status.shared_link" : { - "extractionState" : "manual", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -977,7 +977,7 @@ } }, "share.status.shared_text" : { - "extractionState" : "manual", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1170,6 +1170,76 @@ } } } + }, + "share.status.failed_to_save" : { + "comment" : "Shown when content cannot be staged for the main app", + "extractionState" : "manual", + "localizations" : { + "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } }, + "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } }, + "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } }, + "es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } }, + "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } }, + "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible d’enregistrer dans bitchat" } }, + "he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } }, + "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } }, + "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } }, + "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } }, + "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } }, + "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } }, + "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } }, + "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } }, + "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } }, + "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } }, + "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } }, + "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } }, + "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } }, + "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } }, + "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } }, + "th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } }, + "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat’e kaydedilemedi" } }, + "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } }, + "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } }, + "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } }, + "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } }, + "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } } + } + }, + "share.status.saved_for_review" : { + "comment" : "Shown after content is staged for review in the main app", + "extractionState" : "manual", + "localizations" : { + "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } }, + "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } }, + "de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } }, + "es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } }, + "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } }, + "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez l’app pour vérifier" } }, + "he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } }, + "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } }, + "id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } }, + "it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri l’app per controllare" } }, + "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } }, + "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } }, + "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } }, + "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } }, + "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } }, + "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } }, + "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } }, + "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } }, + "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } }, + "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } }, + "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } }, + "th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } }, + "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat’e kaydedildi — incelemek için uygulamayı açın" } }, + "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } }, + "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } }, + "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } }, + "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } }, + "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } } + } } }, "version" : "1.0" diff --git a/bitchatShareExtension/ShareViewController.swift b/bitchatShareExtension/ShareViewController.swift index 6867d1d4..af435ddb 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -19,9 +19,8 @@ final class ShareViewController: UIViewController { static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content") static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared") static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link") - static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link") - static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text") - static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded") + static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app") + static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app") } private let statusLabel: UILabel = { @@ -44,9 +43,7 @@ final class ShareViewController: UIViewController { statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) ]) - DispatchQueue.global().async { - self.processShare() - } + processShare() } // MARK: - Processing @@ -151,30 +148,31 @@ final class ShareViewController: UIViewController { // MARK: - Save + Finish private func saveAndFinish(url: URL, title: String?) { - let payload: [String: String] = [ - "url": url.absoluteString, - "title": title ?? url.host ?? Strings.sharedLinkTitleFallback - ] - if let json = try? JSONSerialization.data(withJSONObject: payload), - let s = String(data: json, encoding: .utf8) { - saveToSharedDefaults(content: s, type: "url") - finishWithMessage(Strings.sharedLinkConfirmation) - } else { - finishWithMessage(Strings.failedToEncode) - } + let payload = SharedContentPayload.url( + url, + title: title ?? url.host ?? Strings.sharedLinkTitleFallback + ) + stageAndFinish(payload) } private func saveAndFinish(text: String) { - saveToSharedDefaults(content: text, type: "text") - finishWithMessage(Strings.sharedTextConfirmation) + stageAndFinish(.text(text)) } - private func saveToSharedDefaults(content: String, type: String) { - guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return } - userDefaults.set(content, forKey: "sharedContent") - userDefaults.set(type, forKey: "sharedContentType") - userDefaults.set(Date(), forKey: "sharedContentDate") - // No need to force synchronize; the system persists changes + private func stageAndFinish(_ payload: SharedContentPayload) { + guard let store = SharedContentStore(suiteName: Self.groupID) else { + finishWithMessage(Strings.failedToSave) + return + } + + do { + try store.stage(payload) + // Staging is not sending. The main app will require a second, + // destination-labelled confirmation before filling its composer. + finishWithMessage(Strings.savedForReview) + } catch { + finishWithMessage(Strings.failedToSave) + } } private func finishWithMessage(_ msg: String) { diff --git a/bitchatTests/SharedContentHandoffTests.swift b/bitchatTests/SharedContentHandoffTests.swift new file mode 100644 index 00000000..ff205ad6 --- /dev/null +++ b/bitchatTests/SharedContentHandoffTests.swift @@ -0,0 +1,173 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Share extension handoff", .serialized) +struct SharedContentHandoffTests { + private func makeStore() -> (suite: String, defaults: UserDefaults, store: SharedContentStore) { + let suite = "SharedContentHandoffTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return (suite, defaults, SharedContentStore(defaults: defaults)) + } + + @Test("A staged share survives an inactive app and a late open") + func stagedShareSurvivesLateOpen() throws { + let context = makeStore() + defer { context.defaults.removePersistentDomain(forName: context.suite) } + let stagedAt = Date(timeIntervalSince1970: 1_000_000) + let payload = SharedContentPayload.text("review me later", createdAt: stagedAt) + + try context.store.stage(payload, now: stagedAt) + + #expect(context.store.pending(now: stagedAt.addingTimeInterval(60 * 60)) == payload) + #expect(context.defaults.data(forKey: SharedContentStore.storageKey) != nil) + } + + @Test("Malformed, oversized, unsupported, and expired payloads are rejected and cleared") + func invalidPayloadsAreRejectedAndCleared() throws { + let context = makeStore() + defer { context.defaults.removePersistentDomain(forName: context.suite) } + let now = Date(timeIntervalSince1970: 2_000_000) + + context.defaults.set(Data("not-json".utf8), forKey: SharedContentStore.storageKey) + #expect(context.store.pending(now: now) == nil) + #expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil) + + context.defaults.set( + Data(repeating: 0x41, count: SharedContentPayload.maxEnvelopeBytes + 1), + forKey: SharedContentStore.storageKey + ) + #expect(context.store.pending(now: now) == nil) + #expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil) + + let oversized = SharedContentPayload.text( + String(repeating: "x", count: SharedContentPayload.maxContentBytes + 1), + createdAt: now + ) + #expect(throws: SharedContentHandoffError.contentTooLarge) { + try context.store.stage(oversized, now: now) + } + #expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil) + + let unsupportedURL = SharedContentPayload( + kind: .url, + content: "file:///private/tmp/secret.txt", + createdAt: now + ) + #expect(throws: SharedContentHandoffError.unsupportedURL) { + try context.store.stage(unsupportedURL, now: now) + } + + let misleadingControl = SharedContentPayload.text("safe\u{202E}txt", createdAt: now) + #expect(throws: SharedContentHandoffError.invalidCharacters) { + try context.store.stage(misleadingControl, now: now) + } + + let expired = SharedContentPayload.text( + "too old", + createdAt: now.addingTimeInterval(-SharedContentPayload.retentionSeconds - 1) + ) + context.defaults.set(try JSONEncoder().encode(expired), forKey: SharedContentStore.storageKey) + #expect(context.store.pending(now: now) == nil) + #expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil) + } + + @Test("Mesh, geohash, and stale private selections resolve to explicit destinations") + func destinationsAreExplicit() { + let geohashChannel = ChannelID.location( + GeohashChannel(level: .city, geohash: "9Q8YY") + ) + let stalePeer = PeerID(str: "0011223344556677") + + #expect(SharedContentDestination.resolve( + selectedPrivatePeerID: nil, + privateDisplayName: nil, + activeChannel: .mesh + ) == .mesh) + #expect(SharedContentDestination.resolve( + selectedPrivatePeerID: nil, + privateDisplayName: nil, + activeChannel: geohashChannel + ) == .geohash("9q8yy")) + #expect(SharedContentDestination.resolve( + selectedPrivatePeerID: stalePeer, + privateDisplayName: "alice", + activeChannel: geohashChannel + ) == .privateConversation(peerID: stalePeer, displayName: "alice")) + } + + @Test("A destination change requires a new confirmation and never consumes on the stale tap") + @MainActor + func staleDestinationCannotBeConfirmed() throws { + let context = makeStore() + defer { context.defaults.removePersistentDomain(forName: context.suite) } + let now = Date(timeIntervalSince1970: 3_000_000) + let payload = SharedContentPayload.text("do not auto-send", createdAt: now) + let peer = PeerID(str: "8899aabbccddeeff") + let privateDestination = SharedContentDestination.privateConversation( + peerID: peer, + displayName: "alice" + ) + let model = SharedContentImportModel(store: context.store) + try context.store.stage(payload, now: now) + model.refresh(destination: privateDestination, now: now) + + #expect(model.confirm(destination: .mesh, now: now) == nil) + #expect(model.offer?.destination == .mesh) + #expect(context.store.pending(now: now) == payload) + + #expect(model.confirm(destination: .mesh, now: now) == payload.content) + #expect(model.offer == nil) + #expect(context.store.pending(now: now) == nil) + } + + @Test("Confirmation consumes once and cancellation explicitly clears without producing composer text") + @MainActor + func oneTimeConfirmationAndCancellation() throws { + let context = makeStore() + defer { context.defaults.removePersistentDomain(forName: context.suite) } + let now = Date(timeIntervalSince1970: 4_000_000) + let model = SharedContentImportModel(store: context.store) + + let first = SharedContentPayload.text("confirmed", createdAt: now) + try context.store.stage(first, now: now) + model.refresh(destination: .geohash("u4pruy"), now: now) + #expect(model.confirm(destination: .geohash("u4pruy"), now: now) == "confirmed") + #expect(model.confirm(destination: .geohash("u4pruy"), now: now) == nil) + + let second = SharedContentPayload.text("cancelled", createdAt: now) + try context.store.stage(second, now: now) + model.refresh(destination: .mesh, now: now) + model.cancel(destination: .mesh, now: now) + #expect(model.offer == nil) + #expect(context.store.pending(now: now) == nil) + + let third = SharedContentPayload.text("panic-wiped", createdAt: now) + try context.store.stage(third, now: now) + model.refresh(destination: .mesh, now: now) + model.discardAll() + #expect(model.offer == nil) + #expect(context.store.pending(now: now) == nil) + } + + @Test("Cancelling an old review never deletes a newer staged share") + @MainActor + func cancellationPreservesNewerShare() throws { + let context = makeStore() + defer { context.defaults.removePersistentDomain(forName: context.suite) } + let now = Date(timeIntervalSince1970: 5_000_000) + let model = SharedContentImportModel(store: context.store) + let old = SharedContentPayload.text("old", createdAt: now) + let newer = SharedContentPayload.text("new", createdAt: now) + + try context.store.stage(old, now: now) + model.refresh(destination: .mesh, now: now) + try context.store.stage(newer, now: now) + model.cancel(destination: .mesh, now: now) + + #expect(model.offer?.payload == newer) + #expect(context.store.pending(now: now) == newer) + } +} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 6c104e53..91753ca5 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -41,6 +41,7 @@ private struct SmokeFeatureModels { let conversationUIModel: ConversationUIModel let peerListModel: PeerListModel let boardAlertsModel: BoardAlertsModel + let sharedContentImportModel: SharedContentImportModel } @MainActor @@ -99,7 +100,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur verificationModel: verificationModel, conversationUIModel: conversationUIModel, peerListModel: peerListModel, - boardAlertsModel: boardAlertsModel + boardAlertsModel: boardAlertsModel, + sharedContentImportModel: SharedContentImportModel(store: nil) ) } @@ -118,6 +120,7 @@ private func installSmokeEnvironment( .environmentObject(featureModels.conversationUIModel) .environmentObject(featureModels.peerListModel) .environmentObject(featureModels.boardAlertsModel) + .environmentObject(featureModels.sharedContentImportModel) } @MainActor