Skip to content

Commit a288fbf

Browse files
authored
Merge pull request #9 from algorandfoundation/feat/prevent-screenshots
feat: prevent screenshots of sensitive data.
2 parents 8f8ae36 + 9b7908c commit a288fbf

File tree

10 files changed

+288
-19
lines changed

10 files changed

+288
-19
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Tests for lib/screenshotManager.ts
3+
*
4+
* Because screenshotManager is a singleton, each test uses jest.resetModules()
5+
* + a fresh require() so state never leaks between cases.
6+
*
7+
* enable() / disable() / reset() return the internal queue promise, so tests
8+
* can await the returned value directly instead of relying on setImmediate.
9+
*/
10+
11+
import type { screenshotManager as ScreenshotManagerType } from "../../lib/screenshotManager";
12+
13+
describe("ScreenshotManager", () => {
14+
let manager: typeof ScreenshotManagerType;
15+
let mockPrevent: jest.Mock;
16+
let mockAllow: jest.Mock;
17+
18+
beforeEach(() => {
19+
jest.resetModules();
20+
21+
mockPrevent = jest.fn().mockResolvedValue(undefined);
22+
mockAllow = jest.fn().mockResolvedValue(undefined);
23+
24+
// Register fresh mock factory after resetting the module registry.
25+
jest.mock("expo-screen-capture", () => ({
26+
preventScreenCaptureAsync: mockPrevent,
27+
allowScreenCaptureAsync: mockAllow,
28+
}));
29+
30+
({ screenshotManager: manager } =
31+
// eslint-disable-next-line @typescript-eslint/no-require-imports
32+
require("../../lib/screenshotManager") as {
33+
screenshotManager: typeof ScreenshotManagerType;
34+
});
35+
});
36+
37+
// ── basic enable / disable ────────────────────────────────────────────────
38+
39+
it("single enable() calls preventScreenCaptureAsync once", async () => {
40+
await manager.enable();
41+
42+
expect(mockPrevent).toHaveBeenCalledTimes(1);
43+
expect(mockAllow).not.toHaveBeenCalled();
44+
});
45+
46+
it("multiple enable() calls only invoke preventScreenCaptureAsync once", async () => {
47+
await manager.enable();
48+
await manager.enable();
49+
await manager.enable();
50+
51+
expect(mockPrevent).toHaveBeenCalledTimes(1);
52+
expect(mockAllow).not.toHaveBeenCalled();
53+
});
54+
55+
it("enable() × 2 then disable() × 1 does not call allowScreenCaptureAsync", async () => {
56+
await manager.enable();
57+
await manager.enable();
58+
59+
await manager.disable();
60+
61+
expect(mockAllow).not.toHaveBeenCalled();
62+
});
63+
64+
it("enable() × 2 then disable() × 2 calls allowScreenCaptureAsync once", async () => {
65+
await manager.enable();
66+
await manager.enable();
67+
68+
await manager.disable();
69+
await manager.disable();
70+
71+
expect(mockPrevent).toHaveBeenCalledTimes(1);
72+
expect(mockAllow).toHaveBeenCalledTimes(1);
73+
});
74+
75+
it("disable() when count is already 0 does not call any API", async () => {
76+
await manager.disable();
77+
78+
expect(mockPrevent).not.toHaveBeenCalled();
79+
expect(mockAllow).not.toHaveBeenCalled();
80+
});
81+
82+
// ── failure resilience ────────────────────────────────────────────────────
83+
84+
it("preventScreenCaptureAsync failure leaves enabled=false so next enable() retries", async () => {
85+
mockPrevent.mockRejectedValueOnce(new Error("permission denied"));
86+
87+
await manager.enable();
88+
89+
expect(mockPrevent).toHaveBeenCalledTimes(1);
90+
91+
// enabled is still false internally — a subsequent enable() must retry.
92+
await manager.enable();
93+
94+
expect(mockPrevent).toHaveBeenCalledTimes(2);
95+
});
96+
97+
it("allowScreenCaptureAsync failure leaves enabled=true so next disable() retries", async () => {
98+
await manager.enable();
99+
100+
mockAllow.mockRejectedValueOnce(new Error("system error"));
101+
102+
await manager.disable();
103+
104+
expect(mockAllow).toHaveBeenCalledTimes(1);
105+
106+
// enabled is still true internally — a subsequent disable() must retry.
107+
await manager.disable();
108+
109+
expect(mockAllow).toHaveBeenCalledTimes(2);
110+
});
111+
112+
// ── queue serialisation ───────────────────────────────────────────────────
113+
114+
it("rapid enable/disable/enable settles with prevention active", async () => {
115+
// All three must be enqueued synchronously so the queue coalesces them.
116+
// By the time all updates run, count=1 and the intermediate disable
117+
// transition is a no-op (enabled stays true after the first enable).
118+
manager.enable();
119+
manager.disable();
120+
await manager.enable();
121+
122+
expect(mockPrevent).toHaveBeenCalledTimes(1);
123+
expect(mockAllow).not.toHaveBeenCalled();
124+
});
125+
126+
// ── reset ─────────────────────────────────────────────────────────────────
127+
128+
it("reset() while enabled forces allowScreenCaptureAsync and clears count", async () => {
129+
await manager.enable();
130+
await manager.enable(); // count=2, enabled=true
131+
132+
await manager.reset(); // count=0 → should disable
133+
134+
expect(mockAllow).toHaveBeenCalledTimes(1);
135+
136+
// Further disable() should be a no-op (count already 0, enabled already false).
137+
await manager.disable();
138+
139+
expect(mockAllow).toHaveBeenCalledTimes(1);
140+
});
141+
142+
it("reset() when already disabled is a no-op", async () => {
143+
await manager.reset();
144+
145+
expect(mockPrevent).not.toHaveBeenCalled();
146+
expect(mockAllow).not.toHaveBeenCalled();
147+
});
148+
});

