diff --git a/apps/mobile-app/app/(tabs)/credentials/index.tsx b/apps/mobile-app/app/(tabs)/credentials/index.tsx
index 88b6bad6e..d3bbf338c 100644
--- a/apps/mobile-app/app/(tabs)/credentials/index.tsx
+++ b/apps/mobile-app/app/(tabs)/credentials/index.tsx
@@ -175,6 +175,12 @@ export default function CredentialsScreen() : React.ReactNode {
// Logout user
await webApi.logout(error);
},
+ /**
+ * On upgrade required.
+ */
+ onUpgradeRequired: () : void => {
+ router.replace('/upgrade');
+ },
});
} catch (err) {
console.error('Error refreshing credentials:', err);
@@ -186,7 +192,7 @@ export default function CredentialsScreen() : React.ReactNode {
text2: err instanceof Error ? err.message : 'Unknown error',
});
}
- }, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, webApi, authContext]);
+ }, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, webApi, authContext, router]);
useEffect(() => {
if (!isAuthenticated || !isDatabaseAvailable) {
diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx
index 307de7d77..f5379942e 100644
--- a/apps/mobile-app/app/_layout.tsx
+++ b/apps/mobile-app/app/_layout.tsx
@@ -160,7 +160,14 @@ function RootLayoutNav() : React.ReactNode {
await webApi.logout(error);
setRedirectTarget('/login');
setBootComplete(true);
- }
+ },
+ /**
+ * On upgrade required.
+ */
+ onUpgradeRequired: () : void => {
+ setRedirectTarget('/upgrade');
+ setBootComplete(true);
+ },
});
};
@@ -262,6 +269,7 @@ function RootLayoutNav() : React.ReactNode {
+
diff --git a/apps/mobile-app/app/login.tsx b/apps/mobile-app/app/login.tsx
index 5203fd7a4..15ad2af45 100644
--- a/apps/mobile-app/app/login.tsx
+++ b/apps/mobile-app/app/login.tsx
@@ -192,6 +192,7 @@ export default function LoginScreen() : React.ReactNode {
await dbContext.storeEncryptionKeyDerivationParams(encryptionKeyDerivationParams);
await dbContext.initializeDatabase(vaultResponseJson);
+ let checkSuccess = true;
/**
* After setting auth tokens, execute a server status check immediately
* which takes care of certain sanity checks such as ensuring client/server
@@ -203,12 +204,32 @@ export default function LoginScreen() : React.ReactNode {
* Handle the status update.
*/
onError: (message) => {
+ checkSuccess = false;
+
// Show modal with error message
Alert.alert('Error', message);
webApi.logout(message);
- }
+ },
+ /**
+ * On upgrade required.
+ */
+ onUpgradeRequired: async () : Promise => {
+ checkSuccess = false;
+
+ // Still login to ensure the user is logged in.
+ await authContext.login();
+
+ // But after login, redirect to upgrade screen immediately.
+ router.replace('/upgrade');
+ return;
+ },
});
+ if (!checkSuccess) {
+ // If the syncvault checks have failed, we can't continue with the login process.
+ return;
+ }
+
await authContext.login();
authContext.setOfflineMode(false);
diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx
index 0acfb2965..6e27eb079 100644
--- a/apps/mobile-app/app/reinitialize.tsx
+++ b/apps/mobile-app/app/reinitialize.tsx
@@ -163,7 +163,13 @@ export default function ReinitializeScreen() : React.ReactNode {
}
]
);
- }
+ },
+ /**
+ * On upgrade required.
+ */
+ onUpgradeRequired: () : void => {
+ router.replace('/upgrade');
+ },
});
};
diff --git a/apps/mobile-app/app/upgrade.tsx b/apps/mobile-app/app/upgrade.tsx
new file mode 100644
index 000000000..040b611e9
--- /dev/null
+++ b/apps/mobile-app/app/upgrade.tsx
@@ -0,0 +1,472 @@
+import { LinearGradient } from 'expo-linear-gradient';
+import { router } from 'expo-router';
+import { useState, useEffect, useCallback } from 'react';
+import { StyleSheet, View, TouchableOpacity, Alert, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableWithoutFeedback, Keyboard, Text } from 'react-native';
+
+import type { VaultVersion } from '@/utils/dist/shared/vault-sql';
+import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
+
+import { useColors } from '@/hooks/useColorScheme';
+import { useVaultSync } from '@/hooks/useVaultSync';
+
+import Logo from '@/assets/images/logo.svg';
+import LoadingIndicator from '@/components/LoadingIndicator';
+import { ThemedText } from '@/components/themed/ThemedText';
+import { ThemedView } from '@/components/themed/ThemedView';
+import { Avatar } from '@/components/ui/Avatar';
+import { useAuth } from '@/context/AuthContext';
+import { useDb } from '@/context/DbContext';
+import { useWebApi } from '@/context/WebApiContext';
+import NativeVaultManager from '@/specs/NativeVaultManager';
+
+/**
+ * Upgrade screen.
+ */
+export default function UpgradeScreen() : React.ReactNode {
+ const { username } = useAuth();
+ const { sqliteClient } = useDb();
+ const [isLoading, setIsLoading] = useState(false);
+ const [currentVersion, setCurrentVersion] = useState(null);
+ const [latestVersion, setLatestVersion] = useState(null);
+ const [upgradeStatus, setUpgradeStatus] = useState('Preparing upgrade...');
+ const colors = useColors();
+ const webApi = useWebApi();
+ const { syncVault } = useVaultSync();
+
+ /**
+ * Load version information from the database.
+ */
+ const loadVersionInfo = useCallback(async () => {
+ try {
+ if (sqliteClient) {
+ const current = await sqliteClient.getDatabaseVersion();
+ const latest = await sqliteClient.getLatestDatabaseVersion();
+ setCurrentVersion(current);
+ setLatestVersion(latest);
+ }
+ } catch (error) {
+ console.error('Failed to load version information:', error);
+ }
+ }, [sqliteClient]);
+
+ useEffect(() => {
+ loadVersionInfo();
+ }, [loadVersionInfo]);
+
+ /**
+ * Handle the vault upgrade.
+ */
+ const handleUpgrade = async () : Promise => {
+ if (!sqliteClient || !currentVersion || !latestVersion) {
+ Alert.alert('Error', 'Unable to get version information. Please try again.');
+ return;
+ }
+
+ setIsLoading(true);
+ setUpgradeStatus('Preparing upgrade...');
+
+ try {
+ // Get upgrade SQL commands from vault-sql shared library
+ setUpgradeStatus('Generating upgrade SQL...');
+ const vaultSqlGenerator = new VaultSqlGenerator();
+ const upgradeResult = vaultSqlGenerator.getUpgradeVaultSql(currentVersion.revision, latestVersion.revision);
+
+ if (!upgradeResult.success) {
+ throw new Error(upgradeResult.error ?? 'Failed to generate upgrade SQL');
+ }
+
+ if (upgradeResult.sqlCommands.length === 0) {
+ // No upgrade needed, vault is already up to date
+ setUpgradeStatus('Vault is already up to date');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await handleUpgradeSuccess();
+ return;
+ }
+
+ // Begin transaction
+ setUpgradeStatus('Starting database transaction...');
+ await NativeVaultManager.beginTransaction();
+
+ // Execute each SQL command
+ setUpgradeStatus('Applying database migrations...');
+ for (let i = 0; i < upgradeResult.sqlCommands.length; i++) {
+ const sqlCommand = upgradeResult.sqlCommands[i];
+ console.log('update sql command', i);
+ console.log('Executing SQL command:', sqlCommand);
+ setUpgradeStatus(`Applying migration ${i + 1} of ${upgradeResult.sqlCommands.length}...`);
+
+ try {
+ await NativeVaultManager.executeRaw(sqlCommand);
+ } catch (error) {
+ console.error(`Error executing SQL command ${i + 1}:`, sqlCommand, error);
+ await NativeVaultManager.rollbackTransaction();
+ throw new Error(`Failed to apply migration ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ // Commit transaction
+ setUpgradeStatus('Committing changes...');
+ await NativeVaultManager.commitTransaction();
+
+ // Upload to server
+ setUpgradeStatus('Uploading vault to server...');
+ await uploadVaultToServer();
+
+ // Sync and navigate to credentials
+ setUpgradeStatus('Finalizing upgrade...');
+ await handleUpgradeSuccess();
+
+ } catch (error) {
+ console.error('Upgrade failed:', error);
+ Alert.alert('Upgrade Failed', error instanceof Error ? error.message : 'An unknown error occurred during the upgrade. Please try again.');
+ } finally {
+ setIsLoading(false);
+ setUpgradeStatus('Preparing upgrade...');
+ }
+ };
+
+ /**
+ * Upload the upgraded vault to the server.
+ */
+ const uploadVaultToServer = async () : Promise => {
+ try {
+ // Get the current vault revision number
+ const currentRevision = await NativeVaultManager.getCurrentVaultRevisionNumber();
+
+ // Get the encrypted database
+ const encryptedDb = await NativeVaultManager.getEncryptedDatabase();
+ if (!encryptedDb) {
+ throw new Error('Failed to get encrypted database');
+ }
+
+ // Get all private email domains from credentials in order to claim them on server
+ const privateEmailDomains = await sqliteClient!.getPrivateEmailDomains();
+
+ const credentials = await sqliteClient!.getAllCredentials();
+ const privateEmailAddresses = credentials
+ .filter(cred => cred.Alias?.Email != null)
+ .map(cred => cred.Alias!.Email!)
+ .filter((email, index, self) => self.indexOf(email) === index)
+ .filter(email => {
+ return privateEmailDomains.some(domain => email.toLowerCase().endsWith(`@${domain.toLowerCase()}`));
+ });
+
+ // Get username from the auth context
+ if (!username) {
+ throw new Error('Username not found');
+ }
+
+ // Create vault object for upload
+ const newVault = {
+ blob: encryptedDb,
+ createdAt: new Date().toISOString(),
+ credentialsCount: credentials.length,
+ currentRevisionNumber: currentRevision,
+ emailAddressList: privateEmailAddresses,
+ privateEmailDomainList: [], // Empty on purpose, API will not use this for vault updates
+ publicEmailDomainList: [], // Empty on purpose, API will not use this for vault updates
+ encryptionPublicKey: '', // Empty on purpose, only required if new public/private key pair is generated
+ client: '', // Empty on purpose, API will not use this for vault updates
+ updatedAt: new Date().toISOString(),
+ username: username,
+ version: (await sqliteClient!.getDatabaseVersion())?.version ?? '0.0.0'
+ };
+
+ // Upload to server
+ const response = await webApi.post('Vault', newVault);
+
+ if (response.status === 0) {
+ await NativeVaultManager.setCurrentVaultRevisionNumber(response.newRevisionNumber);
+ } else if (response.status === 1) {
+ throw new Error('Vault merge required. Please login via the web app to merge the multiple pending updates to your vault.');
+ } else {
+ throw new Error('Failed to upload vault to server');
+ }
+ } catch (error) {
+ console.error('Error uploading vault:', error);
+ throw error;
+ }
+ };
+
+ /**
+ * Handle successful upgrade completion.
+ */
+ const handleUpgradeSuccess = async () : Promise => {
+ try {
+ // Sync vault to ensure we have the latest data
+ await syncVault({
+ /**
+ * Handle the status update.
+ */
+ onStatus: (message) => setUpgradeStatus(message),
+ /**
+ * Handle successful vault sync and navigate to credentials.
+ */
+ onSuccess: () => {
+ // Navigate to credentials index
+ router.replace('/(tabs)/credentials');
+ },
+ /**
+ * Handle sync error and still navigate to credentials.
+ */
+ onError: (error) => {
+ console.error('Sync error after upgrade:', error);
+ // Still navigate to credentials even if sync fails
+ router.replace('/(tabs)/credentials');
+ }
+ });
+ } catch (error) {
+ console.error('Error during post-upgrade sync:', error);
+ // Navigate to credentials even if sync fails
+ router.replace('/(tabs)/credentials');
+ }
+ };
+
+ /**
+ * Handle the logout.
+ */
+ const handleLogout = async () : Promise => {
+ /*
+ * Clear any stored tokens or session data
+ * This will be handled by the auth context
+ */
+ await webApi.logout();
+ router.replace('/login');
+ };
+
+ const styles = StyleSheet.create({
+ appName: {
+ color: colors.text,
+ fontSize: 32,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ },
+ avatarContainer: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ justifyContent: 'center',
+ marginBottom: 16,
+ },
+ button: {
+ alignItems: 'center',
+ backgroundColor: colors.primary,
+ borderRadius: 8,
+ height: 50,
+ justifyContent: 'center',
+ marginBottom: 16,
+ width: '100%',
+ },
+ buttonText: {
+ color: colors.primarySurfaceText,
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ container: {
+ flex: 1,
+ },
+ content: {
+ backgroundColor: colors.accentBackground,
+ borderRadius: 10,
+ padding: 20,
+ width: '100%',
+ },
+ faceIdButton: {
+ alignItems: 'center',
+ height: 50,
+ justifyContent: 'center',
+ width: '100%',
+ },
+ faceIdButtonText: {
+ color: colors.primary,
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ gradientContainer: {
+ height: Dimensions.get('window').height * 0.4,
+ left: 0,
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ },
+ headerSection: {
+ paddingBottom: 24,
+ paddingHorizontal: 16,
+ paddingTop: 24,
+ },
+ input: {
+ backgroundColor: colors.background,
+ borderRadius: 8,
+ color: colors.text,
+ flex: 1,
+ fontSize: 16,
+ height: 50,
+ paddingHorizontal: 16,
+ },
+ inputContainer: {
+ alignItems: 'center',
+ backgroundColor: colors.background,
+ borderColor: colors.accentBorder,
+ borderRadius: 8,
+ borderWidth: 1,
+ flexDirection: 'row',
+ marginBottom: 16,
+ },
+ inputIcon: {
+ padding: 12,
+ },
+ keyboardAvoidingView: {
+ flex: 1,
+ },
+ loadingContainer: {
+ alignItems: 'center',
+ flex: 1,
+ justifyContent: 'center',
+ },
+ logoContainer: {
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ logoutButton: {
+ alignSelf: 'center',
+ justifyContent: 'center',
+ marginTop: 16,
+ },
+ logoutButtonText: {
+ color: colors.red,
+ fontSize: 16,
+ },
+ mainContent: {
+ flex: 1,
+ justifyContent: 'center',
+ paddingBottom: 40,
+ paddingHorizontal: 20,
+ },
+ currentVersionValue: {
+ color: colors.primary,
+ },
+ latestVersionValue: {
+ color: '#10B981',
+ },
+ scrollContent: {
+ flexGrow: 1,
+ },
+ subtitle: {
+ color: colors.text,
+ fontSize: 16,
+ marginBottom: 24,
+ opacity: 0.7,
+ textAlign: 'center',
+ },
+ username: {
+ color: colors.text,
+ fontSize: 18,
+ opacity: 0.8,
+ textAlign: 'center',
+ },
+ versionContainer: {
+ backgroundColor: colors.background,
+ borderRadius: 8,
+ marginBottom: 16,
+ padding: 16,
+ },
+ versionLabel: {
+ color: colors.text,
+ fontSize: 14,
+ fontWeight: '500',
+ opacity: 0.7,
+ },
+ versionRow: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 8,
+ },
+ versionTitle: {
+ color: colors.text,
+ fontSize: 18,
+ fontWeight: '600',
+ marginBottom: 12,
+ textAlign: 'center',
+ },
+ versionValue: {
+ color: colors.text,
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+ });
+
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ Upgrade Vault
+
+
+
+
+
+ {username}
+
+ AliasVault has updated and your vault needs to be upgraded.
+
+
+ Version Information
+
+ Your vault:
+
+ {currentVersion?.releaseVersion ?? '...'}
+
+
+
+ New version:
+
+ {latestVersion?.releaseVersion ?? '...'}
+
+
+
+
+
+
+ {isLoading ? 'Upgrading...' : 'Upgrade'}
+
+
+
+
+ Logout
+
+
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/mobile-app/context/DbContext.tsx b/apps/mobile-app/context/DbContext.tsx
index 9793573c9..e1ac835b3 100644
--- a/apps/mobile-app/context/DbContext.tsx
+++ b/apps/mobile-app/context/DbContext.tsx
@@ -13,6 +13,7 @@ type DbContextType = {
storeEncryptionKey: (derivedKey: string) => Promise;
storeEncryptionKeyDerivationParams: (keyDerivationParams: EncryptionKeyDerivationParams) => Promise;
initializeDatabase: (vaultResponse: VaultResponse) => Promise;
+ hasPendingMigrations: () => Promise;
clearDatabase: () => void;
getVaultMetadata: () => Promise;
testDatabaseConnection: (derivedKey: string) => Promise;
@@ -97,6 +98,16 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
setDbAvailable(true);
}, [sqliteClient, unlockVault]);
+ /**
+ * Check if there are any pending migrations.
+ */
+ const hasPendingMigrations = useCallback(async () => {
+ const currentVersion = await sqliteClient.getDatabaseVersion();
+ const latestVersion = await sqliteClient.getLatestDatabaseVersion();
+
+ return currentVersion.revision < latestVersion.revision;
+ }, [sqliteClient]);
+
const checkStoredVault = useCallback(async () => {
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
@@ -166,7 +177,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
// Try to get the database version as a simple test query
const version = await sqliteClient.getDatabaseVersion();
- if (version && version.length > 0) {
+ if (version && version.version && version.version.length > 0) {
return true;
}
@@ -182,13 +193,14 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
dbInitialized,
dbAvailable,
initializeDatabase,
+ hasPendingMigrations,
clearDatabase,
getVaultMetadata,
testDatabaseConnection,
unlockVault,
storeEncryptionKey,
storeEncryptionKeyDerivationParams,
- }), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, testDatabaseConnection, unlockVault, storeEncryptionKey, storeEncryptionKeyDerivationParams]);
+ }), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, hasPendingMigrations, clearDatabase, getVaultMetadata, testDatabaseConnection, unlockVault, storeEncryptionKey, storeEncryptionKeyDerivationParams]);
return (
diff --git a/apps/mobile-app/hooks/useVaultMutate.ts b/apps/mobile-app/hooks/useVaultMutate.ts
index c1df67f72..b18a1ae93 100644
--- a/apps/mobile-app/hooks/useVaultMutate.ts
+++ b/apps/mobile-app/hooks/useVaultMutate.ts
@@ -85,7 +85,7 @@ export function useVaultMutate() : {
client: '', // Empty on purpose, API will not use this for vault updates
updatedAt: new Date().toISOString(),
username: username,
- version: await dbContext.sqliteClient!.getDatabaseVersion() ?? '0.0.0'
+ version: (await dbContext.sqliteClient!.getDatabaseVersion())?.version ?? '0.0.0'
};
}, [dbContext, authContext]);
diff --git a/apps/mobile-app/hooks/useVaultSync.ts b/apps/mobile-app/hooks/useVaultSync.ts
index d364fd17b..beb5b9dc8 100644
--- a/apps/mobile-app/hooks/useVaultSync.ts
+++ b/apps/mobile-app/hooks/useVaultSync.ts
@@ -1,3 +1,4 @@
+import { useRouter } from 'expo-router';
import { useCallback } from 'react';
import { AppInfo } from '@/utils/AppInfo';
@@ -37,6 +38,7 @@ type VaultSyncOptions = {
onError?: (error: string) => void;
onStatus?: (message: string) => void;
onOffline?: () => void;
+ onUpgradeRequired?: () => void;
}
/**
@@ -50,7 +52,7 @@ export const useVaultSync = () : {
const webApi = useWebApi();
const syncVault = useCallback(async (options: VaultSyncOptions = {}) => {
- const { initialSync = false, onSuccess, onError, onStatus, onOffline } = options;
+ const { initialSync = false, onSuccess, onError, onStatus, onOffline, onUpgradeRequired } = options;
// For the initial sync, we add an artifical delay to various steps which makes it feel more fluid.
const enableDelay = initialSync;
@@ -112,6 +114,15 @@ export const useVaultSync = () : {
try {
await dbContext.initializeDatabase(vaultResponseJson as VaultResponse);
+
+ // Check if the vault is up to date, if not, redirect to the upgrade page.
+ console.log('Checking for pending migrations');
+ if (await dbContext.hasPendingMigrations()) {
+ console.log('Pending migrations, redirecting to upgrade page');
+ onUpgradeRequired?.();
+ return false;
+ }
+
onSuccess?.(true);
return true;
} catch {
@@ -120,6 +131,14 @@ export const useVaultSync = () : {
}
}
+ // Check if the vault is up to date, if not, redirect to the upgrade page.
+ console.log('Checking for pending migrations');
+ if (await dbContext.hasPendingMigrations()) {
+ console.log('Pending migrations, redirecting to upgrade page');
+ onUpgradeRequired?.();
+ return false;
+ }
+
await withMinimumDelay(() => Promise.resolve(onSuccess?.(false)), 300, enableDelay);
return false;
} catch (err) {
diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
index 608a5429f..ccd00dac3 100644
--- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
+++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
@@ -45,6 +45,10 @@
[vaultManager executeUpdate:query params:params resolver:resolve rejecter:reject];
}
+- (void)executeRaw:(NSString *)query resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
+ [vaultManager executeRaw:query resolver:resolve rejecter:reject];
+}
+
- (void)beginTransaction:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager beginTransaction:resolve rejecter:reject];
}
diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
index 7057d83d9..a661d4104 100644
--- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
+++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
@@ -163,6 +163,19 @@ public class VaultManager: NSObject {
}
}
+ @objc
+ func executeRaw(_ query: String,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ do {
+ // Execute the raw query through the vault store
+ try vaultStore.executeRaw(query)
+ resolve(nil)
+ } catch {
+ reject("RAW_ERROR", "Failed to execute raw query: \(error.localizedDescription)", error)
+ }
+ }
+
@objc
func clearVault() {
do {
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift
index bc7d19f4c..029481822 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift
@@ -74,6 +74,30 @@ extension VaultStore {
return dbConnection.changes
}
+ /// Execute a raw SQL command on the database without parameters (for DDL operations like CREATE TABLE).
+ public func executeRaw(_ query: String) throws {
+ guard let dbConnection = self.dbConnection else {
+ throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"])
+ }
+
+ // Split the query by semicolons to handle multiple statements
+ let statements = query.components(separatedBy: ";")
+
+ for statement in statements {
+ let trimmedStatement = statement.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Skip empty statements and transaction control statements (handled externally)
+ if trimmedStatement.isEmpty ||
+ trimmedStatement.uppercased().hasPrefix("BEGIN TRANSACTION") ||
+ trimmedStatement.uppercased().hasPrefix("COMMIT") ||
+ trimmedStatement.uppercased().hasPrefix("ROLLBACK") {
+ continue
+ }
+
+ try dbConnection.execute(trimmedStatement)
+ }
+ }
+
/// Begin a transaction on the database. This is required for all database operations that modify the database.
public func beginTransaction() throws {
guard let dbConnection = self.dbConnection else {
diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts
index 4a7745b51..bece5e940 100644
--- a/apps/mobile-app/specs/NativeVaultManager.ts
+++ b/apps/mobile-app/specs/NativeVaultManager.ts
@@ -27,6 +27,7 @@ export interface Spec extends TurboModule {
// SQL operations
executeQuery(query: string, params: (string | number | null)[]): Promise;
executeUpdate(query: string, params:(string | number | null)[]): Promise;
+ executeRaw(query: string): Promise;
beginTransaction(): Promise;
commitTransaction(): Promise;
rollbackTransaction(): Promise;
diff --git a/apps/mobile-app/utils/SqliteClient.tsx b/apps/mobile-app/utils/SqliteClient.tsx
index fa1320a77..3e3113a4d 100644
--- a/apps/mobile-app/utils/SqliteClient.tsx
+++ b/apps/mobile-app/utils/SqliteClient.tsx
@@ -1,5 +1,6 @@
import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/shared/models/metadata';
import type { Credential, EncryptionKey, PasswordSettings, TotpCode } from '@/utils/dist/shared/models/vault';
+import { VaultSqlGenerator, VaultVersion } from '@/utils/dist/shared/vault-sql';
import NativeVaultManager from '@/specs/NativeVaultManager';
@@ -560,8 +561,10 @@ class SqliteClient {
* Returns the semantic version (e.g., "1.4.1") from the latest migration.
* Returns null if no migrations are found.
*/
- public async getDatabaseVersion(): Promise {
+ public async getDatabaseVersion(): Promise {
try {
+ let currentVersion = '';
+
// Query the migrations history table for the latest migration
const results = await this.executeQuery<{ MigrationId: string }>(`
SELECT MigrationId
@@ -570,7 +573,7 @@ class SqliteClient {
LIMIT 1`);
if (results.length === 0) {
- return null;
+ throw new Error('No migrations found');
}
// Extract version using regex - matches patterns like "20240917191243_1.4.1-RenameAttachmentsPlural"
@@ -579,16 +582,34 @@ class SqliteClient {
const versionMatch = versionRegex.exec(migrationId);
if (versionMatch?.[1]) {
- return versionMatch[1];
+ currentVersion = versionMatch[1];
}
- return null;
+ // Get all available vault versions to get the revision number of the current version.
+ const vaultSqlGenerator = new VaultSqlGenerator();
+ const allVersions = vaultSqlGenerator.getAllVersions();
+ const currentVersionRevision = allVersions.find(v => v.version === currentVersion);
+
+ if (!currentVersionRevision) {
+ throw new Error(`Current version ${currentVersion} not found in available vault versions.`);
+ }
+
+ return currentVersionRevision;
} catch (error) {
console.error('Error getting database version:', error);
throw error;
}
}
+ /**
+ * Returns the version info of the latest available vault migration.
+ */
+ public async getLatestDatabaseVersion(): Promise {
+ const vaultSqlGenerator = new VaultSqlGenerator();
+ const allVersions = vaultSqlGenerator.getAllVersions();
+ return allVersions[allVersions.length - 1];
+ }
+
/**
* Get TOTP codes for a credential
* @param credentialId - The ID of the credential to get TOTP codes for