feat: add a setting to block screenshots and hide the app in recent apps

This commit is contained in:
Linus Rath
2026-10-31 16:03:54 +01:00
parent f9a3b76174
commit 0ae33f3a07
12 changed files with 199 additions and 31 deletions
+4
View File
@@ -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?.();
};
}, []);
+2
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<!-- expo-camera declares the microphone for video; QR scanning never records audio. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
<!-- expo-screen-capture declares it for its screenshot listener; the app only blocks capture. -->
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" tools:node="remove"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
+5
View File
@@ -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",
+11 -30
View File
@@ -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": [
+1
View File
@@ -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",
@@ -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 (
<SettingsSection title={t('settings.security.screen_privacy.title', "Screen privacy")}>
<SettingItem
label={t('settings.security.screen_privacy.block_screenshots', "Block screenshots")}
description={t('settings.security.screen_privacy.block_screenshots_description', "Stops screenshots and screen recordings of the app and hides its content in the recent apps view.")}
noBorder
>
<ToggleSwitch checked={blockScreenshots} onChange={(v) => update('blockScreenshots', v)} />
</SettingItem>
</SettingsSection>
);
}
// ── 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 (
<View style={styles.container}>
<ScreenPrivacySection />
{certSupported && <ClientCertSection />}
{supported === null && (
+95
View File
@@ -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);
});
});
@@ -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');
+6
View File
@@ -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",
+44
View File
@@ -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<void> {
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;
}
+2 -1
View File
@@ -169,6 +169,7 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'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<SettingsTabId, string> = {
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' : ''}`,
+5
View File
@@ -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<keyof PersistedSettings> = new Set<k
'filesShowThumbnails',
'filesShowHiddenFiles',
'calendarDefaultView',
'blockScreenshots',
]);
export function toExportShape(state: PersistedSettings): Record<string, unknown> {