Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -207,8 +210,6 @@ export default function App(): JSX.Element {
};

useEffect(() => {
CaptureProtection.prevent();

const initializeTheme = async () => {
const savedTheme = await asyncStorageService.getThemePreference();
if (savedTheme) {
Expand Down
199 changes: 199 additions & 0 deletions src/hooks/useScreenProtection.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
70 changes: 70 additions & 0 deletions src/hooks/useScreenProtection.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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,
};
};
20 changes: 4 additions & 16 deletions src/screens/SettingsScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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<ScrollView | null>(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);
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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}
/>
</View>
</View>
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
Loading
Loading