diff --git a/formulus/src/screens/SettingsScreen.tsx b/formulus/src/screens/SettingsScreen.tsx index 7d81069b7..eff272af1 100644 --- a/formulus/src/screens/SettingsScreen.tsx +++ b/formulus/src/screens/SettingsScreen.tsx @@ -8,6 +8,7 @@ import { ActivityIndicator, Image, ScrollView, + Alert, } from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; import {useNavigation} from '@react-navigation/native'; @@ -21,6 +22,8 @@ import {MainAppStackParamList} from '../types/NavigationTypes'; import {colors} from '../theme/colors'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import {ToastService} from '../services/ToastService'; +import {serverSwitchService} from '../services/ServerSwitchService'; +import {syncService} from '../services/SyncService'; type SettingsScreenNavigationProp = StackNavigationProp< MainAppStackParamList, @@ -30,6 +33,7 @@ type SettingsScreenNavigationProp = StackNavigationProp< const SettingsScreen = () => { const navigation = useNavigation(); const [serverUrl, setServerUrl] = useState(''); + const [initialServerUrl, setInitialServerUrl] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(true); @@ -41,26 +45,141 @@ const SettingsScreen = () => { loadSettings(); }, []); - const saveServerUrl = useCallback(async (url: string) => { - if (url.trim()) { - await serverConfigService.saveServerUrl(url); - } - }, []); + const handleServerSwitchIfNeeded = useCallback( + async (url: string): Promise => { + const trimmedUrl = url.trim(); + if (!trimmedUrl) { + ToastService.showLong('Please enter a server URL'); + return false; + } - useEffect(() => { - const timer = setTimeout(() => { - if (serverUrl) { - saveServerUrl(serverUrl); + // If unchanged, just ensure it's saved + if (trimmedUrl === initialServerUrl) { + await serverConfigService.saveServerUrl(trimmedUrl); + return true; } - }, 500); - return () => clearTimeout(timer); - }, [serverUrl, saveServerUrl]); + + try { + const [pendingObservations, pendingAttachments] = await Promise.all([ + serverSwitchService.getPendingObservationCount(), + serverSwitchService.getPendingAttachmentCount(), + ]); + + const performReset = async () => { + await serverSwitchService.resetForServerChange(trimmedUrl); + setInitialServerUrl(trimmedUrl); + setServerUrl(trimmedUrl); + ToastService.showShort('Switched server and cleared local data.'); + }; + + const syncThenReset = async () => { + try { + await syncService.syncObservations(true); + await performReset(); + return true; + } catch (error) { + console.error('Sync before server switch failed:', error); + ToastService.showLong( + 'Sync failed. Please retry or proceed without syncing.', + ); + return false; + } + }; + + return await new Promise(resolve => { + const hasPending = pendingObservations > 0 || pendingAttachments > 0; + const message = hasPending + ? `Unsynced observations: ${pendingObservations}\nUnsynced attachments: ${pendingAttachments}\n\nSync is recommended before switching.` + : 'Switching servers will wipe all local data for the previous server.'; + + const buttons = hasPending + ? [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => { + setServerUrl(initialServerUrl); + resolve(false); + }, + }, + { + text: 'Proceed without syncing', + style: 'destructive', + onPress: () => { + (async () => { + try { + await performReset(); + resolve(true); + } catch (error) { + console.error('Failed to switch server:', error); + ToastService.showLong( + 'Failed to switch server. Please try again.', + ); + resolve(false); + } + })(); + }, + }, + { + text: 'Sync then switch', + onPress: () => { + (async () => { + if (syncService.getIsSyncing()) { + ToastService.showShort('Sync already in progress...'); + return; + } + const ok = await syncThenReset(); + resolve(ok); + })(); + }, + }, + ] + : [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => { + setServerUrl(initialServerUrl); + resolve(false); + }, + }, + { + text: 'Yes, wipe & switch', + style: 'destructive', + onPress: () => { + (async () => { + try { + await performReset(); + resolve(true); + } catch (error) { + console.error('Failed to switch server:', error); + ToastService.showLong( + 'Failed to switch server. Please try again.', + ); + resolve(false); + } + })(); + }, + }, + ]; + + Alert.alert('Switch server?', message, buttons, {cancelable: false}); + }); + } catch (error) { + console.error('Failed to prepare server switch:', error); + ToastService.showLong('Unable to check pending data. Try again.'); + return false; + } + }, + [initialServerUrl], + ); const loadSettings = async () => { try { const savedUrl = await serverConfigService.getServerUrl(); if (savedUrl) { setServerUrl(savedUrl); + setInitialServerUrl(savedUrl); } const credentials = await Keychain.getGenericPassword(); @@ -82,9 +201,14 @@ const SettingsScreen = () => { if (!serverUrl.trim() || !username.trim() || !password.trim()) { return; } + + const serverReady = await handleServerSwitchIfNeeded(serverUrl); + if (!serverReady) { + return; + } + setIsLoggingIn(true); try { - await serverConfigService.saveServerUrl(serverUrl); await Keychain.setGenericPassword(username, password); const userInfo = await login(username, password); setLoggedInUser(userInfo); @@ -112,6 +236,14 @@ const SettingsScreen = () => { const settings = await QRSettingsService.processQRCode( result.data.value, ); + + const serverReady = await handleServerSwitchIfNeeded( + settings.serverUrl, + ); + if (!serverReady) { + return; + } + setServerUrl(settings.serverUrl); setUsername(settings.username); setPassword(settings.password); diff --git a/formulus/src/services/ServerSwitchService.ts b/formulus/src/services/ServerSwitchService.ts new file mode 100644 index 000000000..abeb69243 --- /dev/null +++ b/formulus/src/services/ServerSwitchService.ts @@ -0,0 +1,78 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import RNFS from 'react-native-fs'; +import {database} from '../database/database'; +import {databaseService} from '../database/DatabaseService'; +import {synkronusApi} from '../api/synkronus'; +import {logout} from '../api/synkronus/Auth'; +import {serverConfigService} from './ServerConfigService'; + +/** + * Handles cleanup when switching Synkronus servers to avoid cross-server data. + */ +class ServerSwitchService { + /** + * Count pending local observations (unsynced). + */ + async getPendingObservationCount(): Promise { + const localRepo = databaseService.getLocalRepo(); + const pending = await localRepo.getPendingChanges(); + return pending.length; + } + + /** + * Count pending attachment uploads. + */ + async getPendingAttachmentCount(): Promise { + return await synkronusApi.getUnsyncedAttachmentCount(); + } + + /** + * Fully reset local state and persist the new server URL. + */ + async resetForServerChange(serverUrl: string): Promise { + // 1) Clear attachments on disk (fail fast) + const attachmentsDirectory = `${RNFS.DocumentDirectoryPath}/attachments`; + try { + if (await RNFS.exists(attachmentsDirectory)) { + await RNFS.unlink(attachmentsDirectory); + } + } catch (error) { + throw new Error(`Failed to delete attachments directory: ${error}`); + } + await RNFS.mkdir(attachmentsDirectory); + await RNFS.mkdir(`${attachmentsDirectory}/pending_upload`); + + // 2) Clear app bundle and forms (fail fast) + await synkronusApi.removeAppBundleFiles(); + + // 3) Reset DB + await database.write(async () => { + await database.unsafeResetDatabase(); + }); + + // 4) Clear sync/app metadata + tokens + await AsyncStorage.multiRemove([ + '@last_seen_version', + '@last_attachment_version', + '@lastSync', + '@appVersion', + '@settings', + '@server_url', + '@token', + '@refreshToken', + '@tokenExpiresAt', + '@user', + ]); + await AsyncStorage.setItem('@appVersion', '0'); + + // 5) Clear auth/session + await logout().catch(error => + console.warn('Logout during server switch failed:', error), + ); + + // 6) Save the new server URL (recreates @settings/@server_url) + await serverConfigService.saveServerUrl(serverUrl); + } +} + +export const serverSwitchService = new ServerSwitchService(); diff --git a/formulus/src/services/__tests__/ServerSwitchService.test.ts b/formulus/src/services/__tests__/ServerSwitchService.test.ts new file mode 100644 index 000000000..66d8d3892 --- /dev/null +++ b/formulus/src/services/__tests__/ServerSwitchService.test.ts @@ -0,0 +1,125 @@ +/// + +import {describe, it, expect, jest, beforeEach} from '@jest/globals'; + +const mockDatabase = { + write: jest.fn(async (cb?: () => Promise | void) => { + if (cb) { + await cb(); + } + }), + unsafeResetDatabase: jest.fn(), +}; + +jest.mock('../../database/database', () => ({ + database: mockDatabase, +})); + +const mockLocalRepo = {getPendingChanges: jest.fn()}; +jest.mock('../../database/DatabaseService', () => ({ + databaseService: { + getLocalRepo: jest.fn(() => mockLocalRepo), + }, +})); + +const mockAsyncStorage = { + multiRemove: jest.fn(), + setItem: jest.fn(), +}; +jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage); + +const mockRNFS = { + DocumentDirectoryPath: '/mock/doc', + exists: jest.fn(), + unlink: jest.fn(), + mkdir: jest.fn(), +}; +jest.mock('react-native-fs', () => mockRNFS); + +const mockSynkronusApi = { + removeAppBundleFiles: jest.fn(), + getUnsyncedAttachmentCount: jest.fn(), +}; +jest.mock('../../api/synkronus', () => ({ + synkronusApi: mockSynkronusApi, +})); + +const mockLogout = jest.fn(() => Promise.resolve()); +jest.mock('../../api/synkronus/Auth', () => ({ + logout: mockLogout, +})); + +const mockServerConfigService = { + saveServerUrl: jest.fn(), +}; +jest.mock('../ServerConfigService', () => ({ + serverConfigService: mockServerConfigService, +})); + +const {serverSwitchService} = require('../ServerSwitchService'); + +describe('ServerSwitchService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resets local state and saves new server URL', async () => { + mockRNFS.exists.mockResolvedValueOnce(true); + + await serverSwitchService.resetForServerChange('https://new.example'); + + expect(mockRNFS.exists).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.unlink).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.mkdir).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.mkdir).toHaveBeenCalledWith( + '/mock/doc/attachments/pending_upload', + ); + + expect(mockSynkronusApi.removeAppBundleFiles).toHaveBeenCalled(); + + expect(mockDatabase.write).toHaveBeenCalled(); + expect(mockDatabase.unsafeResetDatabase).toHaveBeenCalled(); + + expect(mockAsyncStorage.multiRemove).toHaveBeenCalledWith([ + '@last_seen_version', + '@last_attachment_version', + '@lastSync', + '@appVersion', + '@settings', + '@server_url', + '@token', + '@refreshToken', + '@tokenExpiresAt', + '@user', + ]); + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith('@appVersion', '0'); + + expect(mockLogout).toHaveBeenCalled(); + expect(mockServerConfigService.saveServerUrl).toHaveBeenCalledWith( + 'https://new.example', + ); + }); + + it('returns pending observation count', async () => { + mockLocalRepo.getPendingChanges.mockResolvedValueOnce([1, 2, 3]); + const result = await serverSwitchService.getPendingObservationCount(); + expect(result).toBe(3); + }); + + it('returns pending attachment count', async () => { + mockSynkronusApi.getUnsyncedAttachmentCount.mockResolvedValueOnce(5); + const result = await serverSwitchService.getPendingAttachmentCount(); + expect(result).toBe(5); + }); + + it('throws if attachments directory cannot be deleted', async () => { + mockRNFS.exists.mockResolvedValueOnce(true); + mockRNFS.unlink.mockRejectedValueOnce(new Error('permission')); + + await expect( + serverSwitchService.resetForServerChange('https://new.example'), + ).rejects.toThrow('Failed to delete attachments directory'); + + expect(mockDatabase.write).not.toHaveBeenCalled(); + }); +});