mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-12 01:13:30 -04:00
Refactor Kotlin queries to use repository pattern (#1404)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user