From 5185dfa41df3d747be6fd86b2d44a3cc2cf4570a Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 25 Oct 2025 17:04:17 +0200 Subject: [PATCH] Refactor CredentialIdentityStore scaffolding (#520) --- .../CredentialIdentityStore.kt | 23 +- .../PasskeyAuthenticationActivity.kt | 1 - .../credentialprovider/PasskeyFormFragment.kt | 14 +- .../PasskeyRegistrationActivity.kt | 1 - .../nativevaultmanager/NativeVaultManager.kt | 12 +- .../app/{vaultstore => utils}/AppInfo.kt | 2 +- .../aliasvault/app/vaultstore/VaultPasskey.kt | 652 ++++++++++++++++++ .../aliasvault/app/vaultstore/VaultStore.kt | 100 +++ .../app/vaultstore/VaultStorePasskey.kt | 514 -------------- .../aliasvault/app/vaultstore/VaultSync.kt | 2 + .../{ => utils}/VersionComparison.kt | 4 +- 11 files changed, 770 insertions(+), 555 deletions(-) rename apps/mobile-app/android/app/src/main/java/net/aliasvault/app/{vaultstore => utils}/AppInfo.kt (96%) create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultPasskey.kt delete mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStorePasskey.kt rename apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/{ => utils}/VersionComparison.kt (97%) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/CredentialIdentityStore.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/CredentialIdentityStore.kt index 2900ba59f..bd975d55d 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/CredentialIdentityStore.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/CredentialIdentityStore.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import net.aliasvault.app.utils.Helpers -import net.aliasvault.app.vaultstore.getPasskeysForCredential import net.aliasvault.app.vaultstore.models.Credential import net.aliasvault.app.vaultstore.models.Passkey import net.aliasvault.app.vaultstore.passkey.PasskeyHelper @@ -81,27 +80,19 @@ class CredentialIdentityStore private constructor(context: Context) { /** * Save credential identities from a list of credentials. * This extracts passkey metadata and stores it in SharedPreferences. - * @param credentials List of credentials with passkeys * @param vaultStore VaultStore instance to query passkeys - * @param db Database connection */ - fun saveCredentialIdentities( - credentials: List, - vaultStore: net.aliasvault.app.vaultstore.VaultStore, - db: android.database.sqlite.SQLiteDatabase, - ) { + fun saveCredentialIdentities(vaultStore: net.aliasvault.app.vaultstore.VaultStore) { try { val passkeyIdentities = mutableListOf() - // Extract passkey identities from credentials - credentials.forEach { credential -> - // Get passkeys for this credential - val passkeys = vaultStore.getPasskeysForCredential(credential.id, db) + // Get all passkeys with their credentials in a single efficient query + // This replaces the N+1 query pattern that was calling getPasskeysForCredential() for each credential + val passkeysWithCredentials = vaultStore.getAllPasskeysWithCredentials() - passkeys.forEach { passkey -> - if (!passkey.isDeleted) { - passkeyIdentities.add(createPasskeyIdentity(passkey, credential)) - } + passkeysWithCredentials.forEach { (passkey, credential) -> + if (!passkey.isDeleted) { + passkeyIdentities.add(createPasskeyIdentity(passkey, credential)) } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyAuthenticationActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyAuthenticationActivity.kt index 109ead994..c638c3f61 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyAuthenticationActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyAuthenticationActivity.kt @@ -11,7 +11,6 @@ import androidx.fragment.app.FragmentActivity import net.aliasvault.app.R import net.aliasvault.app.utils.Helpers import net.aliasvault.app.vaultstore.VaultStore -import net.aliasvault.app.vaultstore.getPasskeyById import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreOperationCallback import net.aliasvault.app.vaultstore.passkey.PasskeyAuthenticator diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyFormFragment.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyFormFragment.kt index 01f5cf45e..36f1b8ca0 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyFormFragment.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyFormFragment.kt @@ -27,11 +27,9 @@ import net.aliasvault.app.exceptions.VaultOperationException import net.aliasvault.app.utils.Helpers import net.aliasvault.app.vaultstore.PasskeyWithCredentialInfo import net.aliasvault.app.vaultstore.VaultStore -import net.aliasvault.app.vaultstore.createCredentialWithPasskey import net.aliasvault.app.vaultstore.models.Passkey import net.aliasvault.app.vaultstore.passkey.PasskeyAuthenticator import net.aliasvault.app.vaultstore.passkey.PasskeyHelper -import net.aliasvault.app.vaultstore.replacePasskey import net.aliasvault.app.webapi.WebApiService import org.json.JSONObject import java.util.Date @@ -293,7 +291,7 @@ class PasskeyFormFragment : Fragment() { rpId = viewModel.rpId, userName = viewModel.userName, displayName = displayName, - passkey = passkey, + passkeyObj = passkey, logo = logo, ) @@ -634,13 +632,9 @@ class PasskeyFormFragment : Fragment() { */ private fun updateCredentialIdentityCache() { try { - val credentials = vaultStore.getAllCredentials() - val db = vaultStore.database - if (db != null) { - val identityStore = CredentialIdentityStore.getInstance(requireContext()) - identityStore.saveCredentialIdentities(credentials, vaultStore, db) - Log.d(TAG, "Updated credential identity cache") - } + val identityStore = CredentialIdentityStore.getInstance(requireContext()) + identityStore.saveCredentialIdentities(vaultStore) + Log.d(TAG, "Updated credential identity cache") } catch (e: Exception) { Log.w(TAG, "Failed to update credential identity cache", e) // Non-critical error, don't throw diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt index db1a239c7..abc4e45bd 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt @@ -12,7 +12,6 @@ import net.aliasvault.app.R import net.aliasvault.app.credentialprovider.models.PasskeyRegistrationViewModel import net.aliasvault.app.utils.Helpers import net.aliasvault.app.vaultstore.VaultStore -import net.aliasvault.app.vaultstore.getPasskeysWithCredentialInfo import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreOperationCallback import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index b4b3f02e2..dc833606f 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -1022,21 +1022,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : // Execute registration in background thread Thread { try { - // Get all credentials from the vault - val credentials = vaultStore.getAllCredentials() - - // Get database connection - val db = vaultStore.database - if (db == null) { - Log.w(TAG, "Database not available - vault may be locked") - return@Thread - } - // Save credential identities to the identity store val identityStore = net.aliasvault.app.credentialprovider.CredentialIdentityStore.getInstance( reactApplicationContext, ) - identityStore.saveCredentialIdentities(credentials, vaultStore, db) + identityStore.saveCredentialIdentities(vaultStore) } catch (e: Exception) { Log.e(TAG, "Error registering credential identities in background", e) } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/AppInfo.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/AppInfo.kt similarity index 96% rename from apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/AppInfo.kt rename to apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/AppInfo.kt index e404f9d6a..054b36937 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/AppInfo.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/AppInfo.kt @@ -1,4 +1,4 @@ -package net.aliasvault.app.vaultstore +package net.aliasvault.app.utils /** * Application configuration constants. diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultPasskey.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultPasskey.kt new file mode 100644 index 000000000..09194dae7 --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultPasskey.kt @@ -0,0 +1,652 @@ +package net.aliasvault.app.vaultstore + +import android.content.ContentValues +import android.database.Cursor +import android.util.Log +import net.aliasvault.app.utils.DateHelpers +import net.aliasvault.app.vaultstore.models.Alias +import net.aliasvault.app.vaultstore.models.Credential +import net.aliasvault.app.vaultstore.models.Passkey +import net.aliasvault.app.vaultstore.models.Service +import net.aliasvault.app.vaultstore.passkey.PasskeyHelper +import java.util.Calendar +import java.util.Date +import java.util.TimeZone +import java.util.UUID + +/** + * Handles passkey operations for the vault. + * This class uses composition to organize passkey-specific functionality. + * + * This is a Kotlin port of the iOS Swift implementation: + * - Reference: apps/mobile-app/ios/VaultStoreKit/VaultStore+Passkey.swift + * + * IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be + * reflected in all ports. Method names, parameters, and behavior should remain consistent. + */ +class VaultPasskey( + private val database: VaultDatabase, +) { + companion object { + private const val TAG = "VaultPasskey" + + /** + * Minimum date definition for default values. + */ + private val MIN_DATE: Date = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + set(Calendar.YEAR, 1) + set(Calendar.MONTH, Calendar.JANUARY) + set(Calendar.DAY_OF_MONTH, 1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.time + } + + // region Passkey Queries + + /** + * Get a passkey by its credential ID (the WebAuthn credential ID, not the parent Credential UUID). + */ + fun getPasskeyByCredentialId(credentialId: ByteArray): Passkey? { + val db = database.dbConnection ?: return null + + // Convert credentialId bytes to UUID string for lookup + val credentialIdString = try { + PasskeyHelper.bytesToGuid(credentialId) + } catch (e: Exception) { + Log.e(TAG, "Failed to convert credentialId bytes to UUID string", e) + return null + } + + val query = """ + SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, + DisplayName, CreatedAt, UpdatedAt, IsDeleted + FROM Passkeys + WHERE Id = ? AND IsDeleted = 0 + LIMIT 1 + """.trimIndent() + + val cursor = db.rawQuery(query, arrayOf(credentialIdString.uppercase())) + cursor.use { + if (it.moveToFirst()) { + return parsePasskeyRow(it) + } + } + + return null + } + + /** + * Get all passkeys for a credential. + */ + fun getPasskeysForCredential(credentialId: UUID): List { + val db = database.dbConnection ?: return emptyList() + + val query = """ + SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, + DisplayName, CreatedAt, UpdatedAt, IsDeleted + FROM Passkeys + WHERE CredentialId = ? AND IsDeleted = 0 + ORDER BY CreatedAt DESC + """.trimIndent() + + val passkeys = mutableListOf() + val cursor = db.rawQuery(query, arrayOf(credentialId.toString().uppercase())) + + cursor.use { + while (it.moveToNext()) { + parsePasskeyRow(it)?.let { passkey -> + passkeys.add(passkey) + } + } + } + + return passkeys + } + + /** + * Get all passkeys for a specific relying party identifier (RP ID). + */ + fun getPasskeysForRpId(rpId: String): List { + val db = database.dbConnection ?: return emptyList() + + val query = """ + SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, + DisplayName, CreatedAt, UpdatedAt, IsDeleted + FROM Passkeys + WHERE RpId = ? AND IsDeleted = 0 + ORDER BY CreatedAt DESC + """.trimIndent() + + val passkeys = mutableListOf() + val cursor = db.rawQuery(query, arrayOf(rpId)) + + cursor.use { + while (it.moveToNext()) { + parsePasskeyRow(it)?.let { passkey -> + passkeys.add(passkey) + } + } + } + + return passkeys + } + + /** + * Get passkeys with credential info for a specific rpId and optionally username. + * Used for finding existing passkeys that might be replaced during registration. + */ + fun getPasskeysWithCredentialInfo( + rpId: String, + userName: String? = null, + userId: ByteArray? = null, + ): List { + val db = database.dbConnection ?: return emptyList() + + val query = """ + SELECT p.Id, p.CredentialId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey, + p.DisplayName, p.CreatedAt, p.UpdatedAt, p.IsDeleted, + c.Username, s.Name + FROM Passkeys p + JOIN Credentials c ON p.CredentialId = c.Id + JOIN Services s ON c.ServiceId = s.Id + WHERE p.RpId = ? AND p.IsDeleted = 0 AND c.IsDeleted = 0 + ORDER BY p.CreatedAt DESC + """.trimIndent() + + val results = mutableListOf() + val cursor = db.rawQuery(query, arrayOf(rpId)) + + cursor.use { + while (it.moveToNext()) { + val passkey = parsePasskeyRow(it) ?: continue + val credUsername = if (!it.isNull(11)) it.getString(11) else null + val serviceName = if (!it.isNull(12)) it.getString(12) else null + + // Filter by username or userId if provided + var matches = true + if (userName != null && credUsername != userName) { + matches = false + } + if (userId != null && passkey.userHandle != null && !userId.contentEquals(passkey.userHandle)) { + matches = false + } + + if (matches) { + results.add( + PasskeyWithCredentialInfo( + passkey = passkey, + serviceName = serviceName, + username = credUsername, + ), + ) + } + } + } + + return results + } + + /** + * Get all passkeys with their associated credentials in a single query. + * This is much more efficient than calling getPasskeysForCredential() for each credential. + * Uses a JOIN to get passkeys and their credentials in one database query. + */ + fun getAllPasskeysWithCredentials(): List { + val db = database.dbConnection ?: return emptyList() + + val query = """ + SELECT + p.Id, p.CredentialId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey, + p.DisplayName, p.CreatedAt as PasskeyCreatedAt, p.UpdatedAt as PasskeyUpdatedAt, p.IsDeleted as PasskeyIsDeleted, + c.Id as CredId, c.Username, s.Name as ServiceName, c.CreatedAt as CredCreatedAt, c.UpdatedAt as CredUpdatedAt + FROM Passkeys p + INNER JOIN Credentials c ON p.CredentialId = c.Id + INNER JOIN Services s ON c.ServiceId = s.Id + WHERE p.IsDeleted = 0 AND c.IsDeleted = 0 + ORDER BY p.CreatedAt DESC + """.trimIndent() + + val results = mutableListOf() + val cursor = db.rawQuery(query, null) + + cursor.use { + while (it.moveToNext()) { + try { + // Parse passkey (columns 0-10) + val passkey = parsePasskeyRowFromJoin(it) ?: continue + + // Parse credential info (columns 11-15) + val credentialId = UUID.fromString(it.getString(11)) + val username = if (!it.isNull(12)) it.getString(12) else null + val serviceName = if (!it.isNull(13)) it.getString(13) else null + + // Create a minimal Credential object with the data we have + val credential = Credential( + id = credentialId, + username = username, + service = Service( + id = UUID.randomUUID(), + name = serviceName, + url = null, + logo = null, + createdAt = Date(), + updatedAt = Date(), + isDeleted = false, + ), + alias = null, + notes = null, + password = null, + createdAt = DateHelpers.parseDateString(it.getString(14)) ?: MIN_DATE, + updatedAt = DateHelpers.parseDateString(it.getString(15)) ?: MIN_DATE, + isDeleted = false, + ) + + results.add(PasskeyWithCredential(passkey, credential)) + } catch (e: Exception) { + Log.e(TAG, "Error parsing passkey with credential row", e) + } + } + } + + return results + } + + /** + * Get a passkey by its ID. + */ + fun getPasskeyById(passkeyId: UUID): Passkey? { + val db = database.dbConnection ?: return null + + val query = """ + SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, + DisplayName, CreatedAt, UpdatedAt, IsDeleted + FROM Passkeys + WHERE Id = ? AND IsDeleted = 0 + LIMIT 1 + """.trimIndent() + + val cursor = db.rawQuery(query, arrayOf(passkeyId.toString().uppercase())) + cursor.use { + if (it.moveToFirst()) { + return parsePasskeyRow(it) + } + } + + return null + } + + // endregion + + // region Passkey Storage + + /** + * Insert a new passkey into the database. + */ + fun insertPasskey(passkey: Passkey) { + val db = database.dbConnection ?: error("Vault not unlocked") + + val insert = """ + INSERT INTO Passkeys (Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, + DisplayName, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val publicKeyString = String(passkey.publicKey, Charsets.UTF_8) + val privateKeyString = String(passkey.privateKey, Charsets.UTF_8) + + db.execSQL( + insert, + arrayOf( + passkey.id.toString().uppercase(), + passkey.parentCredentialId.toString().uppercase(), + passkey.rpId, + passkey.userHandle, + publicKeyString, + privateKeyString, + passkey.prfKey, + passkey.displayName, + DateHelpers.toStandardFormat(passkey.createdAt), + DateHelpers.toStandardFormat(passkey.updatedAt), + if (passkey.isDeleted) 1 else 0, + ), + ) + } + + /** + * Create a new credential with an associated passkey. + */ + fun createCredentialWithPasskey( + rpId: String, + userName: String?, + displayName: String, + passkey: Passkey, + logo: ByteArray? = null, + ): Credential { + val db = database.dbConnection ?: error("Vault not unlocked") + + db.beginTransaction() + try { + val credentialId = passkey.parentCredentialId + val now = Date() + val timestamp = DateHelpers.toStandardFormat(now) + + // Create a minimal service for the RP + val serviceId = UUID.randomUUID() + + val serviceValues = ContentValues().apply { + put("Id", serviceId.toString().uppercase()) + put("Name", displayName) + put("Url", "https://$rpId") + if (logo != null) { + put("Logo", logo) + } else { + putNull("Logo") + } + put("CreatedAt", timestamp) + put("UpdatedAt", timestamp) + put("IsDeleted", 0) + } + db.insert("Services", null, serviceValues) + + // Create a minimal alias with empty fields and default birthdate + val aliasId = UUID.randomUUID() + val aliasInsert = """ + INSERT INTO Aliases (Id, FirstName, LastName, NickName, BirthDate, Gender, Email, + CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + db.execSQL( + aliasInsert, + arrayOf( + aliasId.toString().uppercase(), + "", + "", + "", + DateHelpers.toStandardFormat(MIN_DATE), + "", + "", + timestamp, + timestamp, + 0, + ), + ) + + // Create the credential with the alias + val credentialInsert = """ + INSERT INTO Credentials (Id, ServiceId, AliasId, Username, Notes, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + db.execSQL( + credentialInsert, + arrayOf( + credentialId.toString().uppercase(), + serviceId.toString().uppercase(), + aliasId.toString().uppercase(), + userName, + null, + timestamp, + timestamp, + 0, + ), + ) + + // Insert the passkey + insertPasskey(passkey) + + // Commit transaction + database.commitTransaction() + + // Return the credential + val service = Service( + id = serviceId, + name = displayName, + url = "https://$rpId", + logo = logo, + createdAt = now, + updatedAt = now, + isDeleted = false, + ) + + val alias = Alias( + id = aliasId, + gender = "", + firstName = "", + lastName = "", + nickName = "", + birthDate = MIN_DATE, + email = "", + createdAt = now, + updatedAt = now, + isDeleted = false, + ) + + return Credential( + id = credentialId, + alias = alias, + service = service, + username = userName, + notes = null, + password = null, + createdAt = now, + updatedAt = now, + isDeleted = false, + ) + } catch (e: Exception) { + db.endTransaction() + throw e + } + } + + /** + * Replace an existing passkey with a new one. + */ + fun replacePasskey( + oldPasskeyId: UUID, + newPasskey: Passkey, + displayName: String, + logo: ByteArray? = null, + ) { + val db = database.dbConnection ?: error("Vault not unlocked") + + val now = Date() + val timestamp = DateHelpers.toStandardFormat(now) + + // Get the old passkey to find its credential + val oldPasskey = getPasskeyById(oldPasskeyId) + ?: throw VaultPasskeyError.PasskeyNotFound("Passkey not found: $oldPasskeyId") + + val credentialId = oldPasskey.parentCredentialId + + // Update the credential's service with new logo if provided + if (logo != null) { + val credQuery = """ + SELECT ServiceId FROM Credentials WHERE Id = ? LIMIT 1 + """.trimIndent() + + val cursor = db.rawQuery(credQuery, arrayOf(credentialId.toString().uppercase())) + cursor.use { + if (it.moveToFirst()) { + val serviceId = it.getString(0) + + val serviceUpdate = """ + UPDATE Services + SET Logo = ?, Name = ?, UpdatedAt = ? + WHERE Id = ? + """.trimIndent() + + db.execSQL(serviceUpdate, arrayOf(logo, displayName, timestamp, serviceId)) + } + } + } + + // Delete the old passkey + val deleteQuery = """ + UPDATE Passkeys + SET IsDeleted = 1, UpdatedAt = ? + WHERE Id = ? + """.trimIndent() + + db.execSQL(deleteQuery, arrayOf(timestamp, oldPasskeyId.toString().uppercase())) + + // Create the new passkey with the same credential ID + val updatedPasskey = newPasskey.copy( + parentCredentialId = credentialId, + displayName = displayName, + createdAt = now, + updatedAt = now, + isDeleted = false, + ) + + insertPasskey(updatedPasskey) + } + + // endregion + + // region Helper Methods + + /** + * Parse a passkey row from database query. + */ + private fun parsePasskeyRow(cursor: Cursor): Passkey? { + try { + val idString = cursor.getString(0) + val parentCredentialIdString = cursor.getString(1) + val rpId = cursor.getString(2) + val userHandle = if (!cursor.isNull(3)) cursor.getBlob(3) else null + val publicKeyString = cursor.getString(4) + val privateKeyString = cursor.getString(5) + val prfKey = if (!cursor.isNull(6)) cursor.getBlob(6) else null + val displayName = cursor.getString(7) + val createdAtString = cursor.getString(8) + val updatedAtString = cursor.getString(9) + val isDeleted = cursor.getInt(10) == 1 + + val id = UUID.fromString(idString) + val parentCredentialId = UUID.fromString(parentCredentialIdString) + + val createdAt = DateHelpers.parseDateString(createdAtString) ?: MIN_DATE + val updatedAt = DateHelpers.parseDateString(updatedAtString) ?: MIN_DATE + + val publicKeyData = publicKeyString.toByteArray(Charsets.UTF_8) + val privateKeyData = privateKeyString.toByteArray(Charsets.UTF_8) + + return Passkey( + id = id, + parentCredentialId = parentCredentialId, + rpId = rpId, + userHandle = userHandle, + userName = null, + publicKey = publicKeyData, + privateKey = privateKeyData, + prfKey = prfKey, + displayName = displayName, + createdAt = createdAt, + updatedAt = updatedAt, + isDeleted = isDeleted, + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing passkey row", e) + return null + } + } + + /** + * Parse a passkey row from a JOIN query. + */ + private fun parsePasskeyRowFromJoin(cursor: Cursor): Passkey? { + try { + val idString = cursor.getString(0) + val parentCredentialIdString = cursor.getString(1) + val rpId = cursor.getString(2) + val userHandle = if (!cursor.isNull(3)) cursor.getBlob(3) else null + val publicKeyString = cursor.getString(4) + val privateKeyString = cursor.getString(5) + val prfKey = if (!cursor.isNull(6)) cursor.getBlob(6) else null + val displayName = cursor.getString(7) + val createdAtString = cursor.getString(8) + val updatedAtString = cursor.getString(9) + val isDeleted = cursor.getInt(10) == 1 + + val id = UUID.fromString(idString) + val parentCredentialId = UUID.fromString(parentCredentialIdString) + + val createdAt = DateHelpers.parseDateString(createdAtString) ?: MIN_DATE + val updatedAt = DateHelpers.parseDateString(updatedAtString) ?: MIN_DATE + + val publicKeyData = publicKeyString.toByteArray(Charsets.UTF_8) + val privateKeyData = privateKeyString.toByteArray(Charsets.UTF_8) + + return Passkey( + id = id, + parentCredentialId = parentCredentialId, + rpId = rpId, + userHandle = userHandle, + userName = null, + publicKey = publicKeyData, + privateKey = privateKeyData, + prfKey = prfKey, + displayName = displayName, + createdAt = createdAt, + updatedAt = updatedAt, + isDeleted = isDeleted, + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing passkey row from JOIN", e) + return null + } + } + + // endregion +} + +/** + * Data class to hold passkey with credential info. + */ +data class PasskeyWithCredentialInfo( + /** The passkey. */ + val passkey: Passkey, + /** The service name from the credential. */ + val serviceName: String?, + /** The username from the credential. */ + val username: String?, +) + +/** + * Data class to hold passkey with its associated credential. + */ +data class PasskeyWithCredential( + /** The passkey. */ + val passkey: Passkey, + /** The credential this passkey belongs to. */ + val credential: Credential, +) + +/** + * VaultPasskey-specific errors. + */ +sealed class VaultPasskeyError(message: String) : Exception(message) { + /** + * Error indicating vault is not unlocked. + */ + class VaultNotUnlocked(message: String) : VaultPasskeyError(message) + + /** + * Error indicating passkey was not found. + */ + class PasskeyNotFound(message: String) : VaultPasskeyError(message) + + /** + * Error indicating credential was not found. + */ + class CredentialNotFound(message: String) : VaultPasskeyError(message) + + /** + * Error indicating a database operation failure. + */ + class DatabaseError(message: String) : VaultPasskeyError(message) +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt index a196d922a..66a8a482d 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt @@ -70,6 +70,7 @@ class VaultStore( private val sync = VaultSync(databaseComponent, metadata, crypto) private val mutate = VaultMutate(databaseComponent, query, metadata) private val cache = VaultCache(crypto, databaseComponent, keystoreProvider, storageProvider) + private val passkey = VaultPasskey(databaseComponent) // endregion @@ -445,6 +446,105 @@ class VaultStore( // endregion + // region Passkey Methods + + /** + * Get a passkey by its credential ID (the WebAuthn credential ID). + */ + fun getPasskeyByCredentialId(credentialId: ByteArray): net.aliasvault.app.vaultstore.models.Passkey? { + return passkey.getPasskeyByCredentialId(credentialId) + } + + /** + * Get all passkeys for a credential. + */ + @Suppress("UnusedParameter") + fun getPasskeysForCredential( + credentialId: java.util.UUID, + db: android.database.sqlite.SQLiteDatabase, + ): List { + return passkey.getPasskeysForCredential(credentialId) + } + + /** + * Get all passkeys for a specific relying party identifier (RP ID). + */ + @Suppress("UnusedParameter") + fun getPasskeysForRpId( + rpId: String, + db: android.database.sqlite.SQLiteDatabase, + ): List { + return passkey.getPasskeysForRpId(rpId) + } + + /** + * Get passkeys with credential info for a specific rpId. + */ + @Suppress("UnusedParameter") + fun getPasskeysWithCredentialInfo( + rpId: String, + userName: String? = null, + userId: ByteArray? = null, + db: android.database.sqlite.SQLiteDatabase, + ): List { + return passkey.getPasskeysWithCredentialInfo(rpId, userName, userId) + } + + /** + * Get all passkeys with their associated credentials in a single query. + */ + fun getAllPasskeysWithCredentials(): List { + return passkey.getAllPasskeysWithCredentials() + } + + /** + * Get a passkey by its ID. + */ + @Suppress("UnusedParameter") + fun getPasskeyById( + passkeyId: java.util.UUID, + db: android.database.sqlite.SQLiteDatabase, + ): net.aliasvault.app.vaultstore.models.Passkey? { + return passkey.getPasskeyById(passkeyId) + } + + /** + * Insert a new passkey into the database. + */ + @Suppress("UnusedParameter") + fun insertPasskey(passkeyObj: net.aliasvault.app.vaultstore.models.Passkey, db: android.database.sqlite.SQLiteDatabase) { + passkey.insertPasskey(passkeyObj) + } + + /** + * Create a credential with a passkey. + */ + fun createCredentialWithPasskey( + rpId: String, + userName: String?, + displayName: String, + passkeyObj: net.aliasvault.app.vaultstore.models.Passkey, + logo: ByteArray? = null, + ): net.aliasvault.app.vaultstore.models.Credential { + return passkey.createCredentialWithPasskey(rpId, userName, displayName, passkeyObj, logo) + } + + /** + * Replace an existing passkey with a new one. + */ + @Suppress("UnusedParameter") + fun replacePasskey( + oldPasskeyId: java.util.UUID, + newPasskey: net.aliasvault.app.vaultstore.models.Passkey, + displayName: String, + logo: ByteArray? = null, + db: android.database.sqlite.SQLiteDatabase, + ) { + passkey.replacePasskey(oldPasskeyId, newPasskey, displayName, logo) + } + + // endregion + // region Cache Methods /** diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStorePasskey.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStorePasskey.kt deleted file mode 100644 index 6efd2ccdf..000000000 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStorePasskey.kt +++ /dev/null @@ -1,514 +0,0 @@ -package net.aliasvault.app.vaultstore - -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.util.Log -import net.aliasvault.app.utils.DateHelpers -import net.aliasvault.app.vaultstore.models.Alias -import net.aliasvault.app.vaultstore.models.Credential -import net.aliasvault.app.vaultstore.models.Passkey -import net.aliasvault.app.vaultstore.models.Service -import net.aliasvault.app.vaultstore.passkey.PasskeyHelper -import java.util.Calendar -import java.util.Date -import java.util.TimeZone -import java.util.UUID - -/** - * VaultStorePasskey - * Extension methods for VaultStore to handle passkey operations - * - * This is a Kotlin port of the iOS Swift implementation: - * - Reference: apps/mobile-app/ios/VaultStoreKit/VaultStore+Passkey.swift - * - * IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be - * reflected in all ports. Method names, parameters, and behavior should remain consistent. - */ - -private const val TAG = "VaultStorePasskey" - -/** - * Minimum date definition for default values. - */ -private val MIN_DATE: Date = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { - set(Calendar.YEAR, 1) - set(Calendar.MONTH, Calendar.JANUARY) - set(Calendar.DAY_OF_MONTH, 1) - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) -}.time - -/** - * Get a passkey by its credential ID (the WebAuthn credential ID, not the parent Credential UUID). - */ -fun VaultStore.getPasskeyByCredentialId(credentialId: ByteArray, db: SQLiteDatabase): Passkey? { - // Convert credentialId bytes to UUID string for lookup - val credentialIdString = try { - PasskeyHelper.bytesToGuid(credentialId) - } catch (e: Exception) { - Log.e(TAG, "Failed to convert credentialId bytes to UUID string", e) - return null - } - - val query = """ - SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, - DisplayName, CreatedAt, UpdatedAt, IsDeleted - FROM Passkeys - WHERE Id = ? AND IsDeleted = 0 - LIMIT 1 - """.trimIndent() - - val cursor = db.rawQuery(query, arrayOf(credentialIdString.uppercase())) - cursor.use { - if (it.moveToFirst()) { - return parsePasskeyRow(it) - } - } - - return null -} - -/** - * Get all passkeys for a credential. - */ -fun VaultStore.getPasskeysForCredential(credentialId: UUID, db: SQLiteDatabase): List { - val query = """ - SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, - DisplayName, CreatedAt, UpdatedAt, IsDeleted - FROM Passkeys - WHERE CredentialId = ? AND IsDeleted = 0 - ORDER BY CreatedAt DESC - """.trimIndent() - - val passkeys = mutableListOf() - val cursor = db.rawQuery(query, arrayOf(credentialId.toString().uppercase())) - - cursor.use { - while (it.moveToNext()) { - parsePasskeyRow(it)?.let { passkey -> - passkeys.add(passkey) - } - } - } - - return passkeys -} - -/** - * Get all passkeys for a specific relying party identifier (RP ID). - */ -fun VaultStore.getPasskeysForRpId(rpId: String, db: SQLiteDatabase): List { - val query = """ - SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, - DisplayName, CreatedAt, UpdatedAt, IsDeleted - FROM Passkeys - WHERE RpId = ? AND IsDeleted = 0 - ORDER BY CreatedAt DESC - """.trimIndent() - - val passkeys = mutableListOf() - val cursor = db.rawQuery(query, arrayOf(rpId)) - - cursor.use { - while (it.moveToNext()) { - parsePasskeyRow(it)?.let { passkey -> - passkeys.add(passkey) - } - } - } - - return passkeys -} - -/** - * Data class to hold passkey with credential info. - */ -data class PasskeyWithCredentialInfo( - /** The passkey. */ - val passkey: Passkey, - /** The service name from the credential. */ - val serviceName: String?, - /** The username from the credential. */ - val username: String?, -) - -/** - * Get passkeys with credential info for a specific rpId and optionally username. - * Used for finding existing passkeys that might be replaced during registration. - */ -fun VaultStore.getPasskeysWithCredentialInfo( - rpId: String, - userName: String? = null, - userId: ByteArray? = null, - db: SQLiteDatabase, -): List { - val query = """ - SELECT p.Id, p.CredentialId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey, - p.DisplayName, p.CreatedAt, p.UpdatedAt, p.IsDeleted, - c.Username, s.Name - FROM Passkeys p - JOIN Credentials c ON p.CredentialId = c.Id - JOIN Services s ON c.ServiceId = s.Id - WHERE p.RpId = ? AND p.IsDeleted = 0 AND c.IsDeleted = 0 - ORDER BY p.CreatedAt DESC - """.trimIndent() - - val results = mutableListOf() - val cursor = db.rawQuery(query, arrayOf(rpId)) - - cursor.use { - while (it.moveToNext()) { - val passkey = parsePasskeyRow(it) ?: continue - val credUsername = if (!it.isNull(11)) it.getString(11) else null - val serviceName = if (!it.isNull(12)) it.getString(12) else null - - // Filter by username or userId if provided - var matches = true - if (userName != null && credUsername != userName) { - matches = false - } - if (userId != null && passkey.userHandle != null && !userId.contentEquals(passkey.userHandle)) { - matches = false - } - - if (matches) { - results.add( - PasskeyWithCredentialInfo( - passkey = passkey, - serviceName = serviceName, - username = credUsername, - ), - ) - } - } - } - - return results -} - -/** - * Insert a new passkey into the database. - */ -fun VaultStore.insertPasskey(passkey: Passkey, db: SQLiteDatabase) { - val insert = """ - INSERT INTO Passkeys (Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, - DisplayName, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - - val publicKeyString = String(passkey.publicKey, Charsets.UTF_8) - val privateKeyString = String(passkey.privateKey, Charsets.UTF_8) - - db.execSQL( - insert, - arrayOf( - passkey.id.toString().uppercase(), - passkey.parentCredentialId.toString().uppercase(), - passkey.rpId, - passkey.userHandle, - publicKeyString, - privateKeyString, - passkey.prfKey, - passkey.displayName, - DateHelpers.toStandardFormat(passkey.createdAt), - DateHelpers.toStandardFormat(passkey.updatedAt), - if (passkey.isDeleted) 1 else 0, - ), - ) -} - -/** - * Create a new credential with an associated passkey. - * This method handles the database transaction internally. - */ -fun VaultStore.createCredentialWithPasskey( - rpId: String, - userName: String?, - displayName: String, - passkey: Passkey, - logo: ByteArray? = null, -): Credential { - val db = database ?: error("Vault not unlocked") - - db.beginTransaction() - try { - val credentialId = passkey.parentCredentialId - val now = Date() - val timestamp = DateHelpers.toStandardFormat(now) - - // Create a minimal service for the RP - val serviceId = UUID.randomUUID() - - // Use ContentValues to properly handle BLOB type for logo - val serviceValues = android.content.ContentValues().apply { - put("Id", serviceId.toString().uppercase()) - put("Name", displayName) - put("Url", "https://$rpId") - if (logo != null) { - put("Logo", logo) // ContentValues handles ByteArray -> BLOB correctly - } else { - putNull("Logo") - } - put("CreatedAt", timestamp) - put("UpdatedAt", timestamp) - put("IsDeleted", 0) - } - db.insert("Services", null, serviceValues) - - // Create a minimal alias with empty fields and default birthdate - val aliasId = UUID.randomUUID() - val aliasInsert = """ - INSERT INTO Aliases (Id, FirstName, LastName, NickName, BirthDate, Gender, Email, - CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - - db.execSQL( - aliasInsert, - arrayOf( - aliasId.toString().uppercase(), - "", - "", - "", - DateHelpers.toStandardFormat(MIN_DATE), - "", - "", - timestamp, - timestamp, - 0, - ), - ) - - // Create the credential with the alias - val credentialInsert = """ - INSERT INTO Credentials (Id, ServiceId, AliasId, Username, Notes, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - - db.execSQL( - credentialInsert, - arrayOf( - credentialId.toString().uppercase(), - serviceId.toString().uppercase(), - aliasId.toString().uppercase(), - userName, - null, - timestamp, - timestamp, - 0, - ), - ) - - // Insert the passkey - insertPasskey(passkey, db) - - // Return the credential - val service = Service( - id = serviceId, - name = displayName, - url = "https://$rpId", - logo = logo, - createdAt = now, - updatedAt = now, - isDeleted = false, - ) - - val alias = Alias( - id = aliasId, - gender = "", - firstName = "", - lastName = "", - nickName = "", - birthDate = MIN_DATE, - email = "", - createdAt = now, - updatedAt = now, - isDeleted = false, - ) - - // Commit transaction and persist to encrypted vault file - commitTransaction() - - return Credential( - id = credentialId, - alias = alias, - service = service, - username = userName, - notes = null, - password = null, - createdAt = now, - updatedAt = now, - isDeleted = false, - ) - } catch (e: Exception) { - // Rollback on error - db.endTransaction() - throw e - } -} - -/** - * Replace an existing passkey with a new one. - * This deletes the old passkey and creates a new one with the same credential. - */ -fun VaultStore.replacePasskey( - oldPasskeyId: UUID, - newPasskey: Passkey, - displayName: String, - logo: ByteArray? = null, - db: SQLiteDatabase, -) { - val now = Date() - val timestamp = DateHelpers.toStandardFormat(now) - - // Get the old passkey to find its credential - val oldPasskey = getPasskeyById(oldPasskeyId, db) - ?: throw VaultStorePasskeyError.PasskeyNotFound("Passkey not found: $oldPasskeyId") - - val credentialId = oldPasskey.parentCredentialId - - // Update the credential's service with new logo if provided - if (logo != null) { - // Get the service ID from the credential - val credQuery = """ - SELECT ServiceId FROM Credentials WHERE Id = ? LIMIT 1 - """.trimIndent() - - val cursor = db.rawQuery(credQuery, arrayOf(credentialId.toString().uppercase())) - cursor.use { - if (it.moveToFirst()) { - val serviceId = it.getString(0) - - // Update the service with new logo and displayName - val serviceUpdate = """ - UPDATE Services - SET Logo = ?, Name = ?, UpdatedAt = ? - WHERE Id = ? - """.trimIndent() - - db.execSQL(serviceUpdate, arrayOf(logo, displayName, timestamp, serviceId)) - } - } - } - - // Delete the old passkey - val deleteQuery = """ - UPDATE Passkeys - SET IsDeleted = 1, UpdatedAt = ? - WHERE Id = ? - """.trimIndent() - - db.execSQL(deleteQuery, arrayOf(timestamp, oldPasskeyId.toString().uppercase())) - - // Create the new passkey with the same credential ID - val updatedPasskey = newPasskey.copy( - parentCredentialId = credentialId, - displayName = displayName, - createdAt = now, - updatedAt = now, - isDeleted = false, - ) - - insertPasskey(updatedPasskey, db) -} - -/** - * Get a passkey by its ID. - */ -fun VaultStore.getPasskeyById(passkeyId: UUID, db: SQLiteDatabase): Passkey? { - val query = """ - SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey, - DisplayName, CreatedAt, UpdatedAt, IsDeleted - FROM Passkeys - WHERE Id = ? AND IsDeleted = 0 - LIMIT 1 - """.trimIndent() - - // Always convert the UUID to uppercase when querying the DB - val upperPasskeyId = passkeyId.toString().uppercase() - - val cursor = db.rawQuery(query, arrayOf(upperPasskeyId)) - cursor.use { - if (it.moveToFirst()) { - return parsePasskeyRow(it) - } - } - - return null -} - -/** - * Parse a passkey row from database query. - */ -private fun parsePasskeyRow(cursor: Cursor): Passkey? { - try { - val idString = cursor.getString(0) - val parentCredentialIdString = cursor.getString(1) - val rpId = cursor.getString(2) - val userHandle = if (!cursor.isNull(3)) cursor.getBlob(3) else null - val publicKeyString = cursor.getString(4) - val privateKeyString = cursor.getString(5) - val prfKey = if (!cursor.isNull(6)) cursor.getBlob(6) else null - val displayName = cursor.getString(7) - val createdAtString = cursor.getString(8) - val updatedAtString = cursor.getString(9) - val isDeleted = cursor.getInt(10) == 1 - - // Parse UUIDs - val id = UUID.fromString(idString) - val parentCredentialId = UUID.fromString(parentCredentialIdString) - - // Parse dates - val createdAt = DateHelpers.parseDateString(createdAtString) ?: MIN_DATE - val updatedAt = DateHelpers.parseDateString(updatedAtString) ?: MIN_DATE - - // Parse public/private keys - val publicKeyData = publicKeyString.toByteArray(Charsets.UTF_8) - val privateKeyData = privateKeyString.toByteArray(Charsets.UTF_8) - - return Passkey( - id = id, - parentCredentialId = parentCredentialId, - rpId = rpId, - userHandle = userHandle, - userName = null, // userName not stored in DB, derived from parent credential - publicKey = publicKeyData, - privateKey = privateKeyData, - prfKey = prfKey, - displayName = displayName, - createdAt = createdAt, - updatedAt = updatedAt, - isDeleted = isDeleted, - ) - } catch (e: Exception) { - Log.e(TAG, "Error parsing passkey row", e) - return null - } -} - -/** - * VaultStore passkey-specific errors. - */ -sealed class VaultStorePasskeyError(message: String) : Exception(message) { - /** - * Error indicating vault is not unlocked. - */ - class VaultNotUnlocked(message: String) : VaultStorePasskeyError(message) - - /** - * Error indicating passkey was not found. - */ - class PasskeyNotFound(message: String) : VaultStorePasskeyError(message) - - /** - * Error indicating credential was not found. - */ - class CredentialNotFound(message: String) : VaultStorePasskeyError(message) - - /** - * Error indicating a database operation failure. - */ - class DatabaseError(message: String) : VaultStorePasskeyError(message) -} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultSync.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultSync.kt index eb7ee1f71..6049b2f48 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultSync.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultSync.kt @@ -3,6 +3,8 @@ package net.aliasvault.app.vaultstore import android.util.Log import net.aliasvault.app.exceptions.SerializationException import net.aliasvault.app.exceptions.VaultOperationException +import net.aliasvault.app.utils.AppInfo +import net.aliasvault.app.vaultstore.utils.VersionComparison import org.json.JSONObject /** diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VersionComparison.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/VersionComparison.kt similarity index 97% rename from apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VersionComparison.kt rename to apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/VersionComparison.kt index c3f5a6876..8b4604297 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VersionComparison.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/VersionComparison.kt @@ -1,4 +1,6 @@ -package net.aliasvault.app.vaultstore +package net.aliasvault.app.vaultstore.utils + +import net.aliasvault.app.utils.AppInfo /** * Utility for comparing semantic version strings