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
158 changes: 145 additions & 13 deletions formulus/src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -30,6 +33,7 @@ type SettingsScreenNavigationProp = StackNavigationProp<
const SettingsScreen = () => {
const navigation = useNavigation<SettingsScreenNavigationProp>();
const [serverUrl, setServerUrl] = useState('');
const [initialServerUrl, setInitialServerUrl] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(true);
Expand All @@ -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<boolean> => {
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<boolean>(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();
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
78 changes: 78 additions & 0 deletions formulus/src/services/ServerSwitchService.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
const localRepo = databaseService.getLocalRepo();
const pending = await localRepo.getPendingChanges();
return pending.length;
}

/**
* Count pending attachment uploads.
*/
async getPendingAttachmentCount(): Promise<number> {
return await synkronusApi.getUnsyncedAttachmentCount();
}

/**
* Fully reset local state and persist the new server URL.
*/
async resetForServerChange(serverUrl: string): Promise<void> {
// 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();
Loading
Loading