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 3c7ef001f..c1a621b8f 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 @@ -25,11 +25,12 @@ import net.aliasvault.app.credentialprovider.models.PasskeyRegistrationViewModel import net.aliasvault.app.exceptions.PasskeyOperationException 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.models.Passkey import net.aliasvault.app.vaultstore.passkey.PasskeyAuthenticator import net.aliasvault.app.vaultstore.passkey.PasskeyHelper +import net.aliasvault.app.vaultstore.repositories.ItemWithCredentialInfo +import net.aliasvault.app.vaultstore.repositories.PasskeyWithCredentialInfo import net.aliasvault.app.webapi.WebApiService import org.json.JSONObject import java.util.Date @@ -71,7 +72,7 @@ class PasskeyFormFragment : Fragment() { private var isReplace: Boolean = false private var isMerge: Boolean = false private var passkeyToReplace: PasskeyWithCredentialInfo? = null - private var itemToMerge: net.aliasvault.app.vaultstore.ItemWithCredentialInfo? = null + private var itemToMerge: ItemWithCredentialInfo? = null // UI elements private lateinit var headerSubtitle: TextView @@ -628,7 +629,7 @@ class PasskeyFormFragment : Fragment() { */ private suspend fun mergePasskeyFlow( displayName: String, - item: net.aliasvault.app.vaultstore.ItemWithCredentialInfo, + item: ItemWithCredentialInfo, ) = withContext(Dispatchers.IO) { try { // Step 1: Sync vault before adding passkey to ensure we have latest data diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/models/PasskeyRegistrationViewModel.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/models/PasskeyRegistrationViewModel.kt index acfd99a66..c7567ca77 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/models/PasskeyRegistrationViewModel.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/models/PasskeyRegistrationViewModel.kt @@ -1,8 +1,8 @@ package net.aliasvault.app.credentialprovider.models import androidx.lifecycle.ViewModel -import net.aliasvault.app.vaultstore.ItemWithCredentialInfo -import net.aliasvault.app.vaultstore.PasskeyWithCredentialInfo +import net.aliasvault.app.vaultstore.repositories.ItemWithCredentialInfo +import net.aliasvault.app.vaultstore.repositories.PasskeyWithCredentialInfo import java.util.UUID /** diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultMutate.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultMutate.kt index 750885d5c..92e94ed0d 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultMutate.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultMutate.kt @@ -3,6 +3,7 @@ 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.vaultstore.repositories.ItemRepository import org.json.JSONArray import org.json.JSONObject @@ -11,7 +12,7 @@ import org.json.JSONObject */ class VaultMutate( private val database: VaultDatabase, - private val query: VaultQuery, + private val itemRepository: ItemRepository, private val metadata: VaultMetadataManager, ) { companion object { @@ -190,7 +191,7 @@ class VaultMutate( } // Get all items to count them and extract private email addresses - val items = query.getAllItems() + val items = itemRepository.getAll() val metadataObj = metadata.getVaultMetadataObject() val privateEmailDomains = metadataObj?.privateEmailDomains ?: emptyList() @@ -205,7 +206,7 @@ class VaultMutate( } .distinct() - val dbVersion = query.getDatabaseVersion() + val dbVersion = itemRepository.getDatabaseVersion() @Suppress("SwallowedException") val version = try { 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 index d63506ff4..91d45f922 100644 --- 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 @@ -1,44 +1,23 @@ package net.aliasvault.app.vaultstore -import android.util.Log -import net.aliasvault.app.utils.DateHelpers -import net.aliasvault.app.vaultstore.models.FieldKey import net.aliasvault.app.vaultstore.models.Item import net.aliasvault.app.vaultstore.models.Passkey +import net.aliasvault.app.vaultstore.repositories.ItemWithCredentialInfo import net.aliasvault.app.vaultstore.repositories.PasskeyRepository -import java.util.Calendar -import java.util.Date -import java.util.TimeZone +import net.aliasvault.app.vaultstore.repositories.PasskeyWithCredentialInfo +import net.aliasvault.app.vaultstore.repositories.PasskeyWithItem import java.util.UUID /** * Handles passkey operations for the vault. * This class uses composition to organize passkey-specific functionality. - * * + * * 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, - private val query: VaultQuery, + 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 - } - private val passkeyRepository = PasskeyRepository(database) // region Passkey Queries @@ -73,54 +52,7 @@ class VaultPasskey( userName: String? = null, userId: ByteArray? = null, ): List { - val db = database.dbConnection ?: return emptyList() - - // Query passkeys with associated item data using the new schema - val query = """ - SELECT p.Id, p.ItemId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey, - p.DisplayName, p.CreatedAt, p.UpdatedAt, p.IsDeleted, - i.Name, - fv_username.Value as Username - FROM Passkeys p - INNER JOIN Items i ON p.ItemId = i.Id - LEFT JOIN FieldValues fv_username ON fv_username.ItemId = i.Id - AND fv_username.FieldKey = ? - AND fv_username.IsDeleted = 0 - WHERE p.RpId = ? AND p.IsDeleted = 0 AND i.IsDeleted = 0 AND i.DeletedAt IS NULL - ORDER BY p.CreatedAt DESC - """.trimIndent() - - val results = mutableListOf() - val cursor = db.query(query, arrayOf(FieldKey.LOGIN_USERNAME, rpId)) - - cursor.use { - while (it.moveToNext()) { - val passkey = parsePasskeyRow(it) ?: continue - val itemName = if (!it.isNull(11)) it.getString(11) else null - val itemUsername = if (!it.isNull(12)) it.getString(12) else null - - // Filter by username or userId if provided - var matches = true - if (userName != null && itemUsername != userName) { - matches = false - } - if (userId != null && passkey.userHandle != null && !userId.contentEquals(passkey.userHandle)) { - matches = false - } - - if (matches) { - results.add( - PasskeyWithCredentialInfo( - passkey = passkey, - serviceName = itemName, - username = itemUsername, - ), - ) - } - } - } - - return results + return passkeyRepository.getWithCredentialInfo(rpId, userName, userId) } /** @@ -135,92 +67,7 @@ class VaultPasskey( rpId: String, userName: String? = null, ): List { - val db = database.dbConnection ?: return emptyList() - - // Query Items that: - // 1. Have a URL containing the rpId - // 2. Don't have an associated passkey - // 3. Are not deleted - val query = """ - SELECT i.Id, i.Name, i.CreatedAt, i.UpdatedAt, - fv_url.Value as Url, - fv_username.Value as Username, - fv_password.Value as Password - FROM Items i - INNER JOIN FieldValues fv_url ON fv_url.ItemId = i.Id - AND fv_url.FieldKey = ? - AND fv_url.IsDeleted = 0 - LEFT JOIN FieldValues fv_username ON fv_username.ItemId = i.Id - AND fv_username.FieldKey = ? - AND fv_username.IsDeleted = 0 - LEFT JOIN FieldValues fv_password ON fv_password.ItemId = i.Id - AND fv_password.FieldKey = ? - AND fv_password.IsDeleted = 0 - WHERE i.IsDeleted = 0 - AND i.DeletedAt IS NULL - AND i.ItemType = 'Login' - AND (LOWER(fv_url.Value) LIKE ? OR LOWER(fv_url.Value) LIKE ?) - AND NOT EXISTS ( - SELECT 1 FROM Passkeys p - WHERE p.ItemId = i.Id AND p.IsDeleted = 0 - ) - ORDER BY i.UpdatedAt DESC - """.trimIndent() - - val rpIdLower = rpId.lowercase() - val urlPattern1 = "%$rpIdLower%" - val urlPattern2 = "%${rpIdLower.replace("www.", "")}%" - - val results = mutableListOf() - val cursor = db.query( - query, - arrayOf( - FieldKey.LOGIN_URL, - FieldKey.LOGIN_USERNAME, - FieldKey.LOGIN_PASSWORD, - urlPattern1, - urlPattern2, - ), - ) - - cursor.use { - while (it.moveToNext()) { - val itemIdString = it.getString(0) - val itemName = if (!it.isNull(1)) it.getString(1) else null - val itemCreatedAt = if (!it.isNull(2)) it.getString(2) else null - val itemUpdatedAt = if (!it.isNull(3)) it.getString(3) else null - val url = if (!it.isNull(4)) it.getString(4) else null - val itemUsername = if (!it.isNull(5)) it.getString(5) else null - val hasPassword = !it.isNull(6) && it.getString(6).isNotEmpty() - - // Filter by username if provided - if (userName != null && itemUsername != userName) { - continue - } - - try { - val itemId = UUID.fromString(itemIdString) - val createdAt = DateHelpers.parseDateString(itemCreatedAt ?: "") ?: MIN_DATE - val updatedAt = DateHelpers.parseDateString(itemUpdatedAt ?: "") ?: MIN_DATE - - results.add( - ItemWithCredentialInfo( - itemId = itemId, - serviceName = itemName, - url = url, - username = itemUsername, - hasPassword = hasPassword, - createdAt = createdAt, - updatedAt = updatedAt, - ), - ) - } catch (e: Exception) { - Log.e(TAG, "Error parsing item row", e) - } - } - } - - return results + return passkeyRepository.getItemsWithoutPasskeyForRpId(rpId, userName) } /** @@ -229,73 +76,7 @@ class VaultPasskey( * Uses a JOIN to get passkeys and their items in one database query. */ fun getAllPasskeysWithItems(): List { - val db = database.dbConnection ?: return emptyList() - - val query = """ - SELECT - p.Id, p.ItemId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey, - p.DisplayName, p.CreatedAt as PasskeyCreatedAt, p.UpdatedAt as PasskeyUpdatedAt, p.IsDeleted as PasskeyIsDeleted, - i.Id as ItemId, i.Name, i.CreatedAt as ItemCreatedAt, i.UpdatedAt as ItemUpdatedAt, - fv_username.Value as Username, - fv_email.Value as Email - FROM Passkeys p - INNER JOIN Items i ON p.ItemId = i.Id - LEFT JOIN FieldValues fv_username ON fv_username.ItemId = i.Id - AND fv_username.FieldKey = ? - AND fv_username.IsDeleted = 0 - LEFT JOIN FieldValues fv_email ON fv_email.ItemId = i.Id - AND fv_email.FieldKey = ? - AND fv_email.IsDeleted = 0 - WHERE p.IsDeleted = 0 AND i.IsDeleted = 0 AND i.DeletedAt IS NULL - ORDER BY p.CreatedAt DESC - """.trimIndent() - - val results = mutableListOf() - val cursor = db.query(query, arrayOf(FieldKey.LOGIN_USERNAME, FieldKey.LOGIN_EMAIL)) - - cursor.use { - while (it.moveToNext()) { - try { - // Parse passkey (columns 0-10) - val passkey = parsePasskeyRowFromJoin(it) ?: continue - - // Parse item info (columns 11-15) - val itemId = UUID.fromString(it.getString(11)) - val itemName = if (!it.isNull(12)) it.getString(12) else null - val itemCreatedAt = DateHelpers.parseDateString(it.getString(13)) ?: MIN_DATE - val itemUpdatedAt = DateHelpers.parseDateString(it.getString(14)) ?: MIN_DATE - - @Suppress("UNUSED_VARIABLE") // Username field loaded for potential future use - val username = if (!it.isNull(15)) it.getString(15) else null - - @Suppress("UNUSED_VARIABLE") // Email field loaded for potential future use - val email = if (!it.isNull(16)) it.getString(16) else null - - // Create a minimal Item object with the data we have - // Full items should be loaded via query.getAllItems() when needed - val item = Item( - id = itemId, - name = itemName, - itemType = "Login", - logo = null, - folderId = null, - folderPath = null, - fields = emptyList(), // Not loading all fields for performance - hasPasskey = true, - hasAttachment = false, - hasTotp = false, - createdAt = itemCreatedAt, - updatedAt = itemUpdatedAt, - ) - - results.add(PasskeyWithItem(passkey, item)) - } catch (e: Exception) { - Log.e(TAG, "Error parsing passkey with item row", e) - } - } - } - - return results + return passkeyRepository.getAllWithItems() } /** @@ -357,150 +138,8 @@ class VaultPasskey( } // endregion - - // region Helper Methods - - /** - * Parse a passkey row from database query. - */ - private fun parsePasskeyRow(cursor: android.database.Cursor): Passkey? { - try { - val idString = cursor.getString(0) - val itemIdString = 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 itemId = UUID.fromString(itemIdString) - - 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, - parentItemId = itemId, // Note: field name still parentItemId but refers to ItemId - 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: android.database.Cursor): Passkey? { - try { - val idString = cursor.getString(0) - val itemIdString = 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 itemId = UUID.fromString(itemIdString) - - 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, - parentItemId = itemId, // Note: field name still parentItemId but refers to ItemId - 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 item info. - * - * @property passkey The passkey. - * @property serviceName The service name from the item. - * @property username The username from the item. - */ -data class PasskeyWithCredentialInfo( - val passkey: Passkey, - val serviceName: String?, - val username: String?, -) - -/** - * Data class to hold passkey with its associated item. - * - * @property passkey The passkey. - * @property item The item this passkey belongs to. - */ -data class PasskeyWithItem( - val passkey: Passkey, - val item: Item, -) - -/** - * Data class to hold Item info for Items without passkeys. - * Used for showing existing credentials that can have a passkey added. - * - * @property itemId The UUID of the item. - * @property serviceName The service name (Item.Name). - * @property url The login URL. - * @property username The username from field values. - * @property hasPassword Whether the item has a password. - * @property createdAt When the item was created. - * @property updatedAt When the item was last updated. - */ -data class ItemWithCredentialInfo( - val itemId: UUID, - val serviceName: String?, - val url: String?, - val username: String?, - val hasPassword: Boolean, - val createdAt: Date, - val updatedAt: Date, -) - /** * VaultPasskey-specific errors. */ diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultQuery.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultQuery.kt deleted file mode 100644 index 9ce812b7d..000000000 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultQuery.kt +++ /dev/null @@ -1,431 +0,0 @@ -package net.aliasvault.app.vaultstore - -import android.util.Base64 -import android.util.Log -import net.aliasvault.app.utils.DateHelpers -import net.aliasvault.app.vaultstore.interfaces.ItemOperationCallback -import net.aliasvault.app.vaultstore.models.FieldKey -import net.aliasvault.app.vaultstore.models.FieldType -import net.aliasvault.app.vaultstore.models.Item -import net.aliasvault.app.vaultstore.models.ItemField -import java.util.Calendar -import java.util.Date -import java.util.TimeZone -import java.util.UUID - -/** - * Handles SQL query operations on the vault database. - */ -class VaultQuery( - private val database: VaultDatabase, -) { - companion object { - private const val TAG = "VaultQuery" - - 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 SQL Query Execution - - /** - * Execute a read-only SQL query (SELECT) on the vault. - */ - @Suppress("NestedBlockDepth") - fun executeQuery(query: String, params: Array): List> { - val results = mutableListOf>() - - database.dbConnection?.let { db -> - val convertedParams = params.map { param -> - if (param is String && param.startsWith("av-base64-to-blob:")) { - val base64 = param.substring("av-base64-to-blob:".length) - Base64.decode(base64, Base64.NO_WRAP) - } else { - param - } - }.toTypedArray() - - val cursor = db.query(query, convertedParams.map { it?.toString() }.toTypedArray()) - - cursor.use { - val columnNames = it.columnNames - while (it.moveToNext()) { - val row = mutableMapOf() - for (columnName in columnNames) { - when (it.getType(it.getColumnIndexOrThrow(columnName))) { - android.database.Cursor.FIELD_TYPE_NULL -> row[columnName] = null - android.database.Cursor.FIELD_TYPE_INTEGER -> row[columnName] = it.getLong( - it.getColumnIndexOrThrow(columnName), - ) - android.database.Cursor.FIELD_TYPE_FLOAT -> row[columnName] = it.getDouble( - it.getColumnIndexOrThrow(columnName), - ) - android.database.Cursor.FIELD_TYPE_STRING -> row[columnName] = it.getString( - it.getColumnIndexOrThrow(columnName), - ) - android.database.Cursor.FIELD_TYPE_BLOB -> row[columnName] = it.getBlob( - it.getColumnIndexOrThrow(columnName), - ) - } - } - results.add(row) - } - } - } - - return results - } - - /** - * Execute an SQL update on the vault that mutates it. - */ - fun executeUpdate(query: String, params: Array): Int { - database.dbConnection?.let { db -> - val convertedParams = params.map { param -> - if (param is String && param.startsWith("av-base64-to-blob:")) { - val base64 = param.substring("av-base64-to-blob:".length) - Base64.decode(base64, Base64.NO_WRAP) - } else { - param - } - }.toTypedArray() - - // Execute the statement using compileStatement for non-SELECT queries - val stmt = db.compileStatement(query) - try { - // Bind parameters - convertedParams.forEachIndexed { index, param -> - when (param) { - null -> stmt.bindNull(index + 1) - is ByteArray -> stmt.bindBlob(index + 1, param) - is Long -> stmt.bindLong(index + 1, param) - is Double -> stmt.bindDouble(index + 1, param) - else -> stmt.bindString(index + 1, param.toString()) - } - } - stmt.execute() - } finally { - stmt.close() - } - - // Get the number of affected rows - val cursor = db.rawQuery("SELECT changes()", null) - cursor.use { - if (it.moveToFirst()) { - return it.getInt(0) - } - } - } - return 0 - } - - /** - * Execute a raw SQL command on the vault without parameters. - */ - fun executeRaw(query: String) { - database.dbConnection?.let { db -> - val statements = query.split(";") - - for (statement in statements) { - val trimmedStatement = statement.smartTrim() - - if (trimmedStatement.isEmpty() || - trimmedStatement.uppercase().startsWith("BEGIN") || - trimmedStatement.uppercase().startsWith("COMMIT") || - trimmedStatement.uppercase().startsWith("ROLLBACK") - ) { - continue - } - - // Use compileStatement and execute for all non-SELECT statements - val stmt = db.compileStatement(trimmedStatement) - try { - stmt.execute() - } finally { - stmt.close() - } - } - } - } - - // endregion - - // region Item Operations (New Field-Based Model) - - /** - * Get all items from the vault using the new field-based model. - */ - @Suppress("LongMethod", "NestedBlockDepth") - fun getAllItems(): List { - if (database.dbConnection == null) { - error("Database not initialized") - } - - val itemQuery = """ - SELECT DISTINCT - i.Id, - i.Name, - i.ItemType, - i.FolderId, - f.Name as FolderPath, - l.FileData as Logo, - CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, - CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, - CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, - i.CreatedAt, - i.UpdatedAt - FROM Items i - LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id - WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL - ORDER BY i.CreatedAt DESC - """ - - val items = mutableListOf() - val itemIds = mutableListOf() - - database.dbConnection?.query(itemQuery)?.use { cursor -> - while (cursor.moveToNext()) { - try { - val idString = cursor.getString(0) - val name = if (cursor.isNull(1)) null else cursor.getString(1) - val itemType = cursor.getString(2) - val folderId = if (cursor.isNull(3)) null else cursor.getString(3) - val folderPath = if (cursor.isNull(4)) null else cursor.getString(4) - val logo = if (cursor.isNull(5)) null else cursor.getBlob(5) - val hasPasskey = cursor.getInt(6) == 1 - val hasAttachment = cursor.getInt(7) == 1 - val hasTotp = cursor.getInt(8) == 1 - val createdAt = DateHelpers.parseDateString(cursor.getString(9)) ?: MIN_DATE - val updatedAt = DateHelpers.parseDateString(cursor.getString(10)) ?: MIN_DATE - - val item = Item( - id = UUID.fromString(idString), - name = name, - itemType = itemType, - logo = logo, - folderId = folderId?.let { UUID.fromString(it) }, - folderPath = folderPath, - fields = emptyList(), // Will be populated below - hasPasskey = hasPasskey, - hasAttachment = hasAttachment, - hasTotp = hasTotp, - createdAt = createdAt, - updatedAt = updatedAt, - ) - items.add(item) - itemIds.add(idString) - } catch (e: Exception) { - Log.e(TAG, "Error parsing item row", e) - } - } - } - - // If no items, return empty list - if (items.isEmpty()) { - return emptyList() - } - - // Get all field values for these items - val placeholders = itemIds.joinToString(",") { "?" } - val fieldQuery = """ - SELECT - fv.ItemId, - fv.FieldKey, - fv.FieldDefinitionId, - fd.Label as CustomLabel, - fd.FieldType as CustomFieldType, - fd.IsHidden as CustomIsHidden, - fd.EnableHistory as CustomEnableHistory, - fv.Value, - fv.Weight as DisplayOrder - FROM FieldValues fv - LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id - WHERE fv.ItemId IN ($placeholders) - AND fv.IsDeleted = 0 - ORDER BY fv.ItemId, fv.Weight - """ - - // Build a map of itemId -> [ItemField] - val fieldsByItemId = mutableMapOf>() - - database.dbConnection?.query(fieldQuery, itemIds.toTypedArray())?.use { cursor -> - while (cursor.moveToNext()) { - try { - val itemIdString = cursor.getString(0) - val fieldKey = if (cursor.isNull(1)) null else cursor.getString(1) - val fieldDefinitionId = if (cursor.isNull(2)) null else cursor.getString(2) - val customLabel = if (cursor.isNull(3)) null else cursor.getString(3) - val customFieldType = if (cursor.isNull(4)) null else cursor.getString(4) - val customIsHidden = if (cursor.isNull(5)) false else cursor.getInt(5) == 1 - val customEnableHistory = if (cursor.isNull(6)) false else cursor.getInt(6) == 1 - val value = if (cursor.isNull(7)) "" else cursor.getString(7) - val displayOrder = if (cursor.isNull(8)) 0 else cursor.getInt(8) - - // Determine if this is a custom field - val isCustomField = fieldDefinitionId != null && fieldKey == null - - // Resolve the effective field key - val effectiveFieldKey = fieldKey ?: fieldDefinitionId ?: "" - - // Resolve field metadata - val metadata = resolveFieldMetadata( - fieldKey = effectiveFieldKey, - customLabel = customLabel, - customFieldType = customFieldType, - customIsHidden = customIsHidden, - customEnableHistory = customEnableHistory, - isCustomField = isCustomField, - ) - - val field = ItemField( - fieldKey = effectiveFieldKey, - label = metadata.label, - fieldType = metadata.fieldType, - value = value, - isHidden = metadata.isHidden, - displayOrder = displayOrder, - isCustomField = isCustomField, - enableHistory = metadata.enableHistory, - ) - - fieldsByItemId.getOrPut(itemIdString) { mutableListOf() }.add(field) - } catch (e: Exception) { - Log.e(TAG, "Error parsing field row", e) - } - } - } - - // Assign fields to items - return items.map { item -> - val fields = fieldsByItemId[item.id.toString().uppercase()] ?: emptyList() - item.copy(fields = fields) - } - } - - /** - * Helper class to hold resolved field metadata. - */ - private data class FieldMetadata( - val label: String, - val fieldType: String, - val isHidden: Boolean, - val enableHistory: Boolean, - ) - - /** - * Resolve field metadata for system fields and custom fields. - */ - @Suppress("CyclomaticComplexMethod", "LongParameterList") - // LongParameterList suppressed: All parameters are needed to determine field metadata - private fun resolveFieldMetadata( - fieldKey: String, - customLabel: String?, - customFieldType: String?, - customIsHidden: Boolean, - customEnableHistory: Boolean, - isCustomField: Boolean, - ): FieldMetadata { - if (isCustomField) { - return FieldMetadata( - label = customLabel ?: fieldKey, - fieldType = customFieldType ?: FieldType.TEXT, - isHidden = customIsHidden, - enableHistory = customEnableHistory, - ) - } - - // System field metadata based on FieldKey constants - return when (fieldKey) { - FieldKey.LOGIN_USERNAME -> FieldMetadata("Username", FieldType.TEXT, false, false) - FieldKey.LOGIN_PASSWORD -> FieldMetadata("Password", FieldType.PASSWORD, true, true) - FieldKey.LOGIN_EMAIL -> FieldMetadata("Email", FieldType.EMAIL, false, false) - FieldKey.LOGIN_URL -> FieldMetadata("URL", FieldType.U_R_L, false, false) - FieldKey.CARD_NUMBER -> FieldMetadata("Card Number", FieldType.TEXT, true, false) - FieldKey.CARD_CARDHOLDER_NAME -> FieldMetadata("Cardholder Name", FieldType.TEXT, false, false) - FieldKey.CARD_EXPIRY_MONTH -> FieldMetadata("Expiry Month", FieldType.TEXT, false, false) - FieldKey.CARD_EXPIRY_YEAR -> FieldMetadata("Expiry Year", FieldType.TEXT, false, false) - FieldKey.CARD_CVV -> FieldMetadata("CVV", FieldType.PASSWORD, true, false) - FieldKey.CARD_PIN -> FieldMetadata("PIN", FieldType.PASSWORD, true, false) - FieldKey.ALIAS_FIRST_NAME -> FieldMetadata("First Name", FieldType.TEXT, false, false) - FieldKey.ALIAS_LAST_NAME -> FieldMetadata("Last Name", FieldType.TEXT, false, false) - FieldKey.ALIAS_GENDER -> FieldMetadata("Gender", FieldType.TEXT, false, false) - FieldKey.ALIAS_BIRTHDATE -> FieldMetadata("Birth Date", FieldType.DATE, false, false) - FieldKey.NOTES_CONTENT -> FieldMetadata("Notes", FieldType.TEXT_AREA, false, false) - else -> FieldMetadata(fieldKey, FieldType.TEXT, false, false) - } - } - - // endregion - - // region Item Operations - - /** - * Attempts to get all items using only the cached encryption key. - */ - fun tryGetAllItems(callback: ItemOperationCallback, crypto: VaultCrypto, unlockVault: () -> Unit): Boolean { - if (crypto.encryptionKey == null) { - Log.d(TAG, "Encryption key not in memory, authentication required") - return false - } - - try { - if (!database.isVaultUnlocked()) { - unlockVault() - } - - callback.onSuccess(getAllItems()) - return true - } catch (e: Exception) { - Log.e(TAG, "Error retrieving items", e) - callback.onError(e) - return false - } - } - - /** - * Get the database version from the __EFMigrationsHistory table. - */ - fun getDatabaseVersion(): String { - val query = "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId DESC LIMIT 1" - val results = executeQuery(query, emptyArray()) - - if (results.isEmpty()) { - Log.d(TAG, "No migrations found in database, returning default version") - return "0.0.0" - } - - val migrationId = results[0]["MigrationId"] as? String - if (migrationId == null) { - return "0.0.0" - } - - val versionRegex = Regex("_(\\d+\\.\\d+\\.\\d+)-") - val match = versionRegex.find(migrationId) - - return if (match != null && match.groupValues.size > 1) { - match.groupValues[1] - } else { - Log.d(TAG, "Could not extract version from migration ID '$migrationId', returning default") - "0.0.0" - } - } - - // endregion - - // region Helper Functions - - private fun String.smartTrim(): String { - val invisible = "[\\uFEFF\\u200B\\u00A0\\u202A-\\u202E\\u2060\\u180E]" - return this.replace(Regex("^($invisible)+|($invisible)+$"), "").trim() - } - - // endregion -} 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 e4c264c4d..399f9f158 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 @@ -21,7 +21,7 @@ import kotlin.coroutines.resume * This class uses composition to organize functionality into specialized components: * - VaultCrypto: Handles encryption, decryption, and key management * - VaultDatabase: Handles database storage and operations - * - VaultQuery: Handles SQL query execution and credential retrieval + * - ItemRepository: Handles item queries through the repository pattern * - VaultMetadataManager: Handles metadata and settings storage * - VaultAuth: Handles authentication methods and auto-lock * - VaultSync: Handles vault synchronization with server @@ -70,13 +70,13 @@ class VaultStore( private val crypto = VaultCrypto(keystoreProvider, storageProvider) private val databaseComponent = VaultDatabase(storageProvider, crypto) - private val query = VaultQuery(databaseComponent) + private val itemRepository = net.aliasvault.app.vaultstore.repositories.ItemRepository(databaseComponent) internal val metadata = VaultMetadataManager(storageProvider) private val auth = VaultAuth(storageProvider) { cache.clearCache() } - private val sync = VaultSync(databaseComponent, metadata, crypto, storageProvider, query) - private val mutate = VaultMutate(databaseComponent, query, metadata) + private val sync = VaultSync(databaseComponent, metadata, crypto, storageProvider, itemRepository) + private val mutate = VaultMutate(databaseComponent, itemRepository, metadata) private val cache = VaultCache(crypto, databaseComponent, keystoreProvider, storageProvider) - private val passkey = VaultPasskey(databaseComponent, query) + private val passkey = VaultPasskey(databaseComponent) private val pin by lazy { val androidProvider = storageProvider as net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider // Use reflection to access private context field @@ -256,21 +256,89 @@ class VaultStore( * Execute a read-only SQL query (SELECT) on the vault. */ fun executeQuery(queryString: String, params: Array): List> { - return query.executeQuery(queryString, params) + val db = databaseComponent.dbConnection ?: error("Database not initialized") + + // Convert params to strings for SQLite + val convertedParams = params.map { param -> + when (param) { + null -> null + is ByteArray -> String(param, Charsets.UTF_8) + else -> param.toString() + } + }.toTypedArray() + + val cursor = db.query(queryString, convertedParams) + val results = mutableListOf>() + + cursor.use { + val columnNames = it.columnNames + while (it.moveToNext()) { + val row = mutableMapOf() + for (columnName in columnNames) { + when (it.getType(it.getColumnIndexOrThrow(columnName))) { + android.database.Cursor.FIELD_TYPE_NULL -> row[columnName] = null + android.database.Cursor.FIELD_TYPE_INTEGER -> row[columnName] = it.getLong( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_FLOAT -> row[columnName] = it.getDouble( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_STRING -> row[columnName] = it.getString( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_BLOB -> row[columnName] = it.getBlob( + it.getColumnIndexOrThrow(columnName), + ) + } + } + results.add(row) + } + } + + return results } /** * Execute an SQL update on the vault that mutates it. */ fun executeUpdate(queryString: String, params: Array): Int { - return query.executeUpdate(queryString, params) + val db = databaseComponent.dbConnection ?: error("Database not initialized") + + val convertedParams = params.map { param -> + when (param) { + null -> null + is ByteArray -> String(param, Charsets.UTF_8) + else -> param.toString() + } + }.toTypedArray() + + val stmt = db.compileStatement(queryString) + convertedParams.forEachIndexed { index, value -> + if (value == null) { + stmt.bindNull(index + 1) + } else { + stmt.bindString(index + 1, value) + } + } + stmt.execute() + + // Get the number of affected rows + val affectedCursor = db.rawQuery("SELECT changes()", null) + affectedCursor.use { + if (it.moveToFirst()) { + return it.getInt(0) + } + } + return 0 } /** * Execute a raw SQL command on the vault without parameters. */ fun executeRaw(queryString: String) { - query.executeRaw(queryString) + val db = databaseComponent.dbConnection ?: error("Database not initialized") + val stmt = db.compileStatement(queryString) + stmt.execute() } /** @@ -305,14 +373,30 @@ class VaultStore( * Get all items from the vault. */ fun getAllItems(): List { - return query.getAllItems() + return itemRepository.getAll() } /** * Attempts to get all items using only the cached encryption key. */ fun tryGetAllItems(callback: ItemOperationCallback): Boolean { - return query.tryGetAllItems(callback, crypto) { unlockVault() } + if (crypto.encryptionKey == null) { + android.util.Log.d("VaultStore", "Encryption key not in memory, authentication required") + return false + } + + try { + if (!databaseComponent.isVaultUnlocked()) { + unlockVault() + } + + callback.onSuccess(itemRepository.getAll()) + return true + } catch (e: Exception) { + android.util.Log.e("VaultStore", "Error retrieving items", e) + callback.onError(e) + return false + } } // endregion @@ -617,14 +701,14 @@ class VaultStore( rpId: String, userName: String? = null, userId: ByteArray? = null, - ): List { + ): List { return passkey.getPasskeysWithCredentialInfo(rpId, userName, userId) } /** * Get all passkeys with their associated items in a single query. */ - fun getAllPasskeysWithItems(): List { + fun getAllPasskeysWithItems(): List { return passkey.getAllPasskeysWithItems() } @@ -679,7 +763,7 @@ class VaultStore( fun getItemsWithoutPasskeyForRpId( rpId: String, userName: String? = null, - ): List { + ): List { return passkey.getItemsWithoutPasskeyForRpId(rpId, userName) } 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 b38fe87a6..cad6e8728 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 @@ -5,6 +5,7 @@ import net.aliasvault.app.exceptions.SerializationException import net.aliasvault.app.exceptions.VaultOperationException import net.aliasvault.app.rustcore.VaultMergeService import net.aliasvault.app.utils.AppInfo +import net.aliasvault.app.vaultstore.repositories.ItemRepository import net.aliasvault.app.vaultstore.storageprovider.StorageProvider import net.aliasvault.app.vaultstore.utils.VersionComparison import org.json.JSONArray @@ -18,7 +19,7 @@ class VaultSync( private val metadata: VaultMetadataManager, private val crypto: VaultCrypto, private val storageProvider: StorageProvider, - private val query: VaultQuery, + private val itemRepository: ItemRepository, ) { companion object { private const val TAG = "VaultSync" @@ -503,7 +504,7 @@ class VaultSync( } // Get all items to count them and extract private email addresses - val items = query.getAllItems() + val items = itemRepository.getAll() val metadataObj = metadata.getVaultMetadataObject() val privateEmailDomains = metadataObj?.privateEmailDomains ?: emptyList() @@ -518,7 +519,7 @@ class VaultSync( } .distinct() - val dbVersion = query.getDatabaseVersion() + val dbVersion = itemRepository.getDatabaseVersion() @Suppress("SwallowedException") val version = try { diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt index 94208976e..39350a60d 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt @@ -1,17 +1,21 @@ package net.aliasvault.app.vaultstore.repositories +import android.util.Log +import net.aliasvault.app.utils.DateHelpers import net.aliasvault.app.vaultstore.VaultDatabase import net.aliasvault.app.vaultstore.models.FieldKey +import net.aliasvault.app.vaultstore.models.FieldType import net.aliasvault.app.vaultstore.models.Item +import net.aliasvault.app.vaultstore.models.ItemField import java.util.Calendar import java.util.Date import java.util.TimeZone +import java.util.UUID /** * Repository for Item CRUD operations. * Handles fetching, creating, updating, and deleting items with their related data. */ -@Suppress("UnusedPrivateProperty") // TAG and MIN_DATE reserved for future use class ItemRepository(database: VaultDatabase) : BaseRepository(database) { companion object { private const val TAG = "ItemRepository" @@ -34,10 +38,146 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { * * @return List of Item objects. */ + @Suppress("LongMethod", "NestedBlockDepth", "LoopWithTooManyJumpStatements") fun getAll(): List { - // Implementation delegated to VaultQuery.getAllItems() for now - // This maintains existing tested behavior - error("Use VaultQuery.getAllItems() directly - repository pattern under construction") + val itemQuery = """ + SELECT DISTINCT + i.Id, + i.Name, + i.ItemType, + i.FolderId, + f.Name as FolderPath, + l.FileData as Logo, + CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, + CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + i.CreatedAt, + i.UpdatedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + LEFT JOIN Folders f ON i.FolderId = f.Id + WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL + ORDER BY i.CreatedAt DESC + """.trimIndent() + + val items = mutableListOf() + val itemIds = mutableListOf() + + val itemResults = executeQueryWithBlobs(itemQuery, emptyArray()) + for (row in itemResults) { + try { + val idString = row["Id"] as? String ?: continue + val name = row["Name"] as? String + val itemType = row["ItemType"] as? String ?: continue + val folderId = row["FolderId"] as? String + val folderPath = row["FolderPath"] as? String + val logo = row["Logo"] as? ByteArray + val hasPasskey = (row["HasPasskey"] as? Long) == 1L + val hasAttachment = (row["HasAttachment"] as? Long) == 1L + val hasTotp = (row["HasTotp"] as? Long) == 1L + val createdAt = DateHelpers.parseDateString(row["CreatedAt"] as? String ?: "") ?: MIN_DATE + val updatedAt = DateHelpers.parseDateString(row["UpdatedAt"] as? String ?: "") ?: MIN_DATE + + val item = Item( + id = UUID.fromString(idString), + name = name, + itemType = itemType, + logo = logo, + folderId = folderId?.let { UUID.fromString(it) }, + folderPath = folderPath, + fields = emptyList(), // Will be populated below + hasPasskey = hasPasskey, + hasAttachment = hasAttachment, + hasTotp = hasTotp, + createdAt = createdAt, + updatedAt = updatedAt, + ) + items.add(item) + itemIds.add(idString) + } catch (e: Exception) { + Log.e(TAG, "Error parsing item row", e) + } + } + + // If no items, return empty list + if (items.isEmpty()) { + return emptyList() + } + + // Get all field values for these items + val (placeholders, _) = buildInClause(itemIds) + val fieldQuery = """ + SELECT + fv.ItemId, + fv.FieldKey, + fv.FieldDefinitionId, + fd.Label as CustomLabel, + fd.FieldType as CustomFieldType, + fd.IsHidden as CustomIsHidden, + fd.EnableHistory as CustomEnableHistory, + fv.Value, + fv.Weight as DisplayOrder + FROM FieldValues fv + LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id + WHERE fv.ItemId IN ($placeholders) + AND fv.IsDeleted = 0 + ORDER BY fv.ItemId, fv.Weight + """.trimIndent() + + // Build a map of itemId -> [ItemField] + val fieldsByItemId = mutableMapOf>() + + val fieldResults = executeQuery(fieldQuery, itemIds.toTypedArray()) + for (row in fieldResults) { + try { + val itemIdString = row["ItemId"] as? String ?: continue + val fieldKey = row["FieldKey"] as? String + val fieldDefinitionId = row["FieldDefinitionId"] as? String + val customLabel = row["CustomLabel"] as? String + val customFieldType = row["CustomFieldType"] as? String + val customIsHidden = (row["CustomIsHidden"] as? Long) == 1L + val customEnableHistory = (row["CustomEnableHistory"] as? Long) == 1L + val value = row["Value"] as? String ?: "" + val displayOrder = (row["DisplayOrder"] as? Long)?.toInt() ?: 0 + + // Determine if this is a custom field + val isCustomField = fieldDefinitionId != null && fieldKey == null + + // Resolve the effective field key + val effectiveFieldKey = fieldKey ?: fieldDefinitionId ?: "" + + // Resolve field metadata + val metadata = resolveFieldMetadata( + fieldKey = effectiveFieldKey, + customLabel = customLabel, + customFieldType = customFieldType, + customIsHidden = customIsHidden, + customEnableHistory = customEnableHistory, + isCustomField = isCustomField, + ) + + val field = ItemField( + fieldKey = effectiveFieldKey, + label = metadata.label, + fieldType = metadata.fieldType, + value = value, + isHidden = metadata.isHidden, + displayOrder = displayOrder, + isCustomField = isCustomField, + enableHistory = metadata.enableHistory, + ) + + fieldsByItemId.getOrPut(itemIdString) { mutableListOf() }.add(field) + } catch (e: Exception) { + Log.e(TAG, "Error parsing field row", e) + } + } + + // Assign fields to items + return items.map { item -> + val fields = fieldsByItemId[item.id.toString().uppercase()] ?: emptyList() + item.copy(fields = fields) + } } /** @@ -46,9 +186,115 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { * @param itemId The ID of the item to fetch. * @return Item object or null if not found. */ - @Suppress("UNUSED_PARAMETER") // Method under construction, will be implemented fun getById(itemId: String): Item? { - error("Use VaultQuery.getItemById() directly - repository pattern under construction") + val itemQuery = """ + SELECT DISTINCT + i.Id, + i.Name, + i.ItemType, + i.FolderId, + f.Name as FolderPath, + l.FileData as Logo, + CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, + CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + i.CreatedAt, + i.UpdatedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + LEFT JOIN Folders f ON i.FolderId = f.Id + WHERE i.Id = ? AND i.IsDeleted = 0 AND i.DeletedAt IS NULL + """.trimIndent() + + val itemResults = executeQueryWithBlobs(itemQuery, arrayOf(itemId.uppercase())) + val row = itemResults.firstOrNull() ?: return null + + return try { + val idString = row["Id"] as? String ?: return null + val name = row["Name"] as? String + val itemType = row["ItemType"] as? String ?: return null + val folderId = row["FolderId"] as? String + val folderPath = row["FolderPath"] as? String + val logo = row["Logo"] as? ByteArray + val hasPasskey = (row["HasPasskey"] as? Long) == 1L + val hasAttachment = (row["HasAttachment"] as? Long) == 1L + val hasTotp = (row["HasTotp"] as? Long) == 1L + val createdAt = DateHelpers.parseDateString(row["CreatedAt"] as? String ?: "") ?: MIN_DATE + val updatedAt = DateHelpers.parseDateString(row["UpdatedAt"] as? String ?: "") ?: MIN_DATE + + // Get field values for this item + val fieldQuery = """ + SELECT + fv.FieldKey, + fv.FieldDefinitionId, + fd.Label as CustomLabel, + fd.FieldType as CustomFieldType, + fd.IsHidden as CustomIsHidden, + fd.EnableHistory as CustomEnableHistory, + fv.Value, + fv.Weight as DisplayOrder + FROM FieldValues fv + LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id + WHERE fv.ItemId = ? AND fv.IsDeleted = 0 + ORDER BY fv.Weight + """.trimIndent() + + val fields = mutableListOf() + val fieldResults = executeQuery(fieldQuery, arrayOf(idString)) + for (fieldRow in fieldResults) { + val fieldKey = fieldRow["FieldKey"] as? String + val fieldDefinitionId = fieldRow["FieldDefinitionId"] as? String + val customLabel = fieldRow["CustomLabel"] as? String + val customFieldType = fieldRow["CustomFieldType"] as? String + val customIsHidden = (fieldRow["CustomIsHidden"] as? Long) == 1L + val customEnableHistory = (fieldRow["CustomEnableHistory"] as? Long) == 1L + val value = fieldRow["Value"] as? String ?: "" + val displayOrder = (fieldRow["DisplayOrder"] as? Long)?.toInt() ?: 0 + + val isCustomField = fieldDefinitionId != null && fieldKey == null + val effectiveFieldKey = fieldKey ?: fieldDefinitionId ?: "" + + val metadata = resolveFieldMetadata( + fieldKey = effectiveFieldKey, + customLabel = customLabel, + customFieldType = customFieldType, + customIsHidden = customIsHidden, + customEnableHistory = customEnableHistory, + isCustomField = isCustomField, + ) + + fields.add( + ItemField( + fieldKey = effectiveFieldKey, + label = metadata.label, + fieldType = metadata.fieldType, + value = value, + isHidden = metadata.isHidden, + displayOrder = displayOrder, + isCustomField = isCustomField, + enableHistory = metadata.enableHistory, + ), + ) + } + + Item( + id = UUID.fromString(idString), + name = name, + itemType = itemType, + logo = logo, + folderId = folderId?.let { UUID.fromString(it) }, + folderPath = folderPath, + fields = fields, + hasPasskey = hasPasskey, + hasAttachment = hasAttachment, + hasTotp = hasTotp, + createdAt = createdAt, + updatedAt = updatedAt, + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing item by ID", e) + null + } } /** @@ -74,11 +320,102 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { /** * Get recently deleted items (in trash). + * Note: This returns minimal Item objects without fields for performance. * - * @return List of items. + * @return List of items in trash. */ + @Suppress("LongMethod", "LoopWithTooManyJumpStatements") fun getRecentlyDeleted(): List { - error("Use VaultQuery.getRecentlyDeletedItems() directly - repository pattern under construction") + val query = """ + SELECT DISTINCT + i.Id, + i.Name, + i.ItemType, + i.FolderId, + f.Name as FolderPath, + l.FileData as Logo, + CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, + CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + i.CreatedAt, + i.UpdatedAt, + i.DeletedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + LEFT JOIN Folders f ON i.FolderId = f.Id + WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL + ORDER BY i.DeletedAt DESC + """.trimIndent() + + val items = mutableListOf() + val results = executeQueryWithBlobs(query, emptyArray()) + + for (row in results) { + try { + val idString = row["Id"] as? String ?: continue + val name = row["Name"] as? String + val itemType = row["ItemType"] as? String ?: continue + val folderId = row["FolderId"] as? String + val folderPath = row["FolderPath"] as? String + val logo = row["Logo"] as? ByteArray + val hasPasskey = (row["HasPasskey"] as? Long) == 1L + val hasAttachment = (row["HasAttachment"] as? Long) == 1L + val hasTotp = (row["HasTotp"] as? Long) == 1L + val createdAt = DateHelpers.parseDateString(row["CreatedAt"] as? String ?: "") ?: MIN_DATE + val updatedAt = DateHelpers.parseDateString(row["UpdatedAt"] as? String ?: "") ?: MIN_DATE + + items.add( + Item( + id = UUID.fromString(idString), + name = name, + itemType = itemType, + logo = logo, + folderId = folderId?.let { UUID.fromString(it) }, + folderPath = folderPath, + fields = emptyList(), // Not loading fields for trash items + hasPasskey = hasPasskey, + hasAttachment = hasAttachment, + hasTotp = hasTotp, + createdAt = createdAt, + updatedAt = updatedAt, + ), + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing recently deleted item row", e) + } + } + + return items + } + + /** + * Get the database version from the __EFMigrationsHistory table. + * + * @return Database version string (e.g., "1.0.0"). + */ + fun getDatabaseVersion(): String { + val query = "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId DESC LIMIT 1" + val results = executeQuery(query, emptyArray()) + + if (results.isEmpty()) { + Log.d(TAG, "No migrations found in database, returning default version") + return "0.0.0" + } + + val migrationId = results[0]["MigrationId"] as? String + if (migrationId == null) { + return "0.0.0" + } + + val versionRegex = Regex("_(\\d+\\.\\d+\\.\\d+)-") + val match = versionRegex.find(migrationId) + + return if (match != null && match.groupValues.size > 1) { + match.groupValues[1] + } else { + Log.d(TAG, "Could not extract version from migration ID '$migrationId', returning default") + "0.0.0" + } } /** @@ -197,4 +534,96 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { fun update(item: Item): Int { error("Update operations should use VaultMutate - repository pattern under construction") } + + // MARK: - Helper Methods + + /** + * Helper class to hold resolved field metadata. + */ + private data class FieldMetadata( + val label: String, + val fieldType: String, + val isHidden: Boolean, + val enableHistory: Boolean, + ) + + /** + * Resolve field metadata for system fields and custom fields. + */ + @Suppress("CyclomaticComplexMethod", "LongParameterList") + // LongParameterList suppressed: All parameters are needed to determine field metadata + private fun resolveFieldMetadata( + fieldKey: String, + customLabel: String?, + customFieldType: String?, + customIsHidden: Boolean, + customEnableHistory: Boolean, + isCustomField: Boolean, + ): FieldMetadata { + if (isCustomField) { + return FieldMetadata( + label = customLabel ?: fieldKey, + fieldType = customFieldType ?: FieldType.TEXT, + isHidden = customIsHidden, + enableHistory = customEnableHistory, + ) + } + + // System field metadata based on FieldKey constants + return when (fieldKey) { + FieldKey.LOGIN_USERNAME -> FieldMetadata("Username", FieldType.TEXT, false, false) + FieldKey.LOGIN_PASSWORD -> FieldMetadata("Password", FieldType.PASSWORD, true, true) + FieldKey.LOGIN_EMAIL -> FieldMetadata("Email", FieldType.EMAIL, false, false) + FieldKey.LOGIN_URL -> FieldMetadata("URL", FieldType.U_R_L, false, false) + FieldKey.CARD_NUMBER -> FieldMetadata("Card Number", FieldType.TEXT, true, false) + FieldKey.CARD_CARDHOLDER_NAME -> FieldMetadata("Cardholder Name", FieldType.TEXT, false, false) + FieldKey.CARD_EXPIRY_MONTH -> FieldMetadata("Expiry Month", FieldType.TEXT, false, false) + FieldKey.CARD_EXPIRY_YEAR -> FieldMetadata("Expiry Year", FieldType.TEXT, false, false) + FieldKey.CARD_CVV -> FieldMetadata("CVV", FieldType.PASSWORD, true, false) + FieldKey.CARD_PIN -> FieldMetadata("PIN", FieldType.PASSWORD, true, false) + FieldKey.ALIAS_FIRST_NAME -> FieldMetadata("First Name", FieldType.TEXT, false, false) + FieldKey.ALIAS_LAST_NAME -> FieldMetadata("Last Name", FieldType.TEXT, false, false) + FieldKey.ALIAS_GENDER -> FieldMetadata("Gender", FieldType.TEXT, false, false) + FieldKey.ALIAS_BIRTHDATE -> FieldMetadata("Birth Date", FieldType.DATE, false, false) + FieldKey.NOTES_CONTENT -> FieldMetadata("Notes", FieldType.TEXT_AREA, false, false) + else -> FieldMetadata(fieldKey, FieldType.TEXT, false, false) + } + } + + /** + * Execute a SELECT query that may return BLOB columns. + * Unlike executeQuery which converts all params to strings, this preserves ByteArray types. + */ + private fun executeQueryWithBlobs(query: String, params: Array): List> { + val db = database.dbConnection ?: error("Database not initialized") + val cursor = db.query(query, params.map { it?.toString() }.toTypedArray()) + + val results = mutableListOf>() + cursor.use { + val columnNames = it.columnNames + while (it.moveToNext()) { + val row = mutableMapOf() + for (columnName in columnNames) { + when (it.getType(it.getColumnIndexOrThrow(columnName))) { + android.database.Cursor.FIELD_TYPE_NULL -> row[columnName] = null + android.database.Cursor.FIELD_TYPE_INTEGER -> row[columnName] = it.getLong( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_FLOAT -> row[columnName] = it.getDouble( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_STRING -> row[columnName] = it.getString( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_BLOB -> row[columnName] = it.getBlob( + it.getColumnIndexOrThrow(columnName), + ) + } + } + results.add(row) + } + } + + return results + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/PasskeyRepository.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/PasskeyRepository.kt index 7aeb40682..1b95fdd29 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/PasskeyRepository.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/PasskeyRepository.kt @@ -1,5 +1,6 @@ package net.aliasvault.app.vaultstore.repositories +import android.database.Cursor import android.util.Log import net.aliasvault.app.utils.DateHelpers import net.aliasvault.app.vaultstore.VaultDatabase @@ -423,6 +424,242 @@ class PasskeyRepository(database: VaultDatabase) : BaseRepository(database) { } } + // MARK: - Complex Query Operations + + /** + * Get passkeys with item info for a specific rpId and optionally username. + * Used for finding existing passkeys that might be replaced during registration. + * + * @param rpId The relying party identifier. + * @param userName Optional username to filter by. + * @param userId Optional user ID bytes to filter by. + * @return List of PasskeyWithCredentialInfo objects. + */ + fun getWithCredentialInfo( + rpId: String, + userName: String? = null, + userId: ByteArray? = null, + ): List { + val db = database.dbConnection ?: return emptyList() + + val query = """ + SELECT p.Id, p.ItemId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey, + p.DisplayName, p.CreatedAt, p.UpdatedAt, p.IsDeleted, + i.Name, + fv_username.Value as Username + FROM Passkeys p + INNER JOIN Items i ON p.ItemId = i.Id + LEFT JOIN FieldValues fv_username ON fv_username.ItemId = i.Id + AND fv_username.FieldKey = ? + AND fv_username.IsDeleted = 0 + WHERE p.RpId = ? AND p.IsDeleted = 0 AND i.IsDeleted = 0 AND i.DeletedAt IS NULL + ORDER BY p.CreatedAt DESC + """.trimIndent() + + val results = mutableListOf() + val cursor = db.query(query, arrayOf(FieldKey.LOGIN_USERNAME, rpId)) + + cursor.use { + while (it.moveToNext()) { + val passkey = parsePasskeyFromCursor(it) ?: continue + val itemName = if (!it.isNull(11)) it.getString(11) else null + val itemUsername = if (!it.isNull(12)) it.getString(12) else null + + // Filter by username or userId if provided + var matches = true + if (userName != null && itemUsername != userName) { + matches = false + } + if (userId != null && passkey.userHandle != null && !userId.contentEquals(passkey.userHandle)) { + matches = false + } + + if (matches) { + results.add( + PasskeyWithCredentialInfo( + passkey = passkey, + serviceName = itemName, + username = itemUsername, + ), + ) + } + } + } + + return results + } + + /** + * Get Items that match an rpId but don't have a passkey yet. + * Used for finding existing credentials that could have a passkey added to them. + * + * @param rpId The relying party identifier to match against the login URL. + * @param userName Optional username to filter by. + * @return List of ItemWithCredentialInfo objects representing Items without passkeys. + */ + fun getItemsWithoutPasskeyForRpId( + rpId: String, + userName: String? = null, + ): List { + val db = database.dbConnection ?: return emptyList() + + val query = """ + SELECT i.Id, i.Name, i.CreatedAt, i.UpdatedAt, + fv_url.Value as Url, + fv_username.Value as Username, + fv_password.Value as Password + FROM Items i + INNER JOIN FieldValues fv_url ON fv_url.ItemId = i.Id + AND fv_url.FieldKey = ? + AND fv_url.IsDeleted = 0 + LEFT JOIN FieldValues fv_username ON fv_username.ItemId = i.Id + AND fv_username.FieldKey = ? + AND fv_username.IsDeleted = 0 + LEFT JOIN FieldValues fv_password ON fv_password.ItemId = i.Id + AND fv_password.FieldKey = ? + AND fv_password.IsDeleted = 0 + WHERE i.IsDeleted = 0 + AND i.DeletedAt IS NULL + AND i.ItemType = 'Login' + AND (LOWER(fv_url.Value) LIKE ? OR LOWER(fv_url.Value) LIKE ?) + AND NOT EXISTS ( + SELECT 1 FROM Passkeys p + WHERE p.ItemId = i.Id AND p.IsDeleted = 0 + ) + ORDER BY i.UpdatedAt DESC + """.trimIndent() + + val rpIdLower = rpId.lowercase() + val urlPattern1 = "%$rpIdLower%" + val urlPattern2 = "%${rpIdLower.replace("www.", "")}%" + + val results = mutableListOf() + val cursor = db.query( + query, + arrayOf( + FieldKey.LOGIN_URL, + FieldKey.LOGIN_USERNAME, + FieldKey.LOGIN_PASSWORD, + urlPattern1, + urlPattern2, + ), + ) + + cursor.use { + while (it.moveToNext()) { + val itemIdString = it.getString(0) + val itemName = if (!it.isNull(1)) it.getString(1) else null + val itemCreatedAt = if (!it.isNull(2)) it.getString(2) else null + val itemUpdatedAt = if (!it.isNull(3)) it.getString(3) else null + val url = if (!it.isNull(4)) it.getString(4) else null + val itemUsername = if (!it.isNull(5)) it.getString(5) else null + val hasPassword = !it.isNull(6) && it.getString(6).isNotEmpty() + + // Filter by username if provided + if (userName != null && itemUsername != userName) { + continue + } + + try { + val itemId = UUID.fromString(itemIdString) + val createdAt = DateHelpers.parseDateString(itemCreatedAt ?: "") ?: MIN_DATE + val updatedAt = DateHelpers.parseDateString(itemUpdatedAt ?: "") ?: MIN_DATE + + results.add( + ItemWithCredentialInfo( + itemId = itemId, + serviceName = itemName, + url = url, + username = itemUsername, + hasPassword = hasPassword, + createdAt = createdAt, + updatedAt = updatedAt, + ), + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing item row", e) + } + } + } + + return results + } + + /** + * Get all passkeys with their associated items in a single query. + * This is much more efficient than calling getForItem() for each item. + * + * @return List of PasskeyWithItem objects. + */ + fun getAllWithItems(): List { + val db = database.dbConnection ?: return emptyList() + + val query = """ + SELECT + p.Id, p.ItemId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey, + p.DisplayName, p.CreatedAt as PasskeyCreatedAt, p.UpdatedAt as PasskeyUpdatedAt, p.IsDeleted as PasskeyIsDeleted, + i.Id as ItemId, i.Name, i.CreatedAt as ItemCreatedAt, i.UpdatedAt as ItemUpdatedAt, + fv_username.Value as Username, + fv_email.Value as Email + FROM Passkeys p + INNER JOIN Items i ON p.ItemId = i.Id + LEFT JOIN FieldValues fv_username ON fv_username.ItemId = i.Id + AND fv_username.FieldKey = ? + AND fv_username.IsDeleted = 0 + LEFT JOIN FieldValues fv_email ON fv_email.ItemId = i.Id + AND fv_email.FieldKey = ? + AND fv_email.IsDeleted = 0 + WHERE p.IsDeleted = 0 AND i.IsDeleted = 0 AND i.DeletedAt IS NULL + ORDER BY p.CreatedAt DESC + """.trimIndent() + + val results = mutableListOf() + val cursor = db.query(query, arrayOf(FieldKey.LOGIN_USERNAME, FieldKey.LOGIN_EMAIL)) + + cursor.use { + while (it.moveToNext()) { + try { + // Parse passkey (columns 0-10) + val passkey = parsePasskeyFromJoinCursor(it) ?: continue + + // Parse item info (columns 11-14) + val itemId = UUID.fromString(it.getString(11)) + val itemName = if (!it.isNull(12)) it.getString(12) else null + val itemCreatedAt = DateHelpers.parseDateString(it.getString(13)) ?: MIN_DATE + val itemUpdatedAt = DateHelpers.parseDateString(it.getString(14)) ?: MIN_DATE + + @Suppress("UNUSED_VARIABLE") // Username field loaded for potential future use + val username = if (!it.isNull(15)) it.getString(15) else null + + @Suppress("UNUSED_VARIABLE") // Email field loaded for potential future use + val email = if (!it.isNull(16)) it.getString(16) else null + + // Create a minimal Item object with the data we have + val item = Item( + id = itemId, + name = itemName, + itemType = "Login", + logo = null, + folderId = null, + folderPath = null, + fields = emptyList(), // Not loading all fields for performance + hasPasskey = true, + hasAttachment = false, + hasTotp = false, + createdAt = itemCreatedAt, + updatedAt = itemUpdatedAt, + ) + + results.add(PasskeyWithItem(passkey, item)) + } catch (e: Exception) { + Log.e(TAG, "Error parsing passkey with item row", e) + } + } + } + + return results + } + // MARK: - Helper Methods /** @@ -470,4 +707,146 @@ class PasskeyRepository(database: VaultDatabase) : BaseRepository(database) { null } } + + /** + * Parse a passkey from a cursor (for direct database queries). + * Expects columns 0-10 to be passkey fields. + */ + private fun parsePasskeyFromCursor(cursor: Cursor): Passkey? { + return try { + val idString = cursor.getString(0) + val itemIdString = 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 itemId = UUID.fromString(itemIdString) + + 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) + + Passkey( + id = id, + parentItemId = itemId, + 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 from cursor", e) + null + } + } + + /** + * Parse a passkey from a JOIN query cursor. + * Expects columns 0-10 to be passkey fields. + */ + private fun parsePasskeyFromJoinCursor(cursor: Cursor): Passkey? { + return try { + val idString = cursor.getString(0) + val itemIdString = 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 itemId = UUID.fromString(itemIdString) + + 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) + + Passkey( + id = id, + parentItemId = itemId, + 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 from JOIN cursor", e) + null + } + } } + +// MARK: - Data Classes + +/** + * Data class to hold passkey with item info. + * + * @property passkey The passkey. + * @property serviceName The service name from the item. + * @property username The username from the item. + */ +data class PasskeyWithCredentialInfo( + val passkey: Passkey, + val serviceName: String?, + val username: String?, +) + +/** + * Data class to hold passkey with its associated item. + * + * @property passkey The passkey. + * @property item The item this passkey belongs to. + */ +data class PasskeyWithItem( + val passkey: Passkey, + val item: Item, +) + +/** + * Data class to hold Item info for Items without passkeys. + * Used for showing existing credentials that can have a passkey added. + * + * @property itemId The UUID of the item. + * @property serviceName The service name (Item.Name). + * @property url The login URL. + * @property username The username from field values. + * @property hasPassword Whether the item has a password. + * @property createdAt When the item was created. + * @property updatedAt When the item was last updated. + */ +data class ItemWithCredentialInfo( + val itemId: UUID, + val serviceName: String?, + val url: String?, + val username: String?, + val hasPassword: Boolean, + val createdAt: Date, + val updatedAt: Date, +)