diff --git a/mobile-app/app/(tabs)/credentials/add-edit.tsx b/mobile-app/app/(tabs)/credentials/add-edit.tsx index fec11f08e..68c157bba 100644 --- a/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -1,4 +1,4 @@ -import { StyleSheet, View, TextInput, TouchableOpacity, ScrollView, Platform, Animated } from 'react-native'; +import { StyleSheet, View, TextInput, TouchableOpacity, ScrollView, Platform, Animated, ActivityIndicator } from 'react-native'; import { useState, useEffect, useRef } from 'react'; import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { ThemedText } from '@/components/ThemedText'; @@ -6,19 +6,29 @@ import { ThemedView } from '@/components/ThemedView'; import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView'; import { useColors } from '@/hooks/useColorScheme'; import { useDb } from '@/context/DbContext'; +import { useWebApi } from '@/context/WebApiContext'; +import { useVaultSync } from '@/hooks/useVaultSync'; import { Credential } from '@/utils/types/Credential'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import Toast from 'react-native-toast-message'; import { Gender } from "@/utils/generators/Identity/types/Gender"; import emitter from '@/utils/EventEmitter'; - +import NativeVaultManager from '@/specs/NativeVaultManager'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useAuth } from '@/context/AuthContext'; type CredentialMode = 'random' | 'manual'; +interface VaultPostResponse { + status: number; + newRevisionNumber: number; +} + export default function AddEditCredentialScreen() { const { id, serviceUrl } = useLocalSearchParams<{ id: string, serviceUrl?: string }>(); const router = useRouter(); const colors = useColors(); const dbContext = useDb(); + const authContext = useAuth(); const [mode, setMode] = useState('random'); const [isLoading, setIsLoading] = useState(false); const navigation = useNavigation(); @@ -39,6 +49,9 @@ export default function AddEditCredentialScreen() { Email: "" }, }); + const [syncStatus, setSyncStatus] = useState(''); + const webApi = useWebApi(); + const { syncVault } = useVaultSync(); function extractServiceNameFromUrl(url: string): string { try { @@ -197,38 +210,26 @@ export default function AddEditCredentialScreen() { const handleSave = async () => { try { setIsLoading(true); + setSyncStatus('Checking for vault updates...'); - let credentialToSave = credential as Credential; - - // If mode is random, generate random values for all fields before saving. - // TODO: replace this with actual identity generator logic. - if (mode === 'random') { - console.log('Generating random values'); - credentialToSave = generateRandomValues(); - } - - if (isEditMode) { - // Update existing credential - await dbContext.sqliteClient!.updateCredentialById(credentialToSave); - Toast.show({ - type: 'success', - text1: 'Credential updated successfully', - position: 'bottom' - }); - } else { - // Create new credential - await dbContext.sqliteClient!.createCredential(credentialToSave); - Toast.show({ - type: 'success', - text1: 'Credential created successfully', - position: 'bottom' - }); - } - - // Emit an event to notify list and detail views to refresh - emitter.emit('credentialChanged', credentialToSave.Id); - - router.back(); + // First check if there are any vault updates + await syncVault({ + onStatus: (message) => setSyncStatus(message), + onSuccess: async (hasNewVault) => { + if (hasNewVault) { + console.log('Vault was changed, but has now been reloaded so we can continue with the save.'); + } + await handleSaveInner(); + }, + onError: (error) => { + Toast.show({ + type: 'error', + text1: 'Failed to sync vault', + text2: error, + position: 'bottom' + }); + } + }); } catch (error) { console.error('Error saving credential:', error); Toast.show({ @@ -239,6 +240,96 @@ export default function AddEditCredentialScreen() { }); } finally { setIsLoading(false); + setSyncStatus(''); + } + }; + + const handleSaveInner = async () => { + let credentialToSave = credential as Credential; + + // If mode is random, generate random values for all fields before saving. + if (mode === 'random') { + console.log('Generating random values'); + credentialToSave = generateRandomValues(); + } + + setSyncStatus('Saving changes to vault...'); + + if (isEditMode) { + // Update existing credential + await dbContext.sqliteClient!.updateCredentialById(credentialToSave); + } else { + // Create new credential + await dbContext.sqliteClient!.createCredential(credentialToSave); + } + + // 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'); + } + + setSyncStatus('Uploading vault to server...'); + + // Get email addresses from credentials + const credentials = await dbContext.sqliteClient!.getAllCredentials(); + const emailAddresses = credentials + .filter(cred => cred.Alias?.Email != null) + .map(cred => cred.Alias!.Email!) + .filter((email, index, self) => self.indexOf(email) === index); + + // Get username from the auth context + const username = authContext.username; + 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: emailAddresses, + 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 + // TODO: can client be null? double check this. + 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' + }; + console.log('New vault current revision number:', currentRevision); + + console.log('Trying to upload vault to server...'); + + // Upload to server + const response = await webApi.post('Vault', newVault); + + console.log('Vault upload response:', response); + + // Check if response is successful + if (response.status === 0) { + await NativeVaultManager.setCurrentVaultRevisionNumber(response.newRevisionNumber); + + Toast.show({ + type: 'success', + text1: isEditMode ? 'Credential updated successfully' : 'Credential created successfully', + position: 'bottom' + }); + + // Emit an event to notify list and detail views to refresh + emitter.emit('credentialChanged', credentialToSave.Id); + + router.back(); + } 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'); } }; @@ -327,11 +418,36 @@ export default function AddEditCredentialScreen() { height: 100, textAlignVertical: 'top', }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + syncStatus: { + marginTop: 16, + textAlign: 'center', + color: '#fff', + fontSize: 16, + }, }); return ( + {(isLoading || syncStatus) && ( + + + {syncStatus && ( + {syncStatus} + )} + + )} {!isEditMode && ( diff --git a/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.h b/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.h index eeb987534..ee08fc567 100644 --- a/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.h +++ b/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.h @@ -12,6 +12,10 @@ NS_ASSUME_NONNULL_BEGIN @interface RCTNativeVaultManager : NSObject +- (void)getEncryptedDatabase:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +- (void)getCurrentVaultRevisionNumber:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +- (void)setCurrentVaultRevisionNumber:(double)revisionNumber resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; + @end NS_ASSUME_NONNULL_END diff --git a/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm index 2bb9f7a8e..127076cc4 100644 --- a/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm +++ b/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm @@ -116,4 +116,16 @@ [vaultManager clearVault]; } +- (void)getEncryptedDatabase:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager getEncryptedDatabase:resolve rejecter:reject]; +} + +- (void)getCurrentVaultRevisionNumber:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager getCurrentVaultRevisionNumber:resolve rejecter:reject]; +} + +- (void)setCurrentVaultRevisionNumber:(double)revisionNumber resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager setCurrentVaultRevisionNumber:revisionNumber resolver:resolve rejecter:reject]; +} + @end diff --git a/mobile-app/ios/NativeVaultManager/VaultManager.swift b/mobile-app/ios/NativeVaultManager/VaultManager.swift index 03b773337..5c3cb457a 100644 --- a/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -168,6 +168,31 @@ public class VaultManager: NSObject { } } + @objc + func getEncryptedDatabase(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + if let encryptedDb = vaultStore.getEncryptedDatabase() { + resolve(encryptedDb) + } else { + reject("DB_ERROR", "Failed to get encrypted database", nil) + } + } + + @objc + func getCurrentVaultRevisionNumber(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + let revisionNumber = vaultStore.getCurrentVaultRevisionNumber() + resolve(revisionNumber) + } + + @objc + func setCurrentVaultRevisionNumber(_ revisionNumber: Int, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + vaultStore.setCurrentVaultRevisionNumber(revisionNumber) + resolve(nil) + } + @objc func isVaultInitialized(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { diff --git a/mobile-app/ios/VaultModels/VaultMetadata.swift b/mobile-app/ios/VaultModels/VaultMetadata.swift new file mode 100644 index 000000000..270b73224 --- /dev/null +++ b/mobile-app/ios/VaultModels/VaultMetadata.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct VaultMetadata: Codable { + public var publicEmailDomains: [String]? + public var privateEmailDomains: [String]? + public var vaultRevisionNumber: Int + + public init(publicEmailDomains: [String]? = nil, privateEmailDomains: [String]? = nil, vaultRevisionNumber: Int) { + self.publicEmailDomains = publicEmailDomains + self.privateEmailDomains = privateEmailDomains + self.vaultRevisionNumber = vaultRevisionNumber + } +} diff --git a/mobile-app/ios/VaultStoreKit/VaultStoreKit.swift b/mobile-app/ios/VaultStoreKit/VaultStoreKit.swift index 0ffb22357..18dac5015 100644 --- a/mobile-app/ios/VaultStoreKit/VaultStoreKit.swift +++ b/mobile-app/ios/VaultStoreKit/VaultStoreKit.swift @@ -248,7 +248,7 @@ public class VaultStore { } // Get the encrypted database as a base64 encoded string - private func getEncryptedDatabase() -> String? { + public func getEncryptedDatabase() -> String? { do { // Return the base64 encoded string return try String(contentsOf: getEncryptedDbPath(), encoding: .utf8) @@ -257,11 +257,51 @@ public class VaultStore { } } + // Get the current vault revision number + public func getCurrentVaultRevisionNumber() -> Int { + guard let metadata = getVaultMetadataObject() else { + return 0 + } + return metadata.vaultRevisionNumber + } + + // Set the current vault revision number + public func setCurrentVaultRevisionNumber(_ revisionNumber: Int) { + var metadata: VaultMetadata + + if let existingMetadata = getVaultMetadataObject() { + metadata = existingMetadata + } else { + metadata = VaultMetadata( + publicEmailDomains: [], + privateEmailDomains: [], + vaultRevisionNumber: revisionNumber + ) + } + + metadata.vaultRevisionNumber = revisionNumber + if let data = try? JSONEncoder().encode(metadata), + let jsonString = String(data: data, encoding: .utf8) { + UserDefaults.standard.set(jsonString, forKey: vaultMetadataKey) + UserDefaults.standard.synchronize() + } + } + // Get the vault metadata from UserDefaults public func getVaultMetadata() -> String? { return UserDefaults.standard.string(forKey: vaultMetadataKey) } + // Helper to decode the JSON metadata into VaultMetadata object + private func getVaultMetadataObject() -> VaultMetadata? { + guard let jsonString = getVaultMetadata(), + let data = jsonString.data(using: .utf8), + let metadata = try? JSONDecoder().decode(VaultMetadata.self, from: data) else { + return nil + } + return metadata + } + /** * Initialize the database. * diff --git a/mobile-app/ios/aliasvault.xcodeproj/project.pbxproj b/mobile-app/ios/aliasvault.xcodeproj/project.pbxproj index d01cf4665..c35166803 100644 --- a/mobile-app/ios/aliasvault.xcodeproj/project.pbxproj +++ b/mobile-app/ios/aliasvault.xcodeproj/project.pbxproj @@ -208,7 +208,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */ = { + CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -218,73 +218,12 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = VaultStoreKit; - sourceTree = ""; - }; - CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = VaultStoreKitTests; - sourceTree = ""; - }; - CEE4816B2DBE8AC800F4A367 /* VaultUI */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = VaultUI; - sourceTree = ""; - }; - CEE4817A2DBE8AC800F4A367 /* VaultUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = VaultUITests; - sourceTree = ""; - }; - CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = VaultModels; - sourceTree = ""; - }; - CEE909812DA548C7008D568F /* Autofill */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */, - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = Autofill; - sourceTree = ""; - }; + CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKit; sourceTree = ""; }; + CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = ""; }; + CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = ""; }; + CEE4817A2DBE8AC800F4A367 /* VaultUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUITests; sourceTree = ""; }; + CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = ""; }; + CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1270,10 +1209,7 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -1328,10 +1264,7 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/mobile-app/native/NativeVaultManager.ts b/mobile-app/native/NativeVaultManager.ts new file mode 100644 index 000000000..0c6f7cae4 --- /dev/null +++ b/mobile-app/native/NativeVaultManager.ts @@ -0,0 +1,37 @@ +import { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + // Basic credential operations + addCredential(username: string, password: string, service: string): Promise; + getCredentials(): Promise<{ credentials: Array<{ username: string; password: string; service: string }> }>; + clearVault(): Promise; + + // Vault state management + isVaultInitialized(): Promise; + isVaultUnlocked(): Promise; + getVaultMetadata(): Promise; + unlockVault(): Promise; + + // Database operations + storeDatabase(base64EncryptedDb: string, metadata: string): Promise; + setAuthMethods(authMethods: string[]): Promise; + storeEncryptionKey(base64EncryptionKey: string): Promise; + getEncryptedDatabase(): Promise; + getCurrentVaultRevisionNumber(): Promise; + setCurrentVaultRevisionNumber(revisionNumber: number): Promise; + + // SQL operations + executeQuery(query: string, params: any[]): Promise; + executeUpdate(query: string, params: any[]): Promise; + beginTransaction(): Promise; + commitTransaction(): Promise; + rollbackTransaction(): Promise; + + // Auto-lock settings + setAutoLockTimeout(timeout: number): Promise; + getAutoLockTimeout(): Promise; + getAuthMethods(): Promise; +} + +export default TurboModuleRegistry.getEnforcing('NativeVaultManager'); \ No newline at end of file diff --git a/mobile-app/specs/NativeVaultManager.ts b/mobile-app/specs/NativeVaultManager.ts index f7b00e988..0e905e25d 100644 --- a/mobile-app/specs/NativeVaultManager.ts +++ b/mobile-app/specs/NativeVaultManager.ts @@ -17,6 +17,9 @@ export interface Spec extends TurboModule { storeDatabase(base64EncryptedDb: string, metadata: string): Promise; setAuthMethods(authMethods: string[]): Promise; storeEncryptionKey(base64EncryptionKey: string): Promise; + getEncryptedDatabase(): Promise; + getCurrentVaultRevisionNumber(): Promise; + setCurrentVaultRevisionNumber(revisionNumber: number): Promise; // SQL operations executeQuery(query: string, params: any[]): Promise;