diff --git a/App.tsx b/App.tsx index 7172f76d..299b71ae 100644 --- a/App.tsx +++ b/App.tsx @@ -62,6 +62,7 @@ import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; import { startPolicySync, usePolicyFeature } from './src/stores/policy-store'; +import { startScreenPrivacySync } from './src/lib/screen-privacy'; import { useLocaleStore } from './src/stores/locale-store'; import { useLayout } from './src/lib/use-layout'; import { toast } from './src/stores/toast-store'; @@ -434,9 +435,12 @@ export default function App() { // The organisation policy of the active account's webmail: the cached // one at once, a fresh one on start and on every account switch. const stopPolicySync = startPolicySync(); + // Settings > Security > Block screenshots. + const stopScreenPrivacy = startScreenPrivacySync(); const stopNetwork = useNetworkStore.getState().init(); return () => { stopPolicySync(); + stopScreenPrivacy(); stopNetwork?.(); }; }, []); diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dc980690..5fd3bee1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + diff --git a/locales/rn/en.json b/locales/rn/en.json index f6ef4eb6..ba52ed03 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1569,6 +1569,11 @@ "title": "Public Keys" }, "restricted": "Your server lets only apps its administrator approved change the password, two-factor sign-in, app passwords and API keys. Bulwark signed in without that approval, so mail, contacts, calendars and files work, but these settings don't. Change them in your server's web interface or the Bulwark webmail.", + "screen_privacy": { + "block_screenshots": "Block screenshots", + "block_screenshots_description": "Stops screenshots and screen recordings of the app and hides its content in the recent apps view.", + "title": "Screen privacy" + }, "two_factor": { "active": "Active", "code": "Verification code", diff --git a/package-lock.json b/package-lock.json index bf48cc22..38088548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "expo-intent-launcher": "~13.0.8", "expo-localization": "~17.0.9", "expo-notifications": "^0.32.17", + "expo-screen-capture": "~8.0.10", "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-status-bar": "~3.0.9", @@ -3154,9 +3155,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3174,9 +3172,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3194,9 +3189,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3214,9 +3206,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3234,9 +3223,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3254,9 +3240,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5490,6 +5473,16 @@ "react-native": "*" } }, + "node_modules/expo-screen-capture": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-screen-capture/-/expo-screen-capture-8.0.10.tgz", + "integrity": "sha512-yoD4giW4gg3PHQxkOrL4ArZCn3NJtLnL9opdZV2rMv94PXYZSNbTvhMipYVoDXKEj19k+R3fjoQGZpH9gCFILA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-secure-store": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", @@ -7399,9 +7392,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7422,9 +7412,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7445,9 +7432,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7468,9 +7452,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 13b54cef..c4dea583 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "expo-intent-launcher": "~13.0.8", "expo-localization": "~17.0.9", "expo-notifications": "^0.32.17", + "expo-screen-capture": "~8.0.10", "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-status-bar": "~3.0.9", diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index 7c2d4185..8d61aef0 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -19,6 +19,7 @@ import { } from '../../lib/client-cert'; import { useLocaleStore } from '../../stores/locale-store'; import { useAuthStore } from '../../stores/auth-store'; +import { useSettingsStore } from '../../stores/settings-store'; import { passwordChangeOutcome } from './password-change'; import { AccountSecurityForbiddenError, @@ -134,6 +135,26 @@ function ClientCertSection() { ); } +// ── Screen privacy (mobile-only) ────────────────────────── +// The webmail has no counterpart: a browser can't block screenshots. +// lib/screen-privacy applies the setting. +function ScreenPrivacySection() { + const t = useLocaleStore((s) => s.t); + const blockScreenshots = useSettingsStore((s) => s.blockScreenshots); + const update = useSettingsStore((s) => s.updateSetting); + return ( + + + update('blockScreenshots', v)} /> + + + ); +} + // ── Password change ─────────────────────────────────────── // With TOTP on, Stalwart wants the current code for the change too. A change // also ends the sign-in's tokens (checked on 0.16.27), so a token sign-in has @@ -973,6 +994,8 @@ export function AccountSecuritySettings() { return ( + + {certSupported && } {supported === null && ( diff --git a/src/lib/__tests__/screen-privacy.test.ts b/src/lib/__tests__/screen-privacy.test.ts new file mode 100644 index 00000000..3d2c112f --- /dev/null +++ b/src/lib/__tests__/screen-privacy.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const capture = vi.hoisted(() => ({ + preventScreenCaptureAsync: vi.fn(async (_key?: string) => {}), + allowScreenCaptureAsync: vi.fn(async (_key?: string) => {}), + enableAppSwitcherProtectionAsync: vi.fn(async (_blur?: number) => {}), + disableAppSwitcherProtectionAsync: vi.fn(async () => {}), +})); +vi.mock('expo-screen-capture', () => capture); +vi.mock('../../api/identity', () => ({ getIdentities: vi.fn(async () => []) })); + +import { Platform } from 'react-native'; +import { useSettingsStore, toExportShape, fromExportShape } from '../../stores/settings-store'; +import { resetScreenPrivacyForTests, startScreenPrivacySync } from '../screen-privacy'; + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe('block screenshots', () => { + let stop: (() => void) | null = null; + + beforeEach(() => { + useSettingsStore.getState().resetToDefaults(); + resetScreenPrivacyForTests(); + Object.values(capture).forEach((fn) => fn.mockClear()); + (Platform as { OS: string }).OS = 'android'; + }); + + afterEach(() => { + stop?.(); + stop = null; + (Platform as { OS: string }).OS = 'android'; + }); + + it('is off by default and stays on this device', () => { + expect(useSettingsStore.getState().blockScreenshots).toBe(false); + expect(toExportShape({ ...useSettingsStore.getState(), blockScreenshots: true })).not.toHaveProperty('blockScreenshots'); + expect(fromExportShape({ blockScreenshots: true })).not.toHaveProperty('blockScreenshots'); + }); + + it('leaves screen capture alone while the setting is off', async () => { + stop = startScreenPrivacySync(); + await flush(); + expect(capture.preventScreenCaptureAsync).not.toHaveBeenCalled(); + expect(capture.allowScreenCaptureAsync).not.toHaveBeenCalled(); + }); + + it('blocks capture when turned on and allows it again when turned off', async () => { + stop = startScreenPrivacySync(); + useSettingsStore.getState().updateSetting('blockScreenshots', true); + await flush(); + expect(capture.preventScreenCaptureAsync).toHaveBeenCalledTimes(1); + const key = capture.preventScreenCaptureAsync.mock.calls[0][0]; + + useSettingsStore.getState().updateSetting('blockScreenshots', false); + await flush(); + expect(capture.allowScreenCaptureAsync).toHaveBeenCalledWith(key); + // Android hides the recents thumbnail through FLAG_SECURE already. + expect(capture.enableAppSwitcherProtectionAsync).not.toHaveBeenCalled(); + }); + + it('applies a saved "on" at launch', async () => { + useSettingsStore.setState({ blockScreenshots: true }); + stop = startScreenPrivacySync(); + await flush(); + expect(capture.preventScreenCaptureAsync).toHaveBeenCalledTimes(1); + }); + + it('blurs the app switcher on iOS', async () => { + (Platform as { OS: string }).OS = 'ios'; + stop = startScreenPrivacySync(); + useSettingsStore.getState().updateSetting('blockScreenshots', true); + await flush(); + expect(capture.preventScreenCaptureAsync).toHaveBeenCalledTimes(1); + expect(capture.enableAppSwitcherProtectionAsync).toHaveBeenCalledTimes(1); + + useSettingsStore.getState().updateSetting('blockScreenshots', false); + await flush(); + expect(capture.disableAppSwitcherProtectionAsync).toHaveBeenCalledTimes(1); + }); + + it('tries again on the next change when the native call fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + capture.preventScreenCaptureAsync.mockRejectedValueOnce(new Error('no window')); + stop = startScreenPrivacySync(); + useSettingsStore.getState().updateSetting('blockScreenshots', true); + await flush(); + useSettingsStore.getState().updateSetting('blockScreenshots', false); + await flush(); + // The failed "on" never took effect, so "off" has nothing to undo. + expect(capture.allowScreenCaptureAsync).not.toHaveBeenCalled(); + useSettingsStore.getState().updateSetting('blockScreenshots', true); + await flush(); + expect(capture.preventScreenCaptureAsync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/__tests__/settings-search.test.ts b/src/lib/__tests__/settings-search.test.ts index e741f187..4a2885cc 100644 --- a/src/lib/__tests__/settings-search.test.ts +++ b/src/lib/__tests__/settings-search.test.ts @@ -126,6 +126,7 @@ describe('settings search index (English catalog)', () => { expect(labelsFor('reading')).toContain('Clear search when switching folders'); expect(labelsFor('composing')).toContain('Quick reply'); expect(labelsFor('notifications')).toContain('Unread count on app icon'); + expect(labelsFor('security')).toContain('Block screenshots'); // Webmail-only settings are not offered. expect(labelsFor('calendar')).not.toContain('Free scrolling'); expect(labelsFor('notifications')).not.toContain('Notification sound'); diff --git a/src/lib/licences.json b/src/lib/licences.json index 0571e07b..34cc3655 100644 --- a/src/lib/licences.json +++ b/src/lib/licences.json @@ -118,6 +118,12 @@ "license": "MIT", "url": "https://docs.expo.dev/versions/latest/sdk/notifications/" }, + { + "name": "expo-screen-capture", + "version": "8.0.10", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/screen-capture" + }, { "name": "expo-secure-store", "version": "15.0.8", diff --git a/src/lib/screen-privacy.ts b/src/lib/screen-privacy.ts new file mode 100644 index 00000000..63c6c18a --- /dev/null +++ b/src/lib/screen-privacy.ts @@ -0,0 +1,44 @@ +import { Platform } from 'react-native'; +import * as ScreenCapture from 'expo-screen-capture'; +import { useSettingsStore } from '../stores/settings-store'; + +// "Block screenshots" (Settings > Security, blockScreenshots). On Android +// preventScreenCaptureAsync sets FLAG_SECURE, which also leaves the recent- +// apps thumbnail blank. iOS blocks screenshots and recordings the same way +// but needs the blur overlay for the app switcher on top. +const CAPTURE_KEY = 'bulwark-block-screenshots'; +const SWITCHER_BLUR = 1; + +// Nothing is blocked when the app starts, so "off" needs no native call. +let applied = false; + +export async function applyScreenPrivacy(block: boolean): Promise { + if (applied === block) return; + applied = block; + try { + if (block) { + await ScreenCapture.preventScreenCaptureAsync(CAPTURE_KEY); + if (Platform.OS === 'ios') await ScreenCapture.enableAppSwitcherProtectionAsync(SWITCHER_BLUR); + } else { + await ScreenCapture.allowScreenCaptureAsync(CAPTURE_KEY); + if (Platform.OS === 'ios') await ScreenCapture.disableAppSwitcherProtectionAsync(); + } + } catch (err) { + // Try again on the next change rather than believing the flag is set. + applied = !block; + console.warn('[screen-privacy] could not change screen capture', err); + } +} + +/** Follow the setting from launch on, including the value hydrate loads. */ +export function startScreenPrivacySync(): () => void { + void applyScreenPrivacy(useSettingsStore.getState().blockScreenshots); + return useSettingsStore.subscribe((state, prev) => { + if (state.blockScreenshots !== prev.blockScreenshots) void applyScreenPrivacy(state.blockScreenshots); + }); +} + +/** For tests: forget what was applied. */ +export function resetScreenPrivacyForTests(): void { + applied = false; +} diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index 3259881c..88b4a680 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -169,6 +169,7 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.downloads.after_export', ], security: [ + 'settings.security.screen_privacy', 'settings.security.client_cert', 'settings.security.password', 'settings.security.display_name', @@ -283,7 +284,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record = { folders: 'mailbox rename role', keywords: 'tags labels colors', downloads: 'download filename template eml attachment save export', - security: 'password 2fa two-factor totp app password mfa api key certificate encryption', + security: 'password 2fa two-factor totp app password mfa api key certificate encryption screenshot screen recording recent apps privacy', content_senders: 'block sender remote images privacy tracking', calendar: `event schedule appointment meeting timezone${supportsDeviceSync ? ' android phone device sync' : ''}`, contacts: `address book contact${supportsDeviceSync ? ' android phone device sync' : ''}`, diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 438e5eb3..315f95e7 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -147,6 +147,9 @@ export interface PersistedSettings { trustedSendersAddressBook: boolean | null; senderFavicons: boolean; hideInlineImageAttachments: boolean; + // Mobile-only, device-local: block screenshots and screen recordings and + // hide the app's content in the recent-apps switcher (lib/screen-privacy). + blockScreenshots: boolean; // Language, region & time dateFormat: DateFormat; @@ -374,6 +377,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { trustedSendersAddressBook: null, senderFavicons: false, hideInlineImageAttachments: true, + blockScreenshots: false, groupContactsByLetter: true, sortContactsByLastName: false, @@ -762,6 +766,7 @@ export const DEVICE_LOCAL_KEYS: ReadonlySet = new Set {