diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index d743bb8f..3d5bc5bc 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -424,26 +424,24 @@ private extension AppRuntime { } func handleScreenshotCaptured() { - if appChromeModel.isLocationChannelsSheetPresented { + let isLocationChannelActive: Bool = { + if case .location = chatViewModel.activeChannel { return true } + return false + }() + + switch Self.resolveScreenshotResponse( + isLocationChannelsSheetPresented: appChromeModel.isLocationChannelsSheetPresented, + isAppInfoPresented: appChromeModel.isAppInfoPresented, + hasPrivateChatOpen: chatViewModel.selectedPrivateChatPeer != nil, + isLocationChannelActive: isLocationChannelActive + ) { + case .warnLocally: appChromeModel.triggerScreenshotPrivacyWarning() - return + case .ignore: + break + case .forwardToChat: + chatViewModel.handleScreenshotCaptured() } - - if appChromeModel.isAppInfoPresented { - return - } - - // Screenshots are never announced to public channels (see - // ChatLifecycleCoordinator.handleScreenshotCaptured). A geohash - // timeline screenshot still reveals a place, so warn the person - // taking it — locally, the same alert the channel sheet uses. - if chatViewModel.selectedPrivateChatPeer == nil, - case .location = chatViewModel.activeChannel { - appChromeModel.triggerScreenshotPrivacyWarning() - return - } - - chatViewModel.handleScreenshotCaptured() } func openExternalURL(_ url: URL) { @@ -460,3 +458,40 @@ private extension AppRuntime { } } } + +// MARK: - Screenshot routing + +extension AppRuntime { + /// What a screenshot triggers. Nothing on this table sends anything to + /// a public channel (see `ChatLifecycleCoordinator.handleScreenshotCaptured`). + enum ScreenshotCaptureResponse: Equatable { + /// Show the local location-privacy alert; nothing is sent anywhere. + case warnLocally + /// Do nothing. App Info holds no conversation or location content. + case ignore + /// Hand to the chat layer: a DM notice goes to the peer when a + /// secure session exists; public timelines stay silent. Mesh + /// deliberately gets no local alert either — a mesh screenshot + /// reveals no place and triggers no send, so there is nothing to + /// warn about, and alerting on every screenshot would train people + /// to dismiss the one alert that matters (the location one). + case forwardToChat + } + + /// Pure decision table so the screenshot routing is testable without a + /// runtime. + nonisolated static func resolveScreenshotResponse( + isLocationChannelsSheetPresented: Bool, + isAppInfoPresented: Bool, + hasPrivateChatOpen: Bool, + isLocationChannelActive: Bool + ) -> ScreenshotCaptureResponse { + if isLocationChannelsSheetPresented { return .warnLocally } + if isAppInfoPresented { return .ignore } + // A geohash timeline screenshot still reveals a place — warn the + // person taking it, locally, with the same alert the channel sheet + // uses. + if !hasPrivateChatOpen, isLocationChannelActive { return .warnLocally } + return .forwardToChat + } +} diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 02e5ff18..d969cd15 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -350,3 +350,59 @@ struct ChatLifecycleCoordinatorContextTests { } } + +// MARK: - Screenshot Routing + +/// Pins `AppRuntime`'s screenshot decision table: the geohash warn path, +/// the App Info exemption, and the deliberate mesh silence. Combined with +/// `handleScreenshotCaptured_publicChannel_staysSilent` above, this proves +/// location + screenshot → local privacy alert, and nothing sent anywhere. +struct ScreenshotCaptureRoutingTests { + + @Test("Location channels warn locally; the sheet warns everywhere") + func locationScreenshotsWarnLocally() { + // Geohash timeline, no DM open: warn the person, tell no one. + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: false, + isAppInfoPresented: false, + hasPrivateChatOpen: false, + isLocationChannelActive: true + ) == .warnLocally) + + // The channel sheet reveals location regardless of active channel. + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: true, + isAppInfoPresented: false, + hasPrivateChatOpen: true, + isLocationChannelActive: false + ) == .warnLocally) + } + + @Test("App Info is exempt; mesh and DMs forward to the chat layer") + func nonLocationScreenshotsForwardOrIgnore() { + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: false, + isAppInfoPresented: true, + hasPrivateChatOpen: false, + isLocationChannelActive: true + ) == .ignore) + + // Mesh timeline: forwarded, where the coordinator stays silent for + // public channels — no local alert either (nothing is sent and no + // place is revealed; see ScreenshotCaptureResponse docs). + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: false, + isAppInfoPresented: false, + hasPrivateChatOpen: false, + isLocationChannelActive: false + ) == .forwardToChat) + + // An open DM keeps its peer notice even from a location channel. + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: false, + isAppInfoPresented: false, + hasPrivateChatOpen: true, + isLocationChannelActive: true + ) == .forwardToChat) + } +}