app/_layout.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {registerGlobals} from "react-native-webrtc";
1515
import { globalPolyfill, setupNavigatorPolyfill } from "@/lib/polyfill";
1616
import ReactNativePasskeyAutofill from "@algorandfoundation/react-native-passkey-autofill";
1717
import { CredentialProviderService } from "@/lib/credentialProvider";
18+
import { PreventScreenshotProvider } from "@/providers/PreventScreenshotProvider";
1819
import React from "react";
1920

2021
globalPolyfill()
@@ -139,10 +140,12 @@ export default function RootLayout() {
139140
});
140141

141142
return (
142-
<WalletProvider
143-
provider={provider}
144-
>
145-
<Stack />
146-
</WalletProvider>
143+
<PreventScreenshotProvider>
144+
<WalletProvider
145+
provider={provider}
146+
>
147+
<Stack />
148+
</WalletProvider>
149+
</PreventScreenshotProvider>
147150
)
148151
}

app/import.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { MaterialIcons } from '@expo/vector-icons';
77
import { wordlist } from '@scure/bip39/wordlists/english.js';
88
import { validateMnemonic, mnemonicToSeed } from '@scure/bip39';
99
import { useProvider } from '@/hooks/useProvider';
10+
import { PreventScreenshot } from '@/components/PreventScreenshot';
1011

