diff --git a/apps/mobile-app/android/app/src/main/AndroidManifest.xml b/apps/mobile-app/android/app/src/main/AndroidManifest.xml index 4456043e2..939a9841c 100644 --- a/apps/mobile-app/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile-app/android/app/src/main/AndroidManifest.xml @@ -75,6 +75,13 @@ android:theme="@style/zxing_CaptureTheme" android:screenOrientation="portrait" /> + + + diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillFillActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillFillActivity.kt new file mode 100644 index 000000000..d82c1398b --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillFillActivity.kt @@ -0,0 +1,146 @@ +package net.aliasvault.app.autofill + +import android.app.Activity +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.PersistableBundle +import android.service.autofill.Dataset +import android.util.Log +import android.view.autofill.AutofillId +import android.view.autofill.AutofillManager +import android.widget.RemoteViews +import net.aliasvault.app.R +import net.aliasvault.app.autofill.models.FieldType +import net.aliasvault.app.autofill.utils.AutofillFieldMapper +import net.aliasvault.app.utils.TotpGenerator +import net.aliasvault.app.vaultstore.VaultStore + +/** + * Transparent activity launched by the OS when the user picks an autofill + * credential row (wired via `Dataset.setAuthentication(IntentSender)`). + * Optionally copies the item's current TOTP code to the clipboard when + * [EXTRA_COPY_TOTP] is set, then builds the fill `Dataset` from the item's + * stored values and returns it via `AutofillManager.EXTRA_AUTHENTICATION_RESULT`. + */ +class AutofillFillActivity : Activity() { + + companion object { + private const val TAG = "AliasVaultAutofill" + + /** Intent extra for the vault item ID whose credentials should be filled. */ + const val EXTRA_ITEM_ID = "net.aliasvault.app.autofill.EXTRA_ITEM_ID" + + /** Intent extra holding the parceled `AutofillId`s for the target form fields. */ + const val EXTRA_AUTOFILL_IDS = "net.aliasvault.app.autofill.EXTRA_AUTOFILL_IDS" + + /** Intent extra holding the `FieldType` ordinals matching `EXTRA_AUTOFILL_IDS` one-to-one. */ + const val EXTRA_FIELD_TYPES = "net.aliasvault.app.autofill.EXTRA_FIELD_TYPES" + + /** Intent extra controlling whether the item's current TOTP code is copied to the clipboard. */ + const val EXTRA_COPY_TOTP = "net.aliasvault.app.autofill.EXTRA_COPY_TOTP" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + try { + val itemId = intent.getStringExtra(EXTRA_ITEM_ID) + val autofillIds = parseAutofillIds(intent) + val fieldTypeOrdinals = intent.getIntArrayExtra(EXTRA_FIELD_TYPES) + val copyTotp = intent.getBooleanExtra(EXTRA_COPY_TOTP, false) + + if (itemId == null || autofillIds == null || fieldTypeOrdinals == null || + autofillIds.size != fieldTypeOrdinals.size + ) { + Log.w(TAG, "AutofillFillActivity: missing or mismatched extras, finishing") + setResult(RESULT_CANCELED) + finish() + return + } + + val store = VaultStore.getExistingInstance() + if (store == null || !store.isVaultUnlocked()) { + Log.w(TAG, "AutofillFillActivity: vault not available, finishing") + setResult(RESULT_CANCELED) + finish() + return + } + + val item = store.getAllItems().firstOrNull { it.id.toString().equals(itemId, ignoreCase = true) } + if (item == null) { + Log.w(TAG, "AutofillFillActivity: item not found, finishing") + setResult(RESULT_CANCELED) + finish() + return + } + + if (copyTotp) { + tryCopyTotpToClipboard(store, itemId) + } + + val fields = pairFields(autofillIds, fieldTypeOrdinals) + // Presentation passed to the inner Dataset.Builder is never displayed + // — the OS already showed the outer dataset's presentation in the + // picker — but the legacy constructor requires a valid RemoteViews. + val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_icon) + val builder = Dataset.Builder(presentation) + AutofillFieldMapper.applyItem(builder, item, fields) + + val resultIntent = Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, builder.build()) + } + setResult(RESULT_OK, resultIntent) + } catch (e: Exception) { + Log.e(TAG, "AutofillFillActivity error", e) + setResult(RESULT_CANCELED) + } + finish() + } + + @Suppress("DEPRECATION") + private fun parseAutofillIds(intent: Intent): Array? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayExtra(EXTRA_AUTOFILL_IDS, AutofillId::class.java) + } else { + intent.getParcelableArrayExtra(EXTRA_AUTOFILL_IDS) + ?.mapNotNull { it as? AutofillId } + ?.toTypedArray() + } + } + + private fun pairFields( + autofillIds: Array, + fieldTypeOrdinals: IntArray, + ): List> { + val types = FieldType.values() + return autofillIds.mapIndexed { i, id -> + id to (types.getOrNull(fieldTypeOrdinals[i]) ?: FieldType.UNKNOWN) + } + } + + private fun tryCopyTotpToClipboard(store: VaultStore, itemId: String) { + val secret = store.getTotpSecretForItem(itemId) ?: return + val code = TotpGenerator.generateCode(secret) ?: return + if (code.isEmpty()) return + + try { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("AliasVault", code) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + clip.description.extras = extras + } + clipboard.setPrimaryClip(clip) + Log.d(TAG, "TOTP code copied to clipboard during autofill") + } catch (e: Exception) { + Log.e(TAG, "Failed to copy TOTP code to clipboard", e) + } + } +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt index 6d068e88e..ee77c13e5 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt @@ -26,7 +26,7 @@ import android.view.autofill.AutofillValue import android.widget.RemoteViews import net.aliasvault.app.MainActivity import net.aliasvault.app.R -import net.aliasvault.app.autofill.models.FieldType +import net.aliasvault.app.autofill.utils.AutofillFieldMapper import net.aliasvault.app.autofill.utils.FieldFinder import net.aliasvault.app.autofill.utils.ImageUtils import net.aliasvault.app.autofill.utils.RustItemMatcher @@ -176,6 +176,7 @@ class AutofillService : AutofillService() { // Add debug dataset if enabled in settings val sharedPreferences = getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE) val showSearchText = sharedPreferences.getBoolean("autofill_show_search_text", false) + val copyTotpOnFill = sharedPreferences.getBoolean("autofill_copy_totp_on_fill", true) if (showSearchText) { responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown")) } @@ -190,9 +191,12 @@ class AutofillService : AutofillService() { } else { // If there are matches, add them to the dataset for (item in filteredItems) { - responseBuilder.addDataset( - createItemDataset(fieldFinder, item), + val dataset = createItemDataset( + fieldFinder = fieldFinder, + item = item, + copyTotpOnSelect = copyTotpOnFill && item.hasTotp, ) + responseBuilder.addDataset(dataset) } // Add "Open app" option at the bottom (when search text is not shown and there are matches) @@ -245,124 +249,70 @@ class AutofillService : AutofillService() { } /** - * Create a dataset from an item. - * @param fieldFinder The field finder - * @param item The item - * @return The dataset + * Build the picker Dataset for an item. Sets presentation (label + icon), + * stages placeholder values via [AutofillFieldMapper], and wires + * `Dataset.setAuthentication` to [AutofillFillActivity], passing + * [copyTotpOnSelect] through so the activity copies the TOTP code on + * selection when requested. The OS discards the placeholder values and + * substitutes the dataset returned by the activity. */ - private fun createItemDataset(fieldFinder: FieldFinder, item: Item): Dataset { - // Always use icon layout (will show logo or placeholder icon) - val layoutId = R.layout.autofill_dataset_item_icon + private fun createItemDataset( + fieldFinder: FieldFinder, + item: Item, + copyTotpOnSelect: Boolean, + ): Dataset { + val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_icon) + val builder = Dataset.Builder(presentation) - // Create presentation for this item using our custom layout - val presentation = RemoteViews(packageName, layoutId) - - val dataSetBuilder = Dataset.Builder(presentation) - - // Add autofill values for all fields - var presentationDisplayValue = item.name - var hasSetValue = false - for (field in fieldFinder.autofillableFields) { - val fieldType = field.second - when (fieldType) { - FieldType.PASSWORD -> { - if (!item.password.isNullOrEmpty()) { - dataSetBuilder.setValue( - field.first, - AutofillValue.forText(item.password as CharSequence), - ) - hasSetValue = true - } - } - FieldType.EMAIL -> { - if (!item.email.isNullOrEmpty()) { - dataSetBuilder.setValue( - field.first, - AutofillValue.forText(item.email), - ) - hasSetValue = true - presentationDisplayValue += " (${item.email})" - } else if (!item.username.isNullOrEmpty()) { - dataSetBuilder.setValue( - field.first, - AutofillValue.forText(item.username), - ) - hasSetValue = true - presentationDisplayValue += " (${item.username})" - } - } - FieldType.USERNAME -> { - if (!item.username.isNullOrEmpty()) { - dataSetBuilder.setValue( - field.first, - AutofillValue.forText(item.username), - ) - hasSetValue = true - presentationDisplayValue += " (${item.username})" - } else if (!item.email.isNullOrEmpty()) { - dataSetBuilder.setValue( - field.first, - AutofillValue.forText(item.email), - ) - hasSetValue = true - presentationDisplayValue += " (${item.email})" - } - } - else -> { - // For unknown field types, try both email and username - if (!item.email.isNullOrEmpty()) { - dataSetBuilder.setValue( - field.first, - AutofillValue.forText(item.email), - ) - hasSetValue = true - presentationDisplayValue += " (${item.email})" - } else if (!item.username.isNullOrEmpty()) { - dataSetBuilder.setValue( - field.first, - AutofillValue.forText(item.username), - ) - hasSetValue = true - presentationDisplayValue += " (${item.username})" - } - } - } - } - - // If no value was set, this shouldn't happen now since we filter items - // but keep as safety measure - if (!hasSetValue && fieldFinder.autofillableFields.isNotEmpty()) { + val applyResult = AutofillFieldMapper.applyItem(builder, item, fieldFinder.autofillableFields) + if (!applyResult.hasValue && fieldFinder.autofillableFields.isNotEmpty()) { Log.w(TAG, "Item ${item.name} has no autofillable data - this should have been filtered") - dataSetBuilder.setValue( + builder.setValue( fieldFinder.autofillableFields.first().first, AutofillValue.forText(""), ) } - // Set the display value of the dropdown item. - presentation.setTextViewText( - R.id.text, - presentationDisplayValue, - ) + val displayValue = if (applyResult.labelSuffix != null) { + "${item.name} (${applyResult.labelSuffix})" + } else { + item.name + } + presentation.setTextViewText(R.id.text, displayValue) - // Set the logo if available, otherwise use placeholder icon val logoBytes = item.logo val bitmap = if (logoBytes != null) { ImageUtils.bytesToBitmap(logoBytes) } else { - // Use placeholder key icon for Login/Alias items ItemTypeIcon.getIcon( context = this@AutofillService, itemType = ItemTypeIcon.ItemType.LOGIN, size = 96, ) } - if (bitmap != null) { presentation.setImageViewBitmap(R.id.icon, bitmap) } - return dataSetBuilder.build() + val autofillIds = fieldFinder.autofillableFields.map { it.first }.toTypedArray() + val fieldTypeOrdinals = IntArray(fieldFinder.autofillableFields.size) { i -> + fieldFinder.autofillableFields[i].second.ordinal + } + val authIntent = Intent(this, AutofillFillActivity::class.java).apply { + putExtra(AutofillFillActivity.EXTRA_ITEM_ID, item.id.toString().uppercase()) + putExtra(AutofillFillActivity.EXTRA_AUTOFILL_IDS, autofillIds) + putExtra(AutofillFillActivity.EXTRA_FIELD_TYPES, fieldTypeOrdinals) + putExtra(AutofillFillActivity.EXTRA_COPY_TOTP, copyTotpOnSelect) + } + val pendingIntent = PendingIntent.getActivity( + this, + item.id.hashCode(), + authIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT, + ) + builder.setAuthentication(pendingIntent.intentSender) + + return builder.build() } /** diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/AutofillFieldMapper.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/AutofillFieldMapper.kt new file mode 100644 index 000000000..e69080861 --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/AutofillFieldMapper.kt @@ -0,0 +1,61 @@ +package net.aliasvault.app.autofill.utils + +import android.service.autofill.Dataset +import android.view.autofill.AutofillId +import android.view.autofill.AutofillValue +import net.aliasvault.app.autofill.models.FieldType +import net.aliasvault.app.vaultstore.models.Item + +/** + * Maps a vault [Item] onto the detected autofill fields, setting values on a + * `Dataset.Builder` and reporting which value was used as the picker label + * suffix. + */ +object AutofillFieldMapper { + /** + * Result of applying an item to a dataset builder. + * + * @property hasValue True if at least one field received a value. + * @property labelSuffix First non-password value that was set (email or + * username), suitable for appending to the picker row label, or null if + * only the password field was set. + */ + data class ApplyResult(val hasValue: Boolean, val labelSuffix: String?) + + /** + * Set autofill values on [builder] for each of [fields] using the + * corresponding data from [item]. Empty values are skipped. + */ + fun applyItem( + builder: Dataset.Builder, + item: Item, + fields: List>, + ): ApplyResult { + var hasValue = false + var labelSuffix: String? = null + + for ((autofillId, fieldType) in fields) { + val value = pickValue(item, fieldType) ?: continue + builder.setValue(autofillId, AutofillValue.forText(value)) + hasValue = true + if (labelSuffix == null && fieldType != FieldType.PASSWORD) { + labelSuffix = value + } + } + + return ApplyResult(hasValue, labelSuffix) + } + + private fun pickValue(item: Item, fieldType: FieldType): String? = when (fieldType) { + FieldType.PASSWORD -> item.password.takeUnless { it.isNullOrEmpty() } + FieldType.EMAIL -> + item.email.takeUnless { it.isNullOrEmpty() } + ?: item.username.takeUnless { it.isNullOrEmpty() } + FieldType.USERNAME -> + item.username.takeUnless { it.isNullOrEmpty() } + ?: item.email.takeUnless { it.isNullOrEmpty() } + FieldType.UNKNOWN -> + item.email.takeUnless { it.isNullOrEmpty() } + ?: item.username.takeUnless { it.isNullOrEmpty() } + } +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index 60156f3c4..1e46bbd1f 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -896,6 +896,57 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : } } + /** + * Get the autofill copy-TOTP-on-fill setting. + * Defaults to true when not yet set. + * @param promise The promise to resolve with boolean result + */ + @ReactMethod + override fun getAutofillCopyTotpOnFill(promise: Promise) { + try { + val sharedPreferences = reactApplicationContext.getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE) + val enabled = sharedPreferences.getBoolean("autofill_copy_totp_on_fill", true) + promise.resolve(enabled) + } catch (e: Exception) { + Log.e(TAG, "Error getting autofill copy-TOTP-on-fill setting", e) + promise.reject("ERR_GET_AUTOFILL_SETTING", "Failed to get autofill copy-TOTP-on-fill setting: ${e.message}", e) + } + } + + /** + * Set the autofill copy-TOTP-on-fill setting. + * @param enabled Whether to copy TOTP code to clipboard on autofill + * @param promise The promise to resolve + */ + @ReactMethod + override fun setAutofillCopyTotpOnFill(enabled: Boolean, promise: Promise) { + try { + val sharedPreferences = reactApplicationContext.getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE) + sharedPreferences.edit().putBoolean("autofill_copy_totp_on_fill", enabled).apply() + promise.resolve(null) + } catch (e: Exception) { + Log.e(TAG, "Error setting autofill copy-TOTP-on-fill setting", e) + promise.reject("ERR_SET_AUTOFILL_SETTING", "Failed to set autofill copy-TOTP-on-fill setting: ${e.message}", e) + } + } + + /** + * Generate a TOTP code from a Base32-encoded secret. + * Delegates to the shared Kotlin TotpGenerator so the JS layer can reuse + * the same RFC 6238 implementation as the autofill service. Returns null + * for invalid secrets. + */ + @ReactMethod + override fun generateTotpCode(secret: String, promise: Promise) { + try { + val code = net.aliasvault.app.utils.TotpGenerator.generateCode(secret) + promise.resolve(code) + } catch (e: Exception) { + Log.e(TAG, "Error generating TOTP code", e) + promise.reject("ERR_TOTP_GENERATE", "Failed to generate TOTP code: ${e.message}", e) + } + } + /** * Get the current fragment activity. * @return The fragment activity diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/TotpGenerator.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/TotpGenerator.kt new file mode 100644 index 000000000..0bae23670 --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/TotpGenerator.kt @@ -0,0 +1,91 @@ +package net.aliasvault.app.utils + +import android.util.Log +import java.nio.ByteBuffer +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.experimental.and + +/** + * RFC 6238 TOTP generator. + */ +object TotpGenerator { + private const val TAG = "TotpGenerator" + private const val DEFAULT_PERIOD = 30 + private const val DEFAULT_DIGITS = 6 + + /** + * Generate the current TOTP code for a Base32-encoded secret. + * Returns null when the secret is invalid or HMAC fails. + */ + fun generateCode( + secret: String, + timeSeconds: Long = System.currentTimeMillis() / 1000L, + period: Int = DEFAULT_PERIOD, + digits: Int = DEFAULT_DIGITS, + ): String? { + val secretBytes = base32Decode(secret) ?: return null + if (secretBytes.isEmpty()) return null + + val counter = timeSeconds / period + return generateHotp(secretBytes, counter, digits) + } + + private fun generateHotp(secret: ByteArray, counter: Long, digits: Int): String? { + return try { + val counterBytes = ByteBuffer.allocate(Long.SIZE_BYTES).putLong(counter).array() + + val mac = Mac.getInstance("HmacSHA1") + mac.init(SecretKeySpec(secret, "HmacSHA1")) + val hash = mac.doFinal(counterBytes) + + // Dynamic truncation per RFC 4226 + val offset = (hash[hash.size - 1] and 0x0F).toInt() + val binary = ((hash[offset].toInt() and 0x7F) shl 24) or + ((hash[offset + 1].toInt() and 0xFF) shl 16) or + ((hash[offset + 2].toInt() and 0xFF) shl 8) or + (hash[offset + 3].toInt() and 0xFF) + + val mod = pow10(digits) + val code = binary % mod + code.toString().padStart(digits, '0') + } catch (e: Exception) { + Log.w(TAG, "TOTP HMAC generation failed", e) + null + } + } + + private fun pow10(n: Int): Int { + var result = 1 + repeat(n) { result *= 10 } + return result + } + + /** + * Decode a Base32 (RFC 4648) string to bytes. Tolerates lowercase, spaces, + * and trailing padding ('='). Returns null on invalid characters. + */ + private fun base32Decode(input: String): ByteArray? { + val cleaned = input.uppercase().replace(" ", "").replace("=", "") + if (cleaned.isEmpty()) return ByteArray(0) + + val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + val output = ByteArray((cleaned.length * 5) / 8) + var buffer = 0 + var bitsLeft = 0 + var index = 0 + + for (ch in cleaned) { + val value = alphabet.indexOf(ch) + if (value < 0) return null + buffer = (buffer shl 5) or value + bitsLeft += 5 + if (bitsLeft >= 8) { + output[index++] = ((buffer shr (bitsLeft - 8)) and 0xFF).toByte() + bitsLeft -= 8 + } + } + + return output.copyOfRange(0, index) + } +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt index 7e030d840..9952ac6d6 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt @@ -441,6 +441,23 @@ class VaultStore( return itemRepository.getAll() } + /** + * Get the first non-deleted TOTP secret for an item, or null when none exists. + * Used by the autofill service to copy a credential's current TOTP code to + * the clipboard at fill time. + */ + fun getTotpSecretForItem(itemId: String): String? { + if (!database.isVaultUnlocked()) { + return null + } + return try { + itemRepository.getTotpSecretForItem(itemId) + } catch (e: Exception) { + android.util.Log.e(TAG, "Error getting TOTP secret for item", e) + null + } + } + /** * Attempts to get all items using only the cached encryption key. */ diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt index b84a15deb..a96749af3 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt @@ -389,6 +389,22 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { return (results.firstOrNull()?.get("count") as? Long)?.toInt() ?: 0 } + /** + * Get the first non-deleted TOTP secret for an item, or null when there is none. + * Used by the autofill service to copy the current TOTP code to the clipboard + * when the user selects a credential to fill. + * + * @param itemId The UUID of the item. + * @return The Base32 secret key string, or null. + */ + fun getTotpSecretForItem(itemId: String): String? { + val results = executeQuery( + "SELECT SecretKey FROM TotpCodes WHERE ItemId = ? AND IsDeleted = 0 ORDER BY Name ASC LIMIT 1", + arrayOf(itemId.uppercase()), + ) + return results.firstOrNull()?.get("SecretKey") as? String + } + // MARK: - Write Operations /** diff --git a/apps/mobile-app/android/app/src/main/res/values/styles.xml b/apps/mobile-app/android/app/src/main/res/values/styles.xml index 3be6b3720..6fc3bf63f 100644 --- a/apps/mobile-app/android/app/src/main/res/values/styles.xml +++ b/apps/mobile-app/android/app/src/main/res/values/styles.xml @@ -52,4 +52,5 @@ @android:color/transparent @style/AppTheme +