From e0c06eaf27b30f21ada8a8b7edd3c8d564625beb Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Thu, 9 Jan 2025 09:54:52 +0100 Subject: [PATCH 1/7] chore: add react-native-screenshot-prevent to disable screenshots on iOS --- ios/Podfile.lock | 6 ++++ package.json | 1 + ts/RootContainer.tsx | 3 +- ...{FlagSecure.tsx => FlagSecure.android.tsx} | 0 ts/components/FlagSecure.ios.tsx | 33 +++++++++++++++++++ ts/store/reducers/allowedSnapshotScreens.ts | 4 ++- tsconfig.json | 3 +- yarn.lock | 11 +++++++ 8 files changed, 57 insertions(+), 4 deletions(-) rename ts/components/{FlagSecure.tsx => FlagSecure.android.tsx} (100%) create mode 100644 ts/components/FlagSecure.ios.tsx diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 06dde433409..8fdd59abd66 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2003,6 +2003,8 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - RNScreenshotPrevent (1.2.1): + - React - RNSentry (6.4.0): - DoubleConversion - glog @@ -2180,6 +2182,7 @@ DEPENDENCIES: - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - RNScreenshotPrevent (from `../node_modules/react-native-screenshot-prevent`) - "RNSentry (from `../node_modules/@sentry/react-native`)" - RNShare (from `../node_modules/react-native-share`) - RNStoreReview (from `../node_modules/react-native-store-review`) @@ -2429,6 +2432,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNScreenshotPrevent: + :path: "../node_modules/react-native-screenshot-prevent" RNSentry: :path: "../node_modules/@sentry/react-native" RNShare: @@ -2556,6 +2561,7 @@ SPEC CHECKSUMS: RNReactNativeHapticFeedback: 00ba111b82aa266bb3ee1aa576831c2ea9a9dfad RNReanimated: 26a5a401a5de1c0cf1a3226873825b00ffa85377 RNScreens: 35bb8e81aeccf111baa0ea01a54231390dbbcfd9 + RNScreenshotPrevent: 5f8473abaa2db2476561ef8f3704b66491ce7b01 RNSentry: c5075bc124ebc8afa84e037c7fe257053a0b2cda RNShare: 694e19d7f74ac4c04de3a8af0649e9ccc03bd8b1 RNStoreReview: 613c43e9132998ed41a65946e20c223c91b36464 diff --git a/package.json b/package.json index 32f3afd514c..70b2a63e271 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "react-native-safe-area-context": "^4.10.5", "react-native-screen-brightness": "^2.0.0-alpha", "react-native-screens": "^3.35.0", + "react-native-screenshot-prevent": "^1.2.1", "react-native-share": "^10.2.1", "react-native-splash-screen": "^3.2.0", "react-native-store-review": "^0.4.3", diff --git a/ts/RootContainer.tsx b/ts/RootContainer.tsx index 52e9434ce10..fe219e9a765 100644 --- a/ts/RootContainer.tsx +++ b/ts/RootContainer.tsx @@ -5,7 +5,6 @@ import { AppState, AppStateStatus, NativeEventSubscription, - Platform, StatusBar } from "react-native"; import SplashScreen from "react-native-splash-screen"; @@ -101,7 +100,7 @@ class RootContainer extends React.PureComponent { barStyle={"dark-content"} backgroundColor={customVariables.androidStatusBarColor} /> - {Platform.OS === "android" && } + ; + +/** + * + * Disable screenshots on iOS. The library implementation uses a hidden secure text field, + * as iOS does not expose an API to disable screenshots. + * + * @param props + * @constructor + */ +const FlagSecureComponent: React.FunctionComponent = props => { + useEffect(() => { + if (props.isAllowedSnapshotCurrentScreen) { + RNScreenshotPrevent.disableSecureView(); + } else { + RNScreenshotPrevent.enableSecureView(); + } + }, [props.isAllowedSnapshotCurrentScreen]); + return null; +}; + +const mapStateToProps = (state: GlobalState) => ({ + isAllowedSnapshotCurrentScreen: isAllowedSnapshotCurrentScreen(state) +}); + +export default connect(mapStateToProps)(FlagSecureComponent); diff --git a/ts/store/reducers/allowedSnapshotScreens.ts b/ts/store/reducers/allowedSnapshotScreens.ts index b6b2739a69b..d286d83e62b 100644 --- a/ts/store/reducers/allowedSnapshotScreens.ts +++ b/ts/store/reducers/allowedSnapshotScreens.ts @@ -1,10 +1,12 @@ import { createSelector } from "reselect"; import { PaymentsOnboardingRoutes } from "../../features/payments/onboarding/navigation/routes"; +import { ITW_ROUTES } from "../../features/itwallet/navigation/routes"; import { isDebugModeEnabledSelector } from "./debug"; import { currentRouteSelector } from "./navigation"; export const screenBlackList = new Set([ - PaymentsOnboardingRoutes.PAYMENT_ONBOARDING_SELECT_METHOD as string + PaymentsOnboardingRoutes.PAYMENT_ONBOARDING_SELECT_METHOD as string, + ...Object.values(ITW_ROUTES.PRESENTATION) ]); /** diff --git a/tsconfig.json b/tsconfig.json index 405ad4dea34..108d7a25bd0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "noUnusedParameters": true, "noUnusedLocals": true, "strictFunctionTypes": true, - "useUnknownInCatchVariables": true + "useUnknownInCatchVariables": true, + "moduleSuffixes": [".android", ".ios", ""] }, "exclude": [ "android", diff --git a/yarn.lock b/yarn.lock index 71d23fe4ef2..d3df32aa6a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13711,6 +13711,7 @@ __metadata: react-native-safe-area-context: ^4.10.5 react-native-screen-brightness: ^2.0.0-alpha react-native-screens: ^3.35.0 + react-native-screenshot-prevent: ^1.2.1 react-native-share: ^10.2.1 react-native-splash-screen: ^3.2.0 react-native-store-review: ^0.4.3 @@ -19272,6 +19273,16 @@ __metadata: languageName: node linkType: hard +"react-native-screenshot-prevent@npm:^1.2.1": + version: 1.2.1 + resolution: "react-native-screenshot-prevent@npm:1.2.1" + peerDependencies: + react: "*" + react-native: "*" + checksum: 5563adea419627ce1a066495c7779ffdfa85bdb9c180b47eb25215175a02fc4af402bf24cea684d208ae18651df9396fb545998dd3c566afe67f119ecfa62a4a + languageName: node + linkType: hard + "react-native-share@npm:^10.2.1": version: 10.2.1 resolution: "react-native-share@npm:10.2.1" From a59ce1da9b9de663d064f82e3ea138d99b337c94 Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Thu, 9 Jan 2025 10:30:20 +0100 Subject: [PATCH 2/7] refactor: use react-native-screenshot-prevent for iOS and Android --- package.json | 1 - ts/components/FlagSecure.android.tsx | 32 ------------------- .../{FlagSecure.ios.tsx => FlagSecure.tsx} | 5 +-- tsconfig.json | 3 +- yarn.lock | 8 ----- 5 files changed, 4 insertions(+), 45 deletions(-) delete mode 100644 ts/components/FlagSecure.android.tsx rename ts/components/{FlagSecure.ios.tsx => FlagSecure.tsx} (80%) diff --git a/package.json b/package.json index 70b2a63e271..6b7200888c3 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,6 @@ "react-native-easing-gradient": "^1.1.1", "react-native-exception-handler": "^2.10.8", "react-native-fingerprint-scanner": "git+https://github.com/hieuvp/react-native-fingerprint-scanner.git#9cecc0db326471c571553ea85f7c016fee2f803d", - "react-native-flag-secure-android": "^1.0.3", "react-native-fs": "^2.18.0", "react-native-gesture-handler": "^2.18.1", "react-native-haptic-feedback": "^2.3.3", diff --git a/ts/components/FlagSecure.android.tsx b/ts/components/FlagSecure.android.tsx deleted file mode 100644 index 5a0ffa4e1a2..00000000000 --- a/ts/components/FlagSecure.android.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from "react"; -import { useEffect } from "react"; -import FlagSecure from "react-native-flag-secure-android"; -import { connect } from "react-redux"; -import { isAllowedSnapshotCurrentScreen } from "../store/reducers/allowedSnapshotScreens"; -import { GlobalState } from "../store/reducers/types"; - -type Props = ReturnType; - -/** - * - * On Android, it enables or disables FLAG_SECURE based on the isAllowedSnapshotCurrentScreen prop. - * - * @param props - * @constructor - */ -const FlagSecureComponent: React.FunctionComponent = props => { - useEffect(() => { - if (props.isAllowedSnapshotCurrentScreen) { - FlagSecure.deactivate(); - } else { - FlagSecure.activate(); - } - }, [props.isAllowedSnapshotCurrentScreen]); - return null; -}; - -const mapStateToProps = (state: GlobalState) => ({ - isAllowedSnapshotCurrentScreen: isAllowedSnapshotCurrentScreen(state) -}); - -export default connect(mapStateToProps)(FlagSecureComponent); diff --git a/ts/components/FlagSecure.ios.tsx b/ts/components/FlagSecure.tsx similarity index 80% rename from ts/components/FlagSecure.ios.tsx rename to ts/components/FlagSecure.tsx index 34a809693e4..7af58296484 100644 --- a/ts/components/FlagSecure.ios.tsx +++ b/ts/components/FlagSecure.tsx @@ -9,8 +9,9 @@ type Props = ReturnType; /** * - * Disable screenshots on iOS. The library implementation uses a hidden secure text field, - * as iOS does not expose an API to disable screenshots. + * Disable screenshots. + * On Android, it enables or disables FLAG_SECURE based on the isAllowedSnapshotCurrentScreen prop. + * On iOS, uses a hidden secure text field as iOS does not expose an API to disable screenshots. * * @param props * @constructor diff --git a/tsconfig.json b/tsconfig.json index 108d7a25bd0..405ad4dea34 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,7 @@ "noUnusedParameters": true, "noUnusedLocals": true, "strictFunctionTypes": true, - "useUnknownInCatchVariables": true, - "moduleSuffixes": [".android", ".ios", ""] + "useUnknownInCatchVariables": true }, "exclude": [ "android", diff --git a/yarn.lock b/yarn.lock index d3df32aa6a8..647627ac301 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13686,7 +13686,6 @@ __metadata: react-native-easing-gradient: ^1.1.1 react-native-exception-handler: ^2.10.8 react-native-fingerprint-scanner: "git+https://github.com/hieuvp/react-native-fingerprint-scanner.git#9cecc0db326471c571553ea85f7c016fee2f803d" - react-native-flag-secure-android: ^1.0.3 react-native-fs: ^2.18.0 react-native-gesture-handler: ^2.18.1 react-native-get-random-values: ^1.11.0 @@ -18909,13 +18908,6 @@ __metadata: languageName: node linkType: hard -"react-native-flag-secure-android@npm:^1.0.3": - version: 1.0.3 - resolution: "react-native-flag-secure-android@npm:1.0.3" - checksum: 1b95bc83a040e924d4b913284b914559891d35b52505acb981c18ad82ea3139e3a48e7f710a6c52f9fe0bf32374b45fb7c3cd9a18a35f2c1b5f61f49c0a4737c - languageName: node - linkType: hard - "react-native-fs@npm:^2.18.0": version: 2.18.0 resolution: "react-native-fs@npm:2.18.0" From f1ffc55e92fd9e8088681d86107195c9369d2700 Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Thu, 9 Jan 2025 16:56:19 +0100 Subject: [PATCH 3/7] chore: add more ITW routes to screenshot blacklist --- ts/store/reducers/allowedSnapshotScreens.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ts/store/reducers/allowedSnapshotScreens.ts b/ts/store/reducers/allowedSnapshotScreens.ts index d286d83e62b..6adcec7678a 100644 --- a/ts/store/reducers/allowedSnapshotScreens.ts +++ b/ts/store/reducers/allowedSnapshotScreens.ts @@ -6,6 +6,9 @@ import { currentRouteSelector } from "./navigation"; export const screenBlackList = new Set([ PaymentsOnboardingRoutes.PAYMENT_ONBOARDING_SELECT_METHOD as string, + ITW_ROUTES.IDENTIFICATION.CIE.PIN_SCREEN, + ITW_ROUTES.ISSUANCE.CREDENTIAL_TRUST_ISSUER, + ITW_ROUTES.ISSUANCE.CREDENTIAL_PREVIEW, ...Object.values(ITW_ROUTES.PRESENTATION) ]); From a9997766163fad056a749ea78a25e5d9257d3cc4 Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Fri, 10 Jan 2025 11:22:33 +0100 Subject: [PATCH 4/7] chore: remove references to react-native-flag-secure-android --- android/settings.gradle | 2 -- patches/patches.md | 2 ++ .../react-native-flag-secure-android+1.0.3.patch | 15 --------------- ts/@types/react-native-flag-secure-android.d.ts | 4 ---- 4 files changed, 2 insertions(+), 21 deletions(-) delete mode 100644 patches/react-native-flag-secure-android+1.0.3.patch delete mode 100644 ts/@types/react-native-flag-secure-android.d.ts diff --git a/android/settings.gradle b/android/settings.gradle index 232922d6fee..53ccc6bfca6 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -14,8 +14,6 @@ include ':react-native-share' project(':react-native-share').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-share/android') include ':react-native-lewin-qrcode' project(':react-native-lewin-qrcode').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-lewin-qrcode/android') -include ':react-native-flag-secure-android' -project(':react-native-flag-secure-android').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-flag-secure-android/android') include ':react-native-fs' project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android') include ':react-native-android-open-settings' diff --git a/patches/patches.md b/patches/patches.md index d0e1a2d24f2..da920924325 100644 --- a/patches/patches.md +++ b/patches/patches.md @@ -132,6 +132,8 @@ Created on **24/03/2022** Created on **29/08/2022** +Removed on **10/01/2025** and replaced with `react-native-screenshot-prevent` + #### Reason: - This patch is going to fix a gradle issue that breaks the compile on android platform, due to gradle imcompatibility diff --git a/patches/react-native-flag-secure-android+1.0.3.patch b/patches/react-native-flag-secure-android+1.0.3.patch deleted file mode 100644 index 82b2bba9470..00000000000 --- a/patches/react-native-flag-secure-android+1.0.3.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/node_modules/react-native-flag-secure-android/.DS_Store b/node_modules/react-native-flag-secure-android/.DS_Store -new file mode 100644 -index 0000000..8186bc2 -Binary files /dev/null and b/node_modules/react-native-flag-secure-android/.DS_Store differ -diff --git a/node_modules/react-native-flag-secure-android/android/build.gradle b/node_modules/react-native-flag-secure-android/android/build.gradle -index 88fa0af..242e219 100644 ---- a/node_modules/react-native-flag-secure-android/android/build.gradle -+++ b/node_modules/react-native-flag-secure-android/android/build.gradle -@@ -36,5 +36,5 @@ android { - } - - dependencies { -- compile 'com.facebook.react:react-native:+' -+ implementation 'com.facebook.react:react-native:+' - } diff --git a/ts/@types/react-native-flag-secure-android.d.ts b/ts/@types/react-native-flag-secure-android.d.ts deleted file mode 100644 index 12277cc4d75..00000000000 --- a/ts/@types/react-native-flag-secure-android.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "react-native-flag-secure-android" { - function activate(): void; - function deactivate(): void; -} From 442b77d6388f71c8af2697d9cea9e52b6683f16b Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Fri, 10 Jan 2025 12:22:23 +0100 Subject: [PATCH 5/7] refactor: create usePreventScreenCapture, remove FlagSecure component --- ts/RootContainer.tsx | 2 - ts/components/FlagSecure.tsx | 34 ---------- .../__tests__/allowedSnapshotScreens.test.ts | 63 ------------------- ts/store/reducers/allowedSnapshotScreens.ts | 23 ------- .../usePreventScreenCapture.test.tsx | 45 +++++++++++++ ts/utils/hooks/usePreventScreenCapture.ts | 35 +++++++++++ 6 files changed, 80 insertions(+), 122 deletions(-) delete mode 100644 ts/components/FlagSecure.tsx delete mode 100644 ts/store/reducers/__tests__/allowedSnapshotScreens.test.ts delete mode 100644 ts/store/reducers/allowedSnapshotScreens.ts create mode 100644 ts/utils/hooks/__tests__/usePreventScreenCapture.test.tsx create mode 100644 ts/utils/hooks/usePreventScreenCapture.ts diff --git a/ts/RootContainer.tsx b/ts/RootContainer.tsx index fe219e9a765..dc729a15721 100644 --- a/ts/RootContainer.tsx +++ b/ts/RootContainer.tsx @@ -11,7 +11,6 @@ import SplashScreen from "react-native-splash-screen"; import { connect } from "react-redux"; import configurePushNotifications from "./features/pushNotifications/utils/configurePushNotification"; import DebugInfoOverlay from "./components/DebugInfoOverlay"; -import FlagSecureComponent from "./components/FlagSecure"; import PagoPATestIndicatorOverlay from "./components/PagoPATestIndicatorOverlay"; import { LightModalRoot } from "./components/ui/LightModal"; import { setLocale } from "./i18n"; @@ -100,7 +99,6 @@ class RootContainer extends React.PureComponent { barStyle={"dark-content"} backgroundColor={customVariables.androidStatusBarColor} /> - ; - -/** - * - * Disable screenshots. - * On Android, it enables or disables FLAG_SECURE based on the isAllowedSnapshotCurrentScreen prop. - * On iOS, uses a hidden secure text field as iOS does not expose an API to disable screenshots. - * - * @param props - * @constructor - */ -const FlagSecureComponent: React.FunctionComponent = props => { - useEffect(() => { - if (props.isAllowedSnapshotCurrentScreen) { - RNScreenshotPrevent.disableSecureView(); - } else { - RNScreenshotPrevent.enableSecureView(); - } - }, [props.isAllowedSnapshotCurrentScreen]); - return null; -}; - -const mapStateToProps = (state: GlobalState) => ({ - isAllowedSnapshotCurrentScreen: isAllowedSnapshotCurrentScreen(state) -}); - -export default connect(mapStateToProps)(FlagSecureComponent); diff --git a/ts/store/reducers/__tests__/allowedSnapshotScreens.test.ts b/ts/store/reducers/__tests__/allowedSnapshotScreens.test.ts deleted file mode 100644 index 44f5dbe29b6..00000000000 --- a/ts/store/reducers/__tests__/allowedSnapshotScreens.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable eslint-comments/no-unlimited-disable */ -import { applicationChangeState } from "../../actions/application"; -import { setDebugModeEnabled } from "../../actions/debug"; -import { - isAllowedSnapshotCurrentScreen, - screenBlackList -} from "../allowedSnapshotScreens"; -import { appReducer } from "../index"; -import { GlobalState } from "../types"; - -jest.mock("@react-native-async-storage/async-storage", () => ({ - AsyncStorage: jest.fn() -})); - -jest.mock("react-native-share", () => ({ - open: jest.fn() -})); - -describe("allowed Snapshot Screens Selector test", () => { - it("Test high level composition", () => { - // with the debug mode enabled, expected true - expect( - isAllowedSnapshotCurrentScreen.resultFunc( - "NOT_SNAPSHOTTABLE_SCREEN", - true - ) - ).toBeTruthy(); - }); - it("Test all blacklisted screens", () => { - screenBlackList.forEach(screen => { - expect( - isAllowedSnapshotCurrentScreen.resultFunc(screen, false) - ).toBeFalsy(); - expect( - isAllowedSnapshotCurrentScreen.resultFunc(screen, true) - ).toBeTruthy(); - }); - }); - it("Test re-computations only when store interesting part changes", () => { - // eslint-disable-next-line - let globalState: GlobalState = appReducer( - undefined, - applicationChangeState("active") - ); - expect(isAllowedSnapshotCurrentScreen(globalState)).toBeTruthy(); - expect(isAllowedSnapshotCurrentScreen(globalState)).toBeTruthy(); - // with the same state, only one computation is expected - expect(isAllowedSnapshotCurrentScreen.recomputations()).toBe(1); - globalState = appReducer(globalState, setDebugModeEnabled(false)); - expect(isAllowedSnapshotCurrentScreen(globalState)).toBeTruthy(); - // with a change of state but the same values, no new computation are expected - expect(isAllowedSnapshotCurrentScreen.recomputations()).toBe(1); - globalState = appReducer(globalState, setDebugModeEnabled(true)); - expect(isAllowedSnapshotCurrentScreen(globalState)).toBeTruthy(); - expect(isAllowedSnapshotCurrentScreen(globalState)).toBeTruthy(); - // with a change of state and change in the interested values, a new computation is expected - expect(isAllowedSnapshotCurrentScreen.recomputations()).toBe(2); - globalState = appReducer(globalState, applicationChangeState("background")); - expect(isAllowedSnapshotCurrentScreen(globalState)).toBeTruthy(); - // with a change of state but not in the interested part, no new computation are expected - expect(isAllowedSnapshotCurrentScreen.recomputations()).toBe(2); - }); -}); diff --git a/ts/store/reducers/allowedSnapshotScreens.ts b/ts/store/reducers/allowedSnapshotScreens.ts deleted file mode 100644 index 6adcec7678a..00000000000 --- a/ts/store/reducers/allowedSnapshotScreens.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createSelector } from "reselect"; -import { PaymentsOnboardingRoutes } from "../../features/payments/onboarding/navigation/routes"; -import { ITW_ROUTES } from "../../features/itwallet/navigation/routes"; -import { isDebugModeEnabledSelector } from "./debug"; -import { currentRouteSelector } from "./navigation"; - -export const screenBlackList = new Set([ - PaymentsOnboardingRoutes.PAYMENT_ONBOARDING_SELECT_METHOD as string, - ITW_ROUTES.IDENTIFICATION.CIE.PIN_SCREEN, - ITW_ROUTES.ISSUANCE.CREDENTIAL_TRUST_ISSUER, - ITW_ROUTES.ISSUANCE.CREDENTIAL_PREVIEW, - ...Object.values(ITW_ROUTES.PRESENTATION) -]); - -/** - * Return {true} if a snapshot can be taken in the current screen (android only). - * If the app is in debug mode, the snapshot is always possible. - */ -export const isAllowedSnapshotCurrentScreen = createSelector( - [currentRouteSelector, isDebugModeEnabledSelector], - (currentRoute, debugEnabled) => - debugEnabled ? true : !screenBlackList.has(currentRoute) -); diff --git a/ts/utils/hooks/__tests__/usePreventScreenCapture.test.tsx b/ts/utils/hooks/__tests__/usePreventScreenCapture.test.tsx new file mode 100644 index 00000000000..7ff26ecadff --- /dev/null +++ b/ts/utils/hooks/__tests__/usePreventScreenCapture.test.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { render } from "@testing-library/react-native"; +import { + enableSecureView, + disableSecureView +} from "react-native-screenshot-prevent"; +import { usePreventScreenCapture } from "../usePreventScreenCapture"; + +jest.mock("react-native-screenshot-prevent", () => ({ + enableSecureView: jest.fn(), + disableSecureView: jest.fn() +})); + +type Params = { + key?: string; +}; + +const renderHook = ({ key }: Params = {}) => { + const Component = () => { + usePreventScreenCapture(key); + return null; + }; + return render(); +}; + +describe("usePreventScreenCapture", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("calls native methods once if mounted & unmounted", async () => { + const { unmount } = renderHook(); + expect(enableSecureView).toHaveBeenCalledTimes(1); + unmount(); + expect(disableSecureView).toHaveBeenCalledTimes(1); + }); + + it("does not re-allow screen capture when two hooks are active and one is unmounted", async () => { + renderHook({ key: "A" }); + const hook2 = renderHook({ key: "B" }); + hook2.unmount(); + expect(enableSecureView).toHaveBeenCalledTimes(2); + expect(disableSecureView).toHaveBeenCalledTimes(0); + }); +}); diff --git a/ts/utils/hooks/usePreventScreenCapture.ts b/ts/utils/hooks/usePreventScreenCapture.ts new file mode 100644 index 00000000000..097186d4414 --- /dev/null +++ b/ts/utils/hooks/usePreventScreenCapture.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import RNScreenshotPrevent from "react-native-screenshot-prevent"; + +const activeTags: Set = new Set(); + +const preventScreenCapture = (key: string) => { + if (!activeTags.has(key)) { + activeTags.add(key); + RNScreenshotPrevent.enableSecureView(); + } +}; + +const allowScreenCapture = (key: string) => { + activeTags.delete(key); + if (activeTags.size === 0) { + RNScreenshotPrevent.disableSecureView(); + } +}; + +/** + * Hook that disables screen capture for as long as the component is mounted: + * - On Android, it enables `FLAG_SECURE` + * - On iOS, uses a hidden secure text field as the platform does not expose an API to disable screenshots + * + * @param key An optional key to prevent conflicts when using multiple instances of this hook at the same time. + */ +export function usePreventScreenCapture(key = "default") { + useEffect(() => { + preventScreenCapture(key); + + return () => { + allowScreenCapture(key); + }; + }, [key]); +} From dc5a26c79875adf0f1469be6379ec9bc4bd1fa91 Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Fri, 10 Jan 2025 12:47:14 +0100 Subject: [PATCH 6/7] refactor: move usePreventScreenCapture into relevant screens --- .../itwallet/identification/screens/cie/ItwCiePinScreen.tsx | 3 +++ .../issuance/screens/ItwIssuanceCredentialPreviewScreen.tsx | 2 ++ .../screens/ItwIssuanceCredentialTrustIssuerScreen.tsx | 2 ++ .../screens/ItwPresentationCredentialAttachmentScreen.tsx | 2 ++ .../screens/ItwPresentationCredentialCardModal.tsx | 2 ++ .../screens/ItwPresentationCredentialDetailScreen.tsx | 2 ++ .../screens/ItwPresentationCredentialFiscalCodeModal.tsx | 2 ++ .../trustmark/screens/ItwCredentialTrustmarkScreen.tsx | 2 ++ .../screens/PaymentsOnboardingSelectMethodScreen.tsx | 3 +++ 9 files changed, 20 insertions(+) diff --git a/ts/features/itwallet/identification/screens/cie/ItwCiePinScreen.tsx b/ts/features/itwallet/identification/screens/cie/ItwCiePinScreen.tsx index 65c1a52d759..dc47816d14c 100644 --- a/ts/features/itwallet/identification/screens/cie/ItwCiePinScreen.tsx +++ b/ts/features/itwallet/identification/screens/cie/ItwCiePinScreen.tsx @@ -27,6 +27,7 @@ import I18n from "../../../../../i18n"; import { useIOSelector } from "../../../../../store/hooks"; import { setAccessibilityFocus } from "../../../../../utils/accessibility"; import { useIOBottomSheetAutoresizableModal } from "../../../../../utils/hooks/bottomSheet"; +import { usePreventScreenCapture } from "../../../../../utils/hooks/usePreventScreenCapture"; import { withTrailingPoliceCarLightEmojii } from "../../../../../utils/strings"; import { openWebUrl } from "../../../../../utils/url"; import { @@ -61,6 +62,8 @@ const ForgottenPin = () => ( ); export const ItwCiePinScreen = () => { + usePreventScreenCapture(); + const useCieUat = useIOSelector(isCieLoginUatEnabledSelector); const machineRef = ItwEidIssuanceMachineContext.useActorRef(); diff --git a/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialPreviewScreen.tsx b/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialPreviewScreen.tsx index 5dfd6106410..9c8a11ef419 100644 --- a/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialPreviewScreen.tsx +++ b/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialPreviewScreen.tsx @@ -33,6 +33,7 @@ import { selectCredentialOption, selectCredentialTypeOption } from "../../machine/credential/selectors"; +import { usePreventScreenCapture } from "../../../../utils/hooks/usePreventScreenCapture"; import { ItwCredentialIssuanceMachineContext } from "../../machine/provider"; import { ItwCredentialPreviewClaimsList } from "../components/ItwCredentialPreviewClaimsList"; import { ITW_ROUTES } from "../../navigation/routes"; @@ -45,6 +46,7 @@ export const ItwIssuanceCredentialPreviewScreen = () => { selectCredentialOption ); + usePreventScreenCapture(); useItwDisableGestureNavigation(); useAvoidHardwareBackButton(); diff --git a/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialTrustIssuerScreen.tsx b/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialTrustIssuerScreen.tsx index df7211c1835..095c8a46a14 100644 --- a/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialTrustIssuerScreen.tsx +++ b/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialTrustIssuerScreen.tsx @@ -53,6 +53,7 @@ import { trackWalletDataShareAccepted } from "../../analytics"; import LoadingScreenContent from "../../../../components/screens/LoadingScreenContent"; +import { usePreventScreenCapture } from "../../../../utils/hooks/usePreventScreenCapture"; import { itwIpzsPrivacyUrl } from "../../../../config"; import { ITW_ROUTES } from "../../navigation/routes"; @@ -68,6 +69,7 @@ const ItwIssuanceCredentialTrustIssuerScreen = () => { selectCredentialTypeOption ); + usePreventScreenCapture(); useItwDisableGestureNavigation(); useAvoidHardwareBackButton(); diff --git a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialAttachmentScreen.tsx b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialAttachmentScreen.tsx index a04300f0260..91d5e24c2db 100644 --- a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialAttachmentScreen.tsx +++ b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialAttachmentScreen.tsx @@ -19,6 +19,7 @@ import { import { ParsedCredential } from "../../common/utils/itwTypesUtils"; import { ItwParamsList } from "../../navigation/ItwParamsList"; import { trackWalletCredentialFAC_SIMILE } from "../../analytics"; +import { usePreventScreenCapture } from "../../../../utils/hooks/usePreventScreenCapture"; // We currently only support PDF files, extend this if needed type SupportedAttachmentType = "application/pdf"; @@ -49,6 +50,7 @@ export const ItwPresentationCredentialAttachmentScreen = ({ safeBottomAreaHeight: 0 }); + usePreventScreenCapture(); useFocusEffect(trackWalletCredentialFAC_SIMILE); useHeaderSecondLevel({ diff --git a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialCardModal.tsx b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialCardModal.tsx index a9669ea5ad8..6683bbfe8a7 100644 --- a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialCardModal.tsx +++ b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialCardModal.tsx @@ -21,6 +21,7 @@ import { } from "../../common/utils/itwTypesUtils"; import { ItwParamsList } from "../../navigation/ItwParamsList"; import { ItwPresentationCredentialCardFlipButton } from "../components/ItwPresentationCredentialCardFlipButton"; +import { usePreventScreenCapture } from "../../../../utils/hooks/usePreventScreenCapture"; export type ItwPresentationCredentialCardModalNavigationParams = { credential: StoredCredential; @@ -41,6 +42,7 @@ const ItwPresentationCredentialCardModal = ({ route, navigation }: Props) => { const [isFlipped, setFlipped] = React.useState(false); const theme = useIOTheme(); + usePreventScreenCapture(); useMaxBrightness({ useSmoothTransition: true }); React.useLayoutEffect(() => { diff --git a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialDetailScreen.tsx b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialDetailScreen.tsx index f8c51ac4d50..962b8804dff 100644 --- a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialDetailScreen.tsx +++ b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialDetailScreen.tsx @@ -36,6 +36,7 @@ import { import { ItwCredentialTrustmark } from "../../trustmark/components/ItwCredentialTrustmark"; import ItwCredentialNotFound from "../../common/components/ItwCredentialNotFound"; import { ItwPresentationCredentialUnknownStatus } from "../components/ItwPresentationCredentialUnknownStatus"; +import { usePreventScreenCapture } from "../../../../utils/hooks/usePreventScreenCapture"; export type ItwPresentationCredentialDetailNavigationParams = { credentialType: string; @@ -81,6 +82,7 @@ const ItwPresentationCredentialDetail = ({ ); useDebugInfo(credential); + usePreventScreenCapture(); useFocusEffect(() => { trackCredentialDetail({ diff --git a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialFiscalCodeModal.tsx b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialFiscalCodeModal.tsx index 7467e3e1124..a83d56786c8 100644 --- a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialFiscalCodeModal.tsx +++ b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialFiscalCodeModal.tsx @@ -19,6 +19,7 @@ import { selectFiscalCodeFromEid, selectNameSurnameFromEid } from "../../credentials/store/selectors"; +import { usePreventScreenCapture } from "../../../../utils/hooks/usePreventScreenCapture"; /** * This magic number is the lenght of the encoded fiscal code in a CODE39 barcode. @@ -72,6 +73,7 @@ const ItwPresentationCredentialFiscalCodeModal = () => { const nameSurname = useIOSelector(selectNameSurnameFromEid); const fiscalCode = useIOSelector(selectFiscalCodeFromEid); + usePreventScreenCapture(); useMaxBrightness({ useSmoothTransition: true }); React.useLayoutEffect(() => { diff --git a/ts/features/itwallet/trustmark/screens/ItwCredentialTrustmarkScreen.tsx b/ts/features/itwallet/trustmark/screens/ItwCredentialTrustmarkScreen.tsx index acf0693a136..db1ba648968 100644 --- a/ts/features/itwallet/trustmark/screens/ItwCredentialTrustmarkScreen.tsx +++ b/ts/features/itwallet/trustmark/screens/ItwCredentialTrustmarkScreen.tsx @@ -9,6 +9,7 @@ import { ItwParamsList } from "../../navigation/ItwParamsList"; import { ItwTrustmarkExpirationTimer } from "../components/ItwTrustmarkExpirationTimer"; import { ItwTrustmarkQrCode } from "../components/ItwTrustmarkQrCode"; import { ItwTrustmarkMachineProvider } from "../machine/provider"; +import { usePreventScreenCapture } from "../../../../utils/hooks/usePreventScreenCapture"; export type ItwCredentialTrustmarkScreenNavigationParams = { credentialType: string; @@ -22,6 +23,7 @@ type ScreenProps = IOStackNavigationRouteProps< export const ItwCredentialTrustmarkScreen = (params: ScreenProps) => { const { credentialType } = params.route.params; + usePreventScreenCapture(); useMaxBrightness({ useSmoothTransition: true }); return ( diff --git a/ts/features/payments/onboarding/screens/PaymentsOnboardingSelectMethodScreen.tsx b/ts/features/payments/onboarding/screens/PaymentsOnboardingSelectMethodScreen.tsx index 311b1bed57c..86691f529a2 100644 --- a/ts/features/payments/onboarding/screens/PaymentsOnboardingSelectMethodScreen.tsx +++ b/ts/features/payments/onboarding/screens/PaymentsOnboardingSelectMethodScreen.tsx @@ -12,8 +12,11 @@ import { PaymentsOnboardingRoutes } from "../navigation/routes"; import { paymentsOnboardingGetMethodsAction } from "../store/actions"; import { selectPaymentOnboardingMethods } from "../store/selectors"; import { IOScrollViewWithLargeHeader } from "../../../../components/ui/IOScrollViewWithLargeHeader"; +import { usePreventScreenCapture } from "../../../../utils/hooks/usePreventScreenCapture"; const PaymentsOnboardingSelectMethodScreen = () => { + usePreventScreenCapture(); + const navigation = useIONavigation(); const dispatch = useIODispatch(); From b833c9afe305a315a639ab427f2a021837d808ca Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Fri, 10 Jan 2025 15:38:27 +0100 Subject: [PATCH 7/7] chore: change useFocusEffect instead of useEffect --- .../usePreventScreenCapture.test.tsx | 38 ++++++++++++------- ts/utils/hooks/usePreventScreenCapture.ts | 29 ++++++++++---- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/ts/utils/hooks/__tests__/usePreventScreenCapture.test.tsx b/ts/utils/hooks/__tests__/usePreventScreenCapture.test.tsx index 7ff26ecadff..a9bc87d51f3 100644 --- a/ts/utils/hooks/__tests__/usePreventScreenCapture.test.tsx +++ b/ts/utils/hooks/__tests__/usePreventScreenCapture.test.tsx @@ -1,9 +1,11 @@ -import React from "react"; -import { render } from "@testing-library/react-native"; import { enableSecureView, disableSecureView } from "react-native-screenshot-prevent"; +import { createStore } from "redux"; +import { appReducer } from "../../../store/reducers"; +import { applicationChangeState } from "../../../store/actions/application"; +import { renderScreenWithNavigationStoreContext } from "../../testWrapper"; import { usePreventScreenCapture } from "../usePreventScreenCapture"; jest.mock("react-native-screenshot-prevent", () => ({ @@ -11,19 +13,9 @@ jest.mock("react-native-screenshot-prevent", () => ({ disableSecureView: jest.fn() })); -type Params = { - key?: string; -}; - -const renderHook = ({ key }: Params = {}) => { - const Component = () => { - usePreventScreenCapture(key); - return null; - }; - return render(); -}; - describe("usePreventScreenCapture", () => { + jest.useFakeTimers(); + beforeEach(() => { jest.resetAllMocks(); }); @@ -32,6 +24,7 @@ describe("usePreventScreenCapture", () => { const { unmount } = renderHook(); expect(enableSecureView).toHaveBeenCalledTimes(1); unmount(); + jest.advanceTimersByTime(500); expect(disableSecureView).toHaveBeenCalledTimes(1); }); @@ -39,7 +32,24 @@ describe("usePreventScreenCapture", () => { renderHook({ key: "A" }); const hook2 = renderHook({ key: "B" }); hook2.unmount(); + jest.advanceTimersByTime(500); expect(enableSecureView).toHaveBeenCalledTimes(2); expect(disableSecureView).toHaveBeenCalledTimes(0); }); }); + +type Params = { key?: string }; + +const renderHook = ({ key }: Params = {}) => { + const Component = () => { + usePreventScreenCapture(key); + return null; + }; + const globalState = appReducer(undefined, applicationChangeState("active")); + return renderScreenWithNavigationStoreContext( + Component, + "TEST", + {}, + createStore(appReducer, globalState as any) + ); +}; diff --git a/ts/utils/hooks/usePreventScreenCapture.ts b/ts/utils/hooks/usePreventScreenCapture.ts index 097186d4414..7f004a075f8 100644 --- a/ts/utils/hooks/usePreventScreenCapture.ts +++ b/ts/utils/hooks/usePreventScreenCapture.ts @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useFocusEffect } from "@react-navigation/native"; +import { useCallback, useRef } from "react"; import RNScreenshotPrevent from "react-native-screenshot-prevent"; const activeTags: Set = new Set(); @@ -18,18 +19,30 @@ const allowScreenCapture = (key: string) => { }; /** - * Hook that disables screen capture for as long as the component is mounted: + * Hook that disables screen capture for as long as the component is focused. + * + * In a stack navigator unfocused screens are not unmounted, so the `useEffect` cleanup function is not called. + * + * The native library implementation is the following: * - On Android, it enables `FLAG_SECURE` * - On iOS, uses a hidden secure text field as the platform does not expose an API to disable screenshots * * @param key An optional key to prevent conflicts when using multiple instances of this hook at the same time. */ export function usePreventScreenCapture(key = "default") { - useEffect(() => { - preventScreenCapture(key); + const timeoutRef = useRef(); + + useFocusEffect( + useCallback(() => { + clearTimeout(timeoutRef.current); + + preventScreenCapture(key); - return () => { - allowScreenCapture(key); - }; - }, [key]); + return () => { + // Here we wait a little after the blur event for navigation transition animations. + // eslint-disable-next-line functional/immutable-data + timeoutRef.current = setTimeout(() => allowScreenCapture(key), 500); + }; + }, [key]) + ); }