1112
// Extract provider configuration from expo-constants
1213
const config = Constants.expoConfig?.extra?.provider || {
@@ -138,18 +139,20 @@ export default function ImportWalletScreen() {
138139

139140
<View style={styles.importInputContainer}>
140141
<Text style={styles.importLabel}>Recovery Phrase (24 words)</Text>
141-
<TextInput
142-
style={styles.importTextInput}
143-
multiline
144-
numberOfLines={8}
145-
placeholder="Enter your 24-word recovery phrase here...&#10;word1 word2 word3 ..."
146-
placeholderTextColor="#94A3B8"
147-
value={importText}
148-
onChangeText={setImportText}
149-
autoCapitalize="none"
150-
autoCorrect={false}
151-
textAlignVertical="top"
152-
/>
142+
<PreventScreenshot>
143+
<TextInput
144+
style={styles.importTextInput}
145+
multiline
146+
numberOfLines={8}
147+
placeholder="Enter your 24-word recovery phrase here...&#10;word1 word2 word3 ..."
148+
placeholderTextColor="#94A3B8"
149+
value={importText}
150+
onChangeText={setImportText}
151+
autoCapitalize="none"
152+
autoCorrect={false}
153+
textAlignVertical="top"
154+
/>
155+
</PreventScreenshot>
153156
<Text style={styles.importHelper}>
154157
Words entered: {importText.split(/\s+/).filter(w => w.length > 0).length} / 24
155158
</Text>

app/onboarding.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as bip39 from '@scure/bip39';
1313
import { useProvider } from '@/hooks/useProvider'
1414
import { mnemonicToSeed } from '@scure/bip39'
1515
import ReactNativePasskeyAutofill from "@algorandfoundation/react-native-passkey-autofill";
16+
import { PreventScreenshot } from '@/components/PreventScreenshot';
1617

1718

1819
// Extract provider configuration from expo-constants
@@ -237,7 +238,7 @@ export default function OnboardingScreen() {
237238
</View>
238239

239240
{!isBackupVerified && (
240-
<>
241+
<PreventScreenshot enabled={isPhraseVisible}>
241242
<SeedPhrase
242243
recoveryPhrase={recoveryPhrase || []}
243244
showSeed={isPhraseVisible}
@@ -247,7 +248,7 @@ export default function OnboardingScreen() {
247248
}
248249
primaryColor={primaryColor}
249250
/>
250-
</>
251+
</PreventScreenshot>
251252
)}
252253
</ScrollView>
253254

components/PreventScreenshot.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { usePreventScreenshot } from "@/hooks/usePreventScreenshot";
2+
3+
type Props = {
4+
enabled?: boolean;
5+
children: React.ReactNode;
6+
};
7+
8+
export function PreventScreenshot({ enabled = true, children }: Props) {
9+
usePreventScreenshot(enabled);
10+
11+
return <>{children}</>;
12+
}

hooks/usePreventScreenshot.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { screenshotManager } from "@/lib/screenshotManager";
2+
import { useEffect } from "react";
3+
4+
export function usePreventScreenshot(enabled = true) {
5+
useEffect(() => {
6+
if (!enabled) return;
7+
8+
void screenshotManager.enable().catch((error) => {
9+
console.error("Failed to enable screenshot prevention: ", error);
10+
});
11+
12+
return () => {
13+
void screenshotManager.disable().catch((error) => {
14+
console.error("Failed to disable screenshot prevention: ", error);
15+
});
16+
};
17+
}, [enabled]);
18+
}

lib/screenshotManager.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as ScreenCapture from "expo-screen-capture";
2+
3+
class ScreenshotManager {
4+
private count = 0;
5+
private enabled = false;
6+
private queue: Promise<void> = Promise.resolve();
7+
8+
private async update() {
9+
const shouldEnable = this.count > 0;
10+
11+
if (shouldEnable === this.enabled) return;
12+
13+
const currentCount = this.count; // Capture the current count for logging
14+
15+
if (shouldEnable) {
16+
await ScreenCapture.preventScreenCaptureAsync();
17+
this.enabled = true;
18+
console.debug("Screenshot prevention enabled. Count:", currentCount);
19+
} else {
20+
await ScreenCapture.allowScreenCaptureAsync();
21+
this.enabled = false;
22+
console.debug("Screenshot prevention disabled. Count:", currentCount);
23+
}
24+
}
25+
26+
private enqueue(): Promise<void> {
27+
this.queue = this.queue
28+
.then(() => this.update())
29+
.catch((error) => {
30+
// Swallow the error so that subsequent operations are not blocked.
31+
console.error("Failed to update screenshot capture state: ", error);
32+
});
33+
return this.queue;
34+
}
35+
36+
enable(): Promise<void> {
37+
this.count++;
38+
return this.enqueue();
39+
}
40+
41+
disable(): Promise<void> {
42+
this.count = Math.max(0, this.count - 1);
43+
return this.enqueue();
44+
}
45+
46+
reset(): Promise<void> {
47+
this.count = 0;
48+
return this.enqueue();
49+
}
50+
}
51+
52+
export const screenshotManager = new ScreenshotManager();

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"expo-image": "~3.0.11",
4949
"expo-linking": "~8.0.11",
5050
"expo-router": "~6.0.23",
51+
"expo-screen-capture": "~8.0.9",
5152
"expo-splash-screen": "~31.0.13",
5253
"expo-status-bar": "~3.0.9",
5354
"expo-symbols": "~1.0.8",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { screenshotManager } from "@/lib/screenshotManager";
2+
import React, { useEffect } from "react";
3+
4+
// Not really a provider, but this is where we can do the safety reset on unmount
5+
export function PreventScreenshotProvider({
6+
children,
7+
}: {
8+
children: React.ReactNode;
9+
}) {
10+
useEffect(() => {
11+
return () => {
12+
// Safety reset when app unmounts
13+
screenshotManager.reset().catch((error) => {
14+
console.error("Failed to reset screenshot manager on unmount: ", error);
15+
});
16+
};
17+
}, []);
18+
19+
return <>{children}</>;
20+
}

0 commit comments

Comments
 (0)