diff --git a/src/App.tsx b/src/App.tsx index 95ec406e6..2529c2780 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { Text, View, } from 'react-native'; -import { CaptureProtection, CaptureProtectionProvider } from 'react-native-capture-protection'; +import { CaptureProtectionProvider } from 'react-native-capture-protection'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { useTailwind } from 'tailwind-rn'; @@ -25,6 +25,7 @@ import LinkCopiedModal from './components/modals/LinkCopiedModal'; import { DriveContextProvider } from './contexts/Drive'; import { getRemoteUpdateIfAvailable, useLoadFonts } from './helpers'; import useGetColor from './hooks/useColor'; +import { useScreenProtection } from './hooks/useScreenProtection'; import { useSecurity } from './hooks/useSecurity'; import Navigation from './navigation'; import { LockScreen } from './screens/common/LockScreen'; @@ -55,6 +56,8 @@ export default function App(): JSX.Element { const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>('light'); const { performPeriodicSecurityCheck } = useSecurity(); + useScreenProtection(); + useEffect(() => { const initializeTheme = async () => { const savedTheme = await asyncStorageService.getThemePreference(); @@ -207,8 +210,6 @@ export default function App(): JSX.Element { }; useEffect(() => { - CaptureProtection.prevent(); - const initializeTheme = async () => { const savedTheme = await asyncStorageService.getThemePreference(); if (savedTheme) { diff --git a/src/hooks/useScreenProtection.spec.ts b/src/hooks/useScreenProtection.spec.ts new file mode 100644 index 000000000..bdab7674c --- /dev/null +++ b/src/hooks/useScreenProtection.spec.ts @@ -0,0 +1,199 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { CaptureProtection } from 'react-native-capture-protection'; +import asyncStorageService from '../services/AsyncStorageService'; +import { logger } from '../services/common'; +import { useScreenProtection } from './useScreenProtection'; + +jest.mock('react-native-capture-protection', () => ({ + CaptureProtection: { + prevent: jest.fn(), + allow: jest.fn(), + }, +})); + +jest.mock('../services/AsyncStorageService', () => ({ + getScreenProtectionEnabled: jest.fn(), + saveScreenProtectionEnabled: jest.fn(), +})); + +jest.mock('../services/common', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('useScreenProtection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with protection enabled when saved preference is true', async () => { + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(true); + + const { result } = renderHook(() => useScreenProtection()); + + expect(result.current.isInitialized).toBe(false); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + expect(result.current.isEnabled).toBe(true); + expect(CaptureProtection.prevent).toHaveBeenCalledTimes(1); + expect(CaptureProtection.allow).not.toHaveBeenCalled(); + expect(asyncStorageService.getScreenProtectionEnabled).toHaveBeenCalledTimes(1); + }); + + it('should initialize with protection disabled when saved preference is false', async () => { + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(false); + + const { result } = renderHook(() => useScreenProtection()); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + expect(result.current.isEnabled).toBe(false); + expect(CaptureProtection.allow).toHaveBeenCalledTimes(1); + expect(CaptureProtection.prevent).not.toHaveBeenCalled(); + }); + + it('should default to enabled on initialization error', async () => { + const error = new Error('Storage error'); + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useScreenProtection()); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + expect(result.current.isEnabled).toBe(true); + expect(CaptureProtection.prevent).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith('Error initializing screen protection:', error); + }); + }); + + describe('setScreenProtection', () => { + beforeEach(async () => { + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(false); + }); + + it('should enable screen protection when called with true', async () => { + (asyncStorageService.saveScreenProtectionEnabled as jest.Mock).mockResolvedValue(undefined); + + const { result } = renderHook(() => useScreenProtection()); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + await act(async () => { + await result.current.setScreenProtection(true); + }); + + expect(result.current.isEnabled).toBe(true); + expect(CaptureProtection.prevent).toHaveBeenCalled(); + expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(true); + }); + + it('should disable screen protection when called with false', async () => { + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(true); + (asyncStorageService.saveScreenProtectionEnabled as jest.Mock).mockResolvedValue(undefined); + + const { result } = renderHook(() => useScreenProtection()); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + await act(async () => { + await result.current.setScreenProtection(false); + }); + + expect(result.current.isEnabled).toBe(false); + expect(CaptureProtection.allow).toHaveBeenCalled(); + expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(false); + }); + + it('should revert to enabled on failure', async () => { + const error = new Error('CaptureProtection error'); + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(false); + (CaptureProtection.allow as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useScreenProtection()); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + await act(async () => { + await result.current.setScreenProtection(false); + }); + + expect(result.current.isEnabled).toBe(true); + expect(CaptureProtection.prevent).toHaveBeenCalled(); + expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(true); + expect(logger.error).toHaveBeenCalledWith('Error setting screen protection:', error); + }); + + it('should save preference after successfully changing protection state', async () => { + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(false); + (asyncStorageService.saveScreenProtectionEnabled as jest.Mock).mockResolvedValue(undefined); + (CaptureProtection.prevent as jest.Mock).mockResolvedValue(undefined); + (CaptureProtection.allow as jest.Mock).mockResolvedValue(undefined); + + const { result } = renderHook(() => useScreenProtection()); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + await act(async () => { + await result.current.setScreenProtection(true); + }); + + expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(true); + expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.setScreenProtection(false); + }); + + expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(false); + expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledTimes(2); + }); + }); + + describe('security defaults', () => { + it('should default to enabled for security if no preference is saved', async () => { + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(true); + + const { result } = renderHook(() => useScreenProtection()); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + expect(result.current.isEnabled).toBe(true); + expect(CaptureProtection.prevent).toHaveBeenCalled(); + }); + + it('should enable protection on error for security', async () => { + (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockRejectedValue(new Error('Storage unavailable')); + + const { result } = renderHook(() => useScreenProtection()); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + expect(result.current.isEnabled).toBe(true); + expect(CaptureProtection.prevent).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/hooks/useScreenProtection.ts b/src/hooks/useScreenProtection.ts new file mode 100644 index 000000000..e7366f50f --- /dev/null +++ b/src/hooks/useScreenProtection.ts @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import { CaptureProtection } from 'react-native-capture-protection'; +import asyncStorageService from '../services/AsyncStorageService'; +import { logger } from '../services/common'; + +/** + * Hook to manage screen protection (prevents screenshots and screen recording) + * Handles initialization, state management, and persistence of user preference + */ +export const useScreenProtection = () => { + const [isEnabled, setIsEnabled] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + + /** + * Initializes screen protection based on saved user preference + * Defaults to enabled (true) for security if no preference is saved + */ + useEffect(() => { + const initialize = async () => { + try { + const savedPreference = await asyncStorageService.getScreenProtectionEnabled(); + setIsEnabled(savedPreference); + + if (savedPreference) { + await CaptureProtection.prevent(); + } else { + await CaptureProtection.allow(); + } + + setIsInitialized(true); + } catch (error) { + logger.error('Error initializing screen protection:', error); + setIsEnabled(true); + await CaptureProtection.prevent(); + setIsInitialized(true); + } + }; + + initialize(); + }, []); + + /** + * Enables or disables screen protection + * @param enabled - true to prevent screenshots/recording, false to allow + */ + const setScreenProtection = async (enabled: boolean): Promise => { + try { + setIsEnabled(enabled); + + if (enabled) { + await CaptureProtection.prevent(); + } else { + await CaptureProtection.allow(); + } + + await asyncStorageService.saveScreenProtectionEnabled(enabled); + } catch (error) { + logger.error('Error setting screen protection:', error); + setIsEnabled(true); + await CaptureProtection.prevent(); + await asyncStorageService.saveScreenProtectionEnabled(true); + } + }; + + return { + isEnabled, + isInitialized, + setScreenProtection, + }; +}; diff --git a/src/screens/SettingsScreen/index.tsx b/src/screens/SettingsScreen/index.tsx index f476edc27..3df4e4cdf 100644 --- a/src/screens/SettingsScreen/index.tsx +++ b/src/screens/SettingsScreen/index.tsx @@ -24,6 +24,7 @@ import AppVersionWidget from '../../components/AppVersionWidget'; import SettingsGroup from '../../components/SettingsGroup'; import UserProfilePicture from '../../components/UserProfilePicture'; import useGetColor from '../../hooks/useColor'; +import { useScreenProtection } from '../../hooks/useScreenProtection'; import appService from '../../services/AppService'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { authSelectors } from '../../store/slices/auth'; @@ -36,7 +37,6 @@ import { fs } from '@internxt-mobile/services/FileSystemService'; import { notifications } from '@internxt-mobile/services/NotificationsService'; import { internxtMobileSDKUtils } from '@internxt/mobile-sdk'; -import { CaptureProtection, useCaptureProtection } from 'react-native-capture-protection'; import { paymentsSelectors } from 'src/store/slices/payments'; import asyncStorageService from '../../services/AsyncStorageService'; @@ -45,11 +45,10 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS const tailwind = useTailwind(); const getColor = useGetColor(); const dispatch = useAppDispatch(); - const { protectionStatus } = useCaptureProtection(); const scrollViewRef = useRef(null); const [isDarkMode, setIsDarkMode] = useState(false); - const [screenProtectionEnabled, setScreenProtectionEnabled] = useState(protectionStatus?.screenshot); + const { isEnabled: isScreenProtectionEnabled, setScreenProtection } = useScreenProtection(); const showBilling = useAppSelector(paymentsSelectors.shouldShowBilling); const { user } = useAppSelector((state) => state.auth); const usagePercent = useAppSelector(storageSelectors.usagePercent); @@ -134,18 +133,7 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS }; const handleScreenProtection = async (value: boolean) => { - try { - setScreenProtectionEnabled(value); - - if (value) { - await CaptureProtection.prevent(); - } else { - await CaptureProtection.allow(); - } - } catch (error) { - setScreenProtectionEnabled(true); - await CaptureProtection.prevent(); - } + await setScreenProtection(value); }; const onAccountPressed = () => { @@ -385,7 +373,7 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS thumbColor={isDarkMode ? getColor('text-white') : getColor('text-gray-40')} ios_backgroundColor={getColor('text-gray-20')} onValueChange={handleScreenProtection} - value={screenProtectionEnabled} + value={isScreenProtectionEnabled} /> diff --git a/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.test.ts b/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.spec.ts similarity index 89% rename from src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.test.ts rename to src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.spec.ts index ecb9f9ab5..620040957 100644 --- a/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.test.ts +++ b/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.spec.ts @@ -1,3 +1,19 @@ +jest.mock('@internxt-mobile/services/drive/file', () => ({ + driveFileService: { + regenerateThumbnail: jest.fn(), + }, +})); + +jest.mock('@internxt-mobile/services/common', () => ({ + logger: { + info: jest.fn(), + }, +})); + +jest.mock('@internxt-mobile/services/ErrorService', () => ({ + reportError: jest.fn(), +})); + import { canGenerateThumbnail, shouldRegenerateThumbnail } from './useThumbnailRegeneration'; describe('useThumbnailRegeneration', () => { diff --git a/src/services/AsyncStorageService.ts b/src/services/AsyncStorageService.ts index ad5921215..21023eb7d 100644 --- a/src/services/AsyncStorageService.ts +++ b/src/services/AsyncStorageService.ts @@ -98,6 +98,20 @@ class AsyncStorageService { return this.saveItem(AsyncStorageKey.LastSecurityCheck, date.toISOString()); } + /** + * Gets the screen protection preference (prevents screenshots/screen recording) + * @returns {Promise} true if protection is enabled, defaults to true for security if not set + */ + async getScreenProtectionEnabled(): Promise { + const screenProtectionEnabled = await this.getItem(AsyncStorageKey.ScreenProtectionEnabled); + + return screenProtectionEnabled === null ? true : screenProtectionEnabled === 'true'; + } + + saveScreenProtectionEnabled(enabled: boolean): Promise { + return this.saveItem(AsyncStorageKey.ScreenProtectionEnabled, enabled.toString()); + } + async clearStorage(): Promise { try { const nonSensitiveKeys = [ diff --git a/src/types/index.ts b/src/types/index.ts index b1b0c0043..56b8fed8b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -99,6 +99,7 @@ export enum AsyncStorageKey { LastSecurityCheck = 'lastSecurityCheck', SecurityAlertDismissed = 'securityAlertDismissed', LastSecurityHash = 'lastSecurityHash', + ScreenProtectionEnabled = 'screenProtectionEnabled', } export type ProgressCallback = (progress: number) => void;