Refactor Kotlin queries to use repository pattern (#1404)

This commit is contained in:
Leendert de Borst
2026-01-18 19:37:26 +01:00
parent 69c2cb65bb
commit 57a5f32038
9 changed files with 935 additions and 832 deletions

View File

@@ -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

View File

@@ -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
/**

View File

@@ -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 {

View File

@@ -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<PasskeyWithCredentialInfo> {
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<PasskeyWithCredentialInfo>()
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<ItemWithCredentialInfo> {
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<ItemWithCredentialInfo>()
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<PasskeyWithItem> {
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<PasskeyWithItem>()
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.
*/

View File

@@ -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<Any?>): List<Map<String, Any?>> {
val results = mutableListOf<Map<String, Any?>>()
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<String, Any?>()
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<Any?>): 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<Item> {
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<Item>()
val itemIds = mutableListOf<String>()
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<String, MutableList<ItemField>>()
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
}

View File

@@ -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<Any?>): List<Map<String, Any?>> {
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<Map<String, Any?>>()
cursor.use {
val columnNames = it.columnNames
while (it.moveToNext()) {
val row = mutableMapOf<String, Any?>()
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<Any?>): 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<Item> {
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<PasskeyWithCredentialInfo> {
): List<net.aliasvault.app.vaultstore.repositories.PasskeyWithCredentialInfo> {
return passkey.getPasskeysWithCredentialInfo(rpId, userName, userId)
}
/**
* Get all passkeys with their associated items in a single query.
*/
fun getAllPasskeysWithItems(): List<PasskeyWithItem> {
fun getAllPasskeysWithItems(): List<net.aliasvault.app.vaultstore.repositories.PasskeyWithItem> {
return passkey.getAllPasskeysWithItems()
}
@@ -679,7 +763,7 @@ class VaultStore(
fun getItemsWithoutPasskeyForRpId(
rpId: String,
userName: String? = null,
): List<ItemWithCredentialInfo> {
): List<net.aliasvault.app.vaultstore.repositories.ItemWithCredentialInfo> {
return passkey.getItemsWithoutPasskeyForRpId(rpId, userName)
}

View File

@@ -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 {

View File

@@ -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<Item> {
// 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<Item>()
val itemIds = mutableListOf<String>()
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<String, MutableList<ItemField>>()
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<ItemField>()
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<Item> {
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<Item>()
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<Any?>): List<Map<String, Any?>> {
val db = database.dbConnection ?: error("Database not initialized")
val cursor = db.query(query, params.map { it?.toString() }.toTypedArray())
val results = mutableListOf<Map<String, Any?>>()
cursor.use {
val columnNames = it.columnNames
while (it.moveToNext()) {
val row = mutableMapOf<String, Any?>()
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
}
}

View File

@@ -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<PasskeyWithCredentialInfo> {
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<PasskeyWithCredentialInfo>()
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<ItemWithCredentialInfo> {
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<ItemWithCredentialInfo>()
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<PasskeyWithItem> {
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<PasskeyWithItem>()
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,
)