From b4c460386841dcfcd291cc438899ccbf22952889 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 23 Jun 2025 22:11:48 +0200 Subject: [PATCH] Add onUpgradeRequired and executeRaw logic to iOS (#957) --- .../app/(tabs)/credentials/index.tsx | 8 +- apps/mobile-app/app/_layout.tsx | 10 +- apps/mobile-app/app/login.tsx | 23 +- apps/mobile-app/app/reinitialize.tsx | 8 +- apps/mobile-app/app/upgrade.tsx | 472 ++++++++++++++++++ apps/mobile-app/context/DbContext.tsx | 16 +- apps/mobile-app/hooks/useVaultMutate.ts | 2 +- apps/mobile-app/hooks/useVaultSync.ts | 21 +- .../RCTNativeVaultManager.mm | 4 + .../ios/NativeVaultManager/VaultManager.swift | 13 + .../ios/VaultStoreKit/VaultStore+Query.swift | 24 + apps/mobile-app/specs/NativeVaultManager.ts | 1 + apps/mobile-app/utils/SqliteClient.tsx | 29 +- 13 files changed, 619 insertions(+), 12 deletions(-) create mode 100644 apps/mobile-app/app/upgrade.tsx 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