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
+
\ No newline at end of file
diff --git a/apps/mobile-app/app/(tabs)/settings/android-autofill.tsx b/apps/mobile-app/app/(tabs)/settings/android-autofill.tsx
index 2cba012c6..adaf5faf9 100644
--- a/apps/mobile-app/app/(tabs)/settings/android-autofill.tsx
+++ b/apps/mobile-app/app/(tabs)/settings/android-autofill.tsx
@@ -20,18 +20,23 @@ export default function AndroidAutofillScreen() : React.ReactNode {
const { markAutofillConfigured, shouldShowAutofillReminder } = useAuth();
const [advancedOptionsExpanded, setAdvancedOptionsExpanded] = useState(false);
const [showSearchText, setShowSearchText] = useState(false);
+ const [copyTotpOnFill, setCopyTotpOnFill] = useState(true);
/**
- * Load the show search text setting on mount.
+ * Load native autofill toggle settings on mount.
*/
useEffect(() => {
/**
- * Load the show search text setting.
+ * Read the persisted autofill toggle settings.
*/
const loadSettings = async () : Promise => {
try {
- const value = await NativeVaultManager.getAutofillShowSearchText();
- setShowSearchText(value);
+ const [searchText, totpOnFill] = await Promise.all([
+ NativeVaultManager.getAutofillShowSearchText(),
+ NativeVaultManager.getAutofillCopyTotpOnFill(),
+ ]);
+ setShowSearchText(searchText);
+ setCopyTotpOnFill(totpOnFill);
} catch (err) {
console.warn('Failed to load autofill settings:', err);
}
@@ -78,6 +83,18 @@ export default function AndroidAutofillScreen() : React.ReactNode {
}
};
+ /**
+ * Handle toggling the copy-TOTP-on-fill setting.
+ */
+ const handleToggleCopyTotpOnFill = async (value: boolean) : Promise => {
+ try {
+ await NativeVaultManager.setAutofillCopyTotpOnFill(value);
+ setCopyTotpOnFill(value);
+ } catch (err) {
+ console.warn('Failed to update copy-TOTP-on-fill setting:', err);
+ }
+ };
+
const styles = StyleSheet.create({
advancedOptionsContainer: {
marginTop: 16,
@@ -267,8 +284,8 @@ export default function AndroidAutofillScreen() : React.ReactNode {
{t('settings.androidAutofillSettings.step2')}
-
- {shouldShowAutofillReminder && (
+ {shouldShowAutofillReminder && (
+
- )}
-
+
+ )}
@@ -287,7 +304,7 @@ export default function AndroidAutofillScreen() : React.ReactNode {
onPress={() => setAdvancedOptionsExpanded(!advancedOptionsExpanded)}
>
- {t('settings.androidAutofillSettings.advancedOptions')}
+ {t('settings.advancedOptions')}
{advancedOptionsExpanded ? '▼' : '▶'}
@@ -296,6 +313,22 @@ export default function AndroidAutofillScreen() : React.ReactNode {
{advancedOptionsExpanded && (
+
+
+
+ {t('settings.copyTotpOnFill')}
+
+
+ {t('settings.copyTotpOnFillDescription')}
+
+
+
+
diff --git a/apps/mobile-app/app/(tabs)/settings/ios-autofill.tsx b/apps/mobile-app/app/(tabs)/settings/ios-autofill.tsx
index bd6f59770..ebe487395 100644
--- a/apps/mobile-app/app/(tabs)/settings/ios-autofill.tsx
+++ b/apps/mobile-app/app/(tabs)/settings/ios-autofill.tsx
@@ -1,6 +1,7 @@
import { router } from 'expo-router';
+import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { StyleSheet, View, TouchableOpacity } from 'react-native';
+import { StyleSheet, View, TouchableOpacity, Switch } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
@@ -17,6 +18,26 @@ export default function IosAutofillScreen() : React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const { markAutofillConfigured, shouldShowAutofillReminder } = useAuth();
+ const [advancedOptionsExpanded, setAdvancedOptionsExpanded] = useState(false);
+ const [copyTotpOnFill, setCopyTotpOnFill] = useState(true);
+
+ /**
+ * Load native autofill toggle settings on mount.
+ */
+ useEffect(() => {
+ /**
+ * Read the persisted copy-TOTP-on-fill setting.
+ */
+ const loadSettings = async () : Promise => {
+ try {
+ const value = await NativeVaultManager.getAutofillCopyTotpOnFill();
+ setCopyTotpOnFill(value);
+ } catch (err) {
+ console.warn('Failed to load autofill settings:', err);
+ }
+ };
+ loadSettings();
+ }, []);
/**
* Handle the configure press.
@@ -38,11 +59,42 @@ export default function IosAutofillScreen() : React.ReactNode {
router.back();
};
+ /**
+ * Handle toggling the copy-TOTP-on-fill setting.
+ */
+ const handleToggleCopyTotpOnFill = async (value: boolean) : Promise => {
+ try {
+ await NativeVaultManager.setAutofillCopyTotpOnFill(value);
+ setCopyTotpOnFill(value);
+ } catch (err) {
+ console.warn('Failed to update copy-TOTP-on-fill setting:', err);
+ }
+ };
+
const styles = StyleSheet.create({
+ advancedOptionsContainer: {
+ marginTop: 16,
+ paddingBottom: 16,
+ },
+ advancedOptionsTitle: {
+ color: colors.text,
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ advancedOptionsToggleHeader: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingVertical: 8,
+ },
buttonContainer: {
padding: 16,
paddingBottom: 32,
},
+ chevron: {
+ color: colors.textMuted,
+ fontSize: 20,
+ },
configureButton: {
alignItems: 'center',
backgroundColor: colors.primary,
@@ -100,11 +152,30 @@ export default function IosAutofillScreen() : React.ReactNode {
fontSize: 16,
fontWeight: '600',
},
- warningText: {
+ settingRow: {
+ alignItems: 'center',
+ backgroundColor: colors.accentBackground,
+ borderRadius: 10,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 12,
+ padding: 16,
+ },
+ settingRowDescription: {
color: colors.textMuted,
+ fontSize: 13,
+ lineHeight: 18,
+ marginTop: 4,
+ },
+ settingRowText: {
+ color: colors.text,
+ flex: 1,
+ marginRight: 12,
+ },
+ settingRowTitle: {
+ color: colors.text,
fontSize: 15,
- fontStyle: 'italic',
- marginTop: 8,
+ fontWeight: '500',
},
});
@@ -148,11 +219,8 @@ export default function IosAutofillScreen() : React.ReactNode {
{t('settings.iosAutofillSettings.step5')}
-
- {t('settings.iosAutofillSettings.warningText')}
-
-
- {shouldShowAutofillReminder && (
+ {shouldShowAutofillReminder && (
+
- )}
-
+
+ )}
+
+
+
+ setAdvancedOptionsExpanded(!advancedOptionsExpanded)}
+ >
+
+ {t('settings.advancedOptions')}
+
+
+ {advancedOptionsExpanded ? '▼' : '▶'}
+
+
+
+ {advancedOptionsExpanded && (
+
+
+
+
+ {t('settings.copyTotpOnFill')}
+
+
+ {t('settings.copyTotpOnFillDescription')}
+
+
+
+
+
+ )}
);
-}
\ No newline at end of file
+}
diff --git a/apps/mobile-app/components/items/ItemCard.tsx b/apps/mobile-app/components/items/ItemCard.tsx
index cec065894..f01d7fa31 100644
--- a/apps/mobile-app/components/items/ItemCard.tsx
+++ b/apps/mobile-app/components/items/ItemCard.tsx
@@ -165,7 +165,7 @@ export function ItemCard({ item, onItemDelete, showFolderPath = false }: ItemCar
const totpCodes = await dbContext.sqliteClient.settings.getTotpCodesForItem(item.Id);
const activeTotp = totpCodes.find(tc => !tc.IsDeleted);
if (activeTotp) {
- const code = generateTotpCode(activeTotp.SecretKey);
+ const code = await generateTotpCode(activeTotp.SecretKey);
if (code) {
await copyToClipboard(code);
if (Platform.OS === 'ios') {
diff --git a/apps/mobile-app/components/items/details/TotpEditor.tsx b/apps/mobile-app/components/items/details/TotpEditor.tsx
index b042e1baf..2eedb3b3e 100644
--- a/apps/mobile-app/components/items/details/TotpEditor.tsx
+++ b/apps/mobile-app/components/items/details/TotpEditor.tsx
@@ -1,6 +1,5 @@
import { Ionicons } from '@expo/vector-icons';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
-import * as OTPAuth from 'otpauth';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TextInput, Modal, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
@@ -12,6 +11,7 @@ import { useDialog } from '@/context/DialogContext';
import { useColors } from '@/hooks/useColorScheme';
import NativeVaultManager from '@/specs/NativeVaultManager';
import type { TotpCode } from '@/utils/dist/core/models/vault';
+import { parseOtpAuthUri } from '@/utils/TotpUtility';
type TotpFormData = {
name: string;
@@ -104,28 +104,21 @@ export const TotpEditor: React.FC = ({
if (scannedData) {
// Parse the otpauth:// URL
- try {
- const uri = OTPAuth.URI.parse(scannedData);
- if (uri instanceof OTPAuth.TOTP) {
- const secretKey = uri.secret.base32;
- const name = uri.label || 'Authenticator';
+ const parsed = parseOtpAuthUri(scannedData);
+ if (parsed && parsed.type === 'totp') {
+ const secretKey = parsed.secret.replace(/\s/g, '').replace(/=+$/, '');
+ const name = parsed.label || 'Authenticator';
- // Create new TOTP code immediately
- const newTotpCode: TotpCode = {
- Id: crypto.randomUUID().toUpperCase(),
- Name: name,
- SecretKey: secretKey,
- ItemId: '' // Will be set when saving the item
- };
+ const newTotpCode: TotpCode = {
+ Id: crypto.randomUUID().toUpperCase(),
+ Name: name,
+ SecretKey: secretKey,
+ ItemId: '' // Will be set when saving the item
+ };
- // Add to the list
- const updatedTotpCodes = [...totpCodes, newTotpCode];
- onTotpCodesChange(updatedTotpCodes);
- } else {
- showAlert(t('common.error'), t('totp.errors.scanFailed'));
- }
- } catch (error) {
- console.error('Error parsing TOTP QR code:', error);
+ const updatedTotpCodes = [...totpCodes, newTotpCode];
+ onTotpCodesChange(updatedTotpCodes);
+ } else {
showAlert(t('common.error'), t('totp.errors.scanFailed'));
}
}
@@ -160,18 +153,15 @@ export const TotpEditor: React.FC = ({
// Check if it's a TOTP URI
if (secretKey.toLowerCase().startsWith('otpauth://totp/')) {
- try {
- const uri = OTPAuth.URI.parse(secretKey);
- if (uri instanceof OTPAuth.TOTP) {
- secretKey = uri.secret.base32;
- // If name is empty, use the label from the URI
- if (!name && uri.label) {
- name = uri.label;
- }
- }
- } catch {
+ const parsed = parseOtpAuthUri(secretKey);
+ if (!parsed || parsed.type !== 'totp') {
throw new Error(t('totp.errors.invalidSecretKey'));
}
+ secretKey = parsed.secret;
+ // If name is empty, use the label from the URI
+ if (!name && parsed.label) {
+ name = parsed.label;
+ }
}
// Remove spaces from the secret key
diff --git a/apps/mobile-app/components/items/details/TotpSection.tsx b/apps/mobile-app/components/items/details/TotpSection.tsx
index d21ae4278..0e9bd4ce0 100644
--- a/apps/mobile-app/components/items/details/TotpSection.tsx
+++ b/apps/mobile-app/components/items/details/TotpSection.tsx
@@ -1,4 +1,3 @@
-import * as OTPAuth from 'otpauth';
import React, { useState, useEffect } from 'react';
import { generateTotpCode } from '@/utils/TotpUtility';
@@ -43,16 +42,10 @@ export const TotpSection: React.FC = ({ item }) : React.ReactN
const { t } = useTranslation();
/**
- * Get the remaining seconds.
+ * Get the remaining seconds in the current TOTP window.
*/
const getRemainingSeconds = (step = 30): number => {
- const totp = new OTPAuth.TOTP({
- secret: 'dummy',
- algorithm: 'SHA1',
- digits: 6,
- period: step
- });
- return totp.period - (Math.floor(Date.now() / 1000) % totp.period);
+ return step - (Math.floor(Date.now() / 1000) % step);
};
/**
@@ -109,34 +102,38 @@ export const TotpSection: React.FC = ({ item }) : React.ReactN
}, [item, dbContext?.sqliteClient]);
useEffect(() => {
+ let cancelled = false;
+
/**
- * Update the totp codes.
+ * Generate codes for all current TOTP entries via the native bridge and
+ * push them into state. Falls back to "Error" only when no previous code
+ * exists for that entry, so the display doesn't flicker when a single
+ * tick fails.
*/
- const updateTotpCodes = (prevCodes: Record): Record => {
- const newCodes: Record = {};
- totpCodes.forEach(code => {
- const generatedCode = generateTotpCode(code.SecretKey);
- if (generatedCode) {
- newCodes[code.Id] = generatedCode;
- } else {
- newCodes[code.Id] = prevCodes[code.Id] ?? 'Error';
+ const refreshCodes = async () : Promise => {
+ const results = await Promise.all(
+ totpCodes.map(async (code) => ({
+ id: code.Id,
+ value: await generateTotpCode(code.SecretKey),
+ }))
+ );
+ if (cancelled) return;
+ setCurrentCodes(prev => {
+ const next: Record = {};
+ for (const { id, value } of results) {
+ next[id] = value || prev[id] || 'Error';
}
+ return next;
});
- return newCodes;
};
- const initialCodes: Record = {};
- totpCodes.forEach(code => {
- const codeStr = generateTotpCode(code.SecretKey);
- initialCodes[code.Id] = codeStr || 'Error';
- });
- setCurrentCodes(initialCodes);
+ refreshCodes();
+ const intervalId = setInterval(refreshCodes, 1000);
- const intervalId = setInterval(() => {
- setCurrentCodes(updateTotpCodes);
- }, 1000);
-
- return () : void => clearInterval(intervalId);
+ return () : void => {
+ cancelled = true;
+ clearInterval(intervalId);
+ };
}, [totpCodes]);
if (totpCodes.length === 0) {
diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json
index 26d05ac89..ff6d0546d 100644
--- a/apps/mobile-app/i18n/locales/en.json
+++ b/apps/mobile-app/i18n/locales/en.json
@@ -136,8 +136,7 @@
"step4": "4. Enable \"AliasVault\"",
"step5": "5. Disable other password providers (e.g. \"iCloud Passwords\") to avoid conflicts",
"openIosSettings": "Open iOS Settings",
- "alreadyConfigured": "I already configured it",
- "warningText": "Note: You'll need to authenticate with Face ID/Touch ID or your device passcode when using autofill."
+ "alreadyConfigured": "I already configured it"
},
"androidAutofillSettings": {
"warningTitle": "⚠️ Experimental Feature",
@@ -151,10 +150,12 @@
"buttonTip": "If the button above doesn't work it might be blocked because of security settings. You can manually go to Android Settings → General Management → Passwords and autofill.",
"step2": "2. Some apps, e.g. Google Chrome, may require manual configuration in their settings to allow third-party autofill apps. However, most apps should work with autofill by default.",
"alreadyConfigured": "I already configured it",
- "advancedOptions": "Advanced Options",
"showSearchText": "Show search text",
"showSearchTextDescription": "Include the text AliasVault receives from Android that it uses to search for a matching credential"
},
+ "advancedOptions": "Advanced Options",
+ "copyTotpOnFill": "Copy TOTP code on autofill",
+ "copyTotpOnFillDescription": "When you autofill a credential with 2FA, automatically copy its TOTP code to the clipboard so you can paste it into the next field.",
"vaultUnlock": "Vault Unlock Method",
"autoLock": "Auto-lock Timeout",
"clipboardClear": "Clear Clipboard",
diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift
index 1eb961a27..d9031c57a 100644
--- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift
+++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift
@@ -3,6 +3,7 @@ import SwiftUI
import VaultStoreKit
import VaultUI
import VaultModels
+import VaultUtils
/**
* Credential-specific functionality for CredentialProviderViewController
@@ -11,7 +12,6 @@ import VaultModels
extension CredentialProviderViewController: CredentialProviderDelegate {
// MARK: - CredentialProviderDelegate Implementation
-
func setupCredentialView(vaultStore: VaultStore, serviceUrl: String?) throws -> UIViewController {
// Create the ViewModel with injected behaviors
let viewModel = CredentialProviderViewModel(
@@ -124,6 +124,18 @@ extension CredentialProviderViewController: CredentialProviderDelegate {
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
}
+ // If the credential has a TOTP secret and the user has the
+ // copy-on-fill setting enabled (default), put the current
+ // TOTP code on the clipboard so they can paste it into the
+ // 2FA field after the autofill completes.
+ if matchingCredential.hasTotp,
+ let secret = matchingCredential.totpSecret,
+ AutofillSettings.shouldCopyTotpOnFill,
+ let code = TotpGenerator.generateCode(secret: secret),
+ !code.isEmpty {
+ UIPasteboard.general.string = code
+ }
+
// Use the identifier that matches the credential identity
let identifier = request.credentialIdentity.user
let passwordCredential = ASPasswordCredential(
diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
index 699d02e95..bc196cbee 100644
--- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
+++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
@@ -151,10 +151,22 @@
[vaultManager setAutofillShowSearchText:showSearchText resolver:resolve rejecter:reject];
}
+- (void)getAutofillCopyTotpOnFill:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
+ [vaultManager getAutofillCopyTotpOnFill:resolve rejecter:reject];
+}
+
+- (void)setAutofillCopyTotpOnFill:(BOOL)enabled resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
+ [vaultManager setAutofillCopyTotpOnFill:enabled resolver:resolve rejecter:reject];
+}
+
- (void)copyToClipboardWithExpiration:(NSString *)text expirationSeconds:(double)expirationSeconds localOnly:(BOOL)localOnly resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager copyToClipboardWithExpiration:text expirationSeconds:expirationSeconds localOnly:localOnly resolver:resolve rejecter:reject];
}
+- (void)generateTotpCode:(NSString *)secret resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
+ [vaultManager generateTotpCode:secret resolver:resolve rejecter:reject];
+}
+
// MARK: - Android-specific methods (stubs for iOS)
- (void)isIgnoringBatteryOptimizations:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
index b14175c6f..6b71c8b31 100644
--- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
+++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
@@ -5,6 +5,7 @@ import VaultStoreKit
import VaultModels
import SwiftUI
import VaultUI
+import VaultUtils
import AVFoundation
import RustCoreFramework
import AuthenticationServices
@@ -392,6 +393,29 @@ public class VaultManager: NSObject {
resolve(nil)
}
+ @objc
+ func getAutofillCopyTotpOnFill(_ resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ resolve(AutofillSettings.shouldCopyTotpOnFill)
+ }
+
+ @objc
+ func setAutofillCopyTotpOnFill(_ enabled: Bool,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ AutofillSettings.shouldCopyTotpOnFill = enabled
+ resolve(nil)
+ }
+
+ @objc
+ func generateTotpCode(_ secret: String,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ // Returns nil for invalid secrets; the JS side treats null as "code unavailable".
+ let code = TotpGenerator.generateCode(secret: secret)
+ resolve(code)
+ }
+
@objc
func copyToClipboardWithExpiration(_ text: String,
expirationSeconds: Double,
diff --git a/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift b/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift
deleted file mode 100644
index 0c8fbad5b..000000000
--- a/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-import Foundation
-import VaultModels
-
-/// Constants used for userDefaults keys and other things.
-public struct VaultConstants {
- static let keychainService = "net.aliasvault.autofill"
- static let keychainAccessGroup = "group.net.aliasvault.autofill"
- static let userDefaultsSuite = "group.net.aliasvault.autofill"
-
- static let vaultMetadataKey = "aliasvault_vault_metadata"
- static let encryptionKeyKey = "aliasvault_encryption_key"
- static let encryptedDbFileName = "encrypted_db.sqlite"
- static let authMethodsKey = "aliasvault_auth_methods"
- static let autoLockTimeoutKey = "aliasvault_auto_lock_timeout"
- static let encryptionKeyDerivationParamsKey = "aliasvault_encryption_key_derivation_params"
- static let usernameKey = "aliasvault_username"
- static let offlineModeKey = "aliasvault_offline_mode"
- static let pinEnabledKey = "aliasvault_pin_enabled"
- static let serverVersionKey = "aliasvault_server_version"
-
- // Sync state keys (for offline sync and race detection)
- static let isDirtyKey = "aliasvault_is_dirty"
- static let mutationSequenceKey = "aliasvault_mutation_sequence"
- static let isSyncingKey = "aliasvault_is_syncing"
-
- static let defaultAutoLockTimeout: Int = 3600 // 1 hour in seconds
-
- // Trash retention. Soft-deleted items stay in the recycle bin for this many
- // days before the Rust pruner permanently removes them on the next sync.
- // This value is declared in other places as well, make sure to update them
- // when updating this value.
- static let trashRetentionDays: Int = 30
-}
diff --git a/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift b/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift
index 8df127b37..dad580db7 100644
--- a/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift
@@ -1,4 +1,5 @@
import Foundation
+import VaultUtils
/**
* Native Swift WebAPI service for making HTTP requests to the AliasVault server.
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift
index 775fb397b..dcf613985 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift
@@ -2,6 +2,7 @@ import Foundation
import LocalAuthentication
import Security
import VaultModels
+import VaultUtils
/// Extension for the VaultStore class to handle authentication methods
extension VaultStore {
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift
index 0640e2670..652c1821f 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift
@@ -1,5 +1,6 @@
import Foundation
import Security
+import VaultUtils
/// Extension for the VaultStore class to handle cache management
extension VaultStore {
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift
index e9c7e38e4..0bcdfaa4c 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift
@@ -3,6 +3,7 @@ import CryptoKit
import LocalAuthentication
import Security
import SignalArgon2
+import VaultUtils
/// Extension for the VaultStore class to handle encryption/decryption
extension VaultStore {
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift
index 1fbefe588..a3f9b020e 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift
@@ -1,5 +1,6 @@
import Foundation
import SQLite
+import VaultUtils
/// Extension for the VaultStore class to handle database management
extension VaultStore {
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift
index 9a874daf4..250585840 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift
@@ -1,5 +1,6 @@
import Foundation
import VaultModels
+import VaultUtils
/// Extension for the VaultStore class to handle metadata management
extension VaultStore {
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Mutate.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Mutate.swift
index 8953aab9f..783049dab 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Mutate.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Mutate.swift
@@ -1,5 +1,6 @@
import Foundation
import VaultModels
+import VaultUtils
/// Vault upload model that matches the API contract
public struct VaultUpload: Codable {
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Pin.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Pin.swift
index 98cc2c4b8..0f3c8e158 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Pin.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Pin.swift
@@ -3,6 +3,7 @@ import CryptoKit
import Security
import SignalArgon2
import VaultModels
+import VaultUtils
/// Extension for the VaultStore class to handle PIN unlock functionality
extension VaultStore {
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore.swift
index fe43407e6..8a374ae02 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore.swift
@@ -5,6 +5,7 @@ import CryptoKit
import CommonCrypto
import Security
import VaultModels
+import VaultUtils
/// This class is used to store and retrieve the encrypted AliasVault database and encryption key.
/// It also handles executing queries against the SQLite database and biometric authentication.
diff --git a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift
index ee29172ec..4dd26e8c9 100644
--- a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift
+++ b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift
@@ -8,12 +8,14 @@ private let locBundle = Bundle.vaultUI
public struct AutofillCredentialCard: View {
let credential: AutofillCredential
let action: () -> Void
- let onCopy: () -> Void
+ let onCopy: (String) -> Void
@Environment(\.colorScheme) private var colorScheme
- @State private var showCopyToast = false
- @State private var copyToastMessage = ""
- public init(credential: AutofillCredential, action: @escaping () -> Void, onCopy: @escaping () -> Void) {
+ public init(
+ credential: AutofillCredential,
+ action: @escaping () -> Void,
+ onCopy: @escaping (String) -> Void = { _ in }
+ ) {
self.credential = credential
self.action = action
self.onCopy = onCopy
@@ -72,15 +74,14 @@ public struct AutofillCredentialCard: View {
.cornerRadius(8)
}
.contextMenu(menuItems: {
+ // Copy actions only copy to the clipboard and show a toast — they
+ // intentionally leave the autofill picker open so the user can
+ // still pick a credential to fill afterwards (for example: copy
+ // TOTP first, then tap to fill username/password).
if let username = credential.username, !username.isEmpty {
Button(action: {
UIPasteboard.general.string = username
- copyToastMessage = String(localized: "username_copied", bundle: locBundle)
- showCopyToast = true
- // Delay for 1 second before calling onCopy which dismisses the view
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- onCopy()
- }
+ onCopy(String(localized: "username_copied", bundle: locBundle))
}, label: {
Label(String(localized: "copy_username", bundle: locBundle), systemImage: "person")
})
@@ -89,12 +90,7 @@ public struct AutofillCredentialCard: View {
if let password = credential.password, !password.isEmpty {
Button(action: {
UIPasteboard.general.string = password
- copyToastMessage = String(localized: "password_copied", bundle: locBundle)
- showCopyToast = true
- // Delay for 1 second before calling onCopy which dismisses the view
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- onCopy()
- }
+ onCopy(String(localized: "password_copied", bundle: locBundle))
}, label: {
Label(String(localized: "copy_password", bundle: locBundle), systemImage: "key")
})
@@ -103,20 +99,28 @@ public struct AutofillCredentialCard: View {
if let email = credential.email, !email.isEmpty {
Button(action: {
UIPasteboard.general.string = email
- copyToastMessage = String(localized: "email_copied", bundle: locBundle)
- showCopyToast = true
- // Delay for 1 second before calling onCopy which dismisses the view
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- onCopy()
- }
+ onCopy(String(localized: "email_copied", bundle: locBundle))
}, label: {
Label(String(localized: "copy_email", bundle: locBundle), systemImage: "envelope")
})
}
+ if credential.hasTotp,
+ let secret = credential.totpSecret,
+ let code = TotpGenerator.generateCode(secret: secret),
+ !code.isEmpty {
+ Button(action: {
+ UIPasteboard.general.string = code
+ onCopy(String(localized: "totp_code_copied", bundle: locBundle))
+ }, label: {
+ Label(String(localized: "copy_totp_code", bundle: locBundle), systemImage: "number")
+ })
+ }
+
if (credential.username != nil && !credential.username!.isEmpty) ||
(credential.password != nil && !credential.password!.isEmpty) ||
- (credential.email != nil && !credential.email!.isEmpty) {
+ (credential.email != nil && !credential.email!.isEmpty) ||
+ credential.hasTotp {
Divider()
}
@@ -136,29 +140,37 @@ public struct AutofillCredentialCard: View {
Label(String(localized: "edit", bundle: locBundle), systemImage: "pencil")
})
})
- .overlay(
- Group {
- if showCopyToast {
- VStack {
- Spacer()
- Text(copyToastMessage)
- .padding()
- .background(Color.black.opacity(0.7))
- .foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
- .cornerRadius(8)
- .padding(.bottom, 20)
- }
- .transition(.opacity)
- .onAppear {
- DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
- withAnimation {
- showCopyToast = false
- }
- }
- }
- }
- }
+ }
+}
+
+/// Toast pill used for copy confirmations.
+public struct CopyToastView: View {
+ public let message: String
+
+ public init(message: String) {
+ self.message = message
+ }
+
+ public var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(.green)
+ Text(message)
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.primary)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(
+ Capsule()
+ .fill(.regularMaterial)
)
+ .overlay(
+ Capsule()
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
+ )
+ .shadow(color: Color.black.opacity(0.18), radius: 12, x: 0, y: 4)
}
}
@@ -188,7 +200,6 @@ public func truncateText(_ text: String?, limit: Int) -> String {
createdAt: Date(),
updatedAt: Date()
),
- action: {},
- onCopy: {}
+ action: {}
)
}
diff --git a/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift b/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift
index 89fe99438..684bf9f15 100644
--- a/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift
+++ b/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift
@@ -11,6 +11,7 @@ public struct CredentialProviderView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
+ @State private var toastMessage: String?
public init(viewModel: CredentialProviderViewModel) {
self._viewModel = ObservedObject(wrappedValue: viewModel)
@@ -91,9 +92,7 @@ public struct CredentialProviderView: View {
password: password
)
},
- onCopy: {
- viewModel.cancel()
- }
+ onCopy: presentToast
)
}
}
@@ -163,6 +162,19 @@ public struct CredentialProviderView: View {
}
}
}
+ .overlay(alignment: .bottom) {
+ if let message = toastMessage {
+ CopyToastView(message: message)
+ .padding(.bottom, 24)
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ }
+ }
+ .animation(.spring(response: 0.4, dampingFraction: 0.85), value: toastMessage)
+ .task(id: toastMessage) {
+ guard toastMessage != nil else { return }
+ try? await Task.sleep(nanoseconds: 2_000_000_000)
+ toastMessage = nil
+ }
.task {
try? await Task.sleep(nanoseconds: 100_000_000)
await viewModel.loadCredentials()
@@ -173,6 +185,12 @@ public struct CredentialProviderView: View {
}
}
+ /// Show the global copy-confirmation toast. Each call resets the
+ /// auto-dismiss timer via `.task(id: toastMessage)` on the body.
+ private func presentToast(_ message: String) {
+ toastMessage = message
+ }
+
/// Two-way binding that maps the optional pendingLinkSelection to a Bool
/// for SwiftUI's `alert(_:isPresented:presenting:)` modifier. Setting the
/// bound value to `false` clears the pending selection on the view-model.
@@ -221,7 +239,7 @@ private struct AutofillCredentialCardWithSelection: View {
let credential: AutofillCredential
let isChoosingTextToInsert: Bool
let onSelect: (String, String) -> Void
- let onCopy: () -> Void
+ let onCopy: (String) -> Void
@State private var showSelectionSheet = false
@State private var totpCode: String?
@@ -239,6 +257,18 @@ private struct AutofillCredentialCardWithSelection: View {
// For normal autofill, use the credential's identifier property
let identifier = credential.identifier
+ // If the credential has a TOTP secret and the user has the
+ // copy-on-fill setting enabled (default), put the current
+ // TOTP code on the clipboard so they can paste it into the
+ // 2FA field after the autofill completes.
+ if credential.hasTotp,
+ let secret = credential.totpSecret,
+ AutofillSettings.shouldCopyTotpOnFill,
+ let code = TotpGenerator.generateCode(secret: secret),
+ !code.isEmpty {
+ UIPasteboard.general.string = code
+ }
+
// Fill both username and password immediately for normal autofill
onSelect(identifier, credential.password ?? "")
}
diff --git a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings
index 9cdb8bf1f..f32fa554c 100644
--- a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings
+++ b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings
@@ -35,10 +35,12 @@
"copy_username" = "Copy Username";
"copy_password" = "Copy Password";
"copy_email" = "Copy Email";
+"copy_totp_code" = "Copy TOTP Code";
"view_details" = "View Details";
"username_copied" = "Username copied";
"password_copied" = "Password copied";
"email_copied" = "Email copied";
+"totp_code_copied" = "TOTP code copied";
"totp_code" = "TOTP Code";
/* Search bar */
diff --git a/apps/mobile-app/ios/VaultUtils/AutofillSettings.swift b/apps/mobile-app/ios/VaultUtils/AutofillSettings.swift
new file mode 100644
index 000000000..ff253d913
--- /dev/null
+++ b/apps/mobile-app/ios/VaultUtils/AutofillSettings.swift
@@ -0,0 +1,28 @@
+import Foundation
+
+/// Read/write access to autofill-related preferences shared between the main app and
+/// the iOS Autofill extension via the App Group UserDefaults suite.
+///
+/// All underlying identifiers (suite name + key names) come from `VaultConstants`
+/// so they are defined exactly once across the project.
+public enum AutofillSettings {
+ private static var sharedDefaults: UserDefaults? {
+ UserDefaults(suiteName: VaultConstants.userDefaultsSuite)
+ }
+
+ /// Whether the autofill extension should copy a credential's current TOTP code to
+ /// the clipboard when the user selects it for autofill.
+ /// Defaults to `true` when the key has never been written.
+ public static var shouldCopyTotpOnFill: Bool {
+ get {
+ guard let defaults = sharedDefaults else { return true }
+ if defaults.object(forKey: VaultConstants.autofillCopyTotpOnFillKey) == nil {
+ return true
+ }
+ return defaults.bool(forKey: VaultConstants.autofillCopyTotpOnFillKey)
+ }
+ set {
+ sharedDefaults?.set(newValue, forKey: VaultConstants.autofillCopyTotpOnFillKey)
+ }
+ }
+}
diff --git a/apps/mobile-app/ios/VaultUtils/VaultConstants.swift b/apps/mobile-app/ios/VaultUtils/VaultConstants.swift
new file mode 100644
index 000000000..28bb6f8a9
--- /dev/null
+++ b/apps/mobile-app/ios/VaultUtils/VaultConstants.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// Constants used for userDefaults keys, keychain identifiers, and other shared
+/// identifiers across the app, autofill extension, and shared frameworks.
+public struct VaultConstants {
+ public static let keychainService = "net.aliasvault.autofill"
+ public static let keychainAccessGroup = "group.net.aliasvault.autofill"
+ public static let userDefaultsSuite = "group.net.aliasvault.autofill"
+
+ public static let vaultMetadataKey = "aliasvault_vault_metadata"
+ public static let encryptionKeyKey = "aliasvault_encryption_key"
+ public static let encryptedDbFileName = "encrypted_db.sqlite"
+ public static let authMethodsKey = "aliasvault_auth_methods"
+ public static let autoLockTimeoutKey = "aliasvault_auto_lock_timeout"
+ public static let encryptionKeyDerivationParamsKey = "aliasvault_encryption_key_derivation_params"
+ public static let usernameKey = "aliasvault_username"
+ public static let offlineModeKey = "aliasvault_offline_mode"
+ public static let pinEnabledKey = "aliasvault_pin_enabled"
+ public static let serverVersionKey = "aliasvault_server_version"
+ public static let autofillCopyTotpOnFillKey = "aliasvault_autofill_copy_totp_on_fill"
+
+ // Sync state keys (for offline sync and race detection)
+ public static let isDirtyKey = "aliasvault_is_dirty"
+ public static let mutationSequenceKey = "aliasvault_mutation_sequence"
+ public static let isSyncingKey = "aliasvault_is_syncing"
+
+ public static let defaultAutoLockTimeout: Int = 3600 // 1 hour in seconds
+
+ // Trash retention. Soft-deleted items stay in the recycle bin for this many
+ // days before the Rust pruner permanently removes them on the next sync.
+ // This value is declared in other places as well, make sure to update them
+ // when updating this value.
+ public static let trashRetentionDays: Int = 30
+}
diff --git a/apps/mobile-app/package-lock.json b/apps/mobile-app/package-lock.json
index d0573f376..af298e64c 100644
--- a/apps/mobile-app/package-lock.json
+++ b/apps/mobile-app/package-lock.json
@@ -36,7 +36,6 @@
"fbemitter": "^3.0.0",
"i18next": "^25.3.2",
"lodash": "^4.18.1",
- "otpauth": "^9.4.0",
"react": "19.0.0",
"react-hook-form": "^7.56.1",
"react-i18next": "^15.6.0",
@@ -3161,18 +3160,6 @@
"@tybys/wasm-util": "^0.10.0"
}
},
- "node_modules/@noble/hashes": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
- "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
- "license": "MIT",
- "engines": {
- "node": "^14.21.3 || >=16"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
@@ -11804,18 +11791,6 @@
"node": ">=8"
}
},
- "node_modules/otpauth": {
- "version": "9.4.1",
- "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.1.tgz",
- "integrity": "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==",
- "license": "MIT",
- "dependencies": {
- "@noble/hashes": "1.8.0"
- },
- "funding": {
- "url": "https://github.com/hectorm/otpauth?sponsor=1"
- }
- },
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json
index aa038d6ef..923429399 100644
--- a/apps/mobile-app/package.json
+++ b/apps/mobile-app/package.json
@@ -52,7 +52,6 @@
"fbemitter": "^3.0.0",
"i18next": "^25.3.2",
"lodash": "^4.18.1",
- "otpauth": "^9.4.0",
"react": "19.0.0",
"react-hook-form": "^7.56.1",
"react-i18next": "^15.6.0",
diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts
index 18e7b1cae..21501d710 100644
--- a/apps/mobile-app/specs/NativeVaultManager.ts
+++ b/apps/mobile-app/specs/NativeVaultManager.ts
@@ -67,10 +67,18 @@ export interface Spec extends TurboModule {
openAutofillSettingsPage(): Promise;
getAutofillShowSearchText(): Promise;
setAutofillShowSearchText(showSearchText: boolean): Promise;
+ getAutofillCopyTotpOnFill(): Promise;
+ setAutofillCopyTotpOnFill(enabled: boolean): Promise;
// Clipboard management
copyToClipboardWithExpiration(text: string, expirationSeconds: number, localOnly: boolean): Promise;
+ // TOTP code generation (RFC 6238, HMAC-SHA1, 6 digits, 30s period).
+ // Delegates to the platform-native TOTP generator so iOS, Android and the
+ // autofill extensions all share one implementation. Returns null when the
+ // secret is invalid.
+ generateTotpCode(secret: string): Promise;
+
// Battery optimization management
isIgnoringBatteryOptimizations(): Promise;
requestIgnoreBatteryOptimizations(): Promise;
diff --git a/apps/mobile-app/utils/TotpUtility.ts b/apps/mobile-app/utils/TotpUtility.ts
index 0c9a275b7..be8daf8e3 100644
--- a/apps/mobile-app/utils/TotpUtility.ts
+++ b/apps/mobile-app/utils/TotpUtility.ts
@@ -1,23 +1,89 @@
-import * as OTPAuth from 'otpauth';
+import NativeVaultManager from '@/specs/NativeVaultManager';
/**
* Generates the current TOTP code for the given secret key.
- * Uses standard TOTP settings: SHA1, 6 digits, 30-second period.
+ *
+ * Delegates to the platform-native TOTP generator (Swift on iOS, Kotlin on
+ * Android) so the React Native layer, the iOS Autofill extension, and the
+ * Android Autofill service all share one RFC 6238 implementation. Standard
+ * settings: HMAC-SHA1, 6 digits, 30-second period.
*
* @param secretKey - Base32-encoded TOTP secret
* @returns The current 6-digit TOTP code, or empty string on error
*/
-export function generateTotpCode(secretKey: string): string {
+export async function generateTotpCode(secretKey: string): Promise {
try {
- const totp = new OTPAuth.TOTP({
- secret: secretKey,
- algorithm: 'SHA1',
- digits: 6,
- period: 30
- });
- return totp.generate();
+ const code = await NativeVaultManager.generateTotpCode(secretKey);
+ return code ?? '';
} catch (error) {
console.error('Error generating TOTP code:', error);
return '';
}
}
+
+/**
+ * Parsed `otpauth://` URI components.
+ */
+export type OtpAuthUri = {
+ /** "totp" or "hotp" — only "totp" is supported elsewhere in the app. */
+ type: 'totp' | 'hotp';
+ /** URL-decoded path component, typically "Issuer:account". */
+ label: string;
+ /** Base32 secret from the `secret` query parameter. */
+ secret: string;
+ /** Optional `issuer` query parameter. */
+ issuer?: string;
+};
+
+/**
+ * Parse an `otpauth://` URI per
+ * https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
+ *
+ * Returns null when the input is not a valid `otpauth://` URI or is missing
+ * a `secret` parameter. Does NOT validate the Base32 alphabet of the secret —
+ * callers (e.g. `sanitizeSecretKey` in TotpEditor) handle that separately.
+ */
+export function parseOtpAuthUri(uri: string): OtpAuthUri | null {
+ const trimmed = uri.trim();
+ const prefix = 'otpauth://';
+ if (trimmed.toLowerCase().slice(0, prefix.length) !== prefix) {
+ return null;
+ }
+
+ const afterScheme = trimmed.slice(prefix.length);
+ const slashIdx = afterScheme.indexOf('/');
+ if (slashIdx < 0) {
+ return null;
+ }
+
+ const typeRaw = afterScheme.slice(0, slashIdx).toLowerCase();
+ if (typeRaw !== 'totp' && typeRaw !== 'hotp') {
+ return null;
+ }
+
+ const rest = afterScheme.slice(slashIdx + 1);
+ const queryIdx = rest.indexOf('?');
+ const labelEncoded = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest;
+ const queryString = queryIdx >= 0 ? rest.slice(queryIdx + 1) : '';
+
+ let label: string;
+ try {
+ label = decodeURIComponent(labelEncoded);
+ } catch {
+ label = labelEncoded;
+ }
+
+ const params = new URLSearchParams(queryString);
+ const secret = params.get('secret');
+ if (!secret) {
+ return null;
+ }
+
+ const issuer = params.get('issuer');
+ return {
+ type: typeRaw,
+ label,
+ secret,
+ ...(issuer ? { issuer } : {}),
+ };
+}
diff --git a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css
index 2cdcda33b..82433ffc4 100644
--- a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css
+++ b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css
@@ -1833,6 +1833,11 @@ video {
background-color: rgb(255 251 235 / var(--tw-bg-opacity));
}
+.bg-amber-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(245 158 11 / var(--tw-bg-opacity));
+}
+
.bg-black {
--tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
@@ -1858,6 +1863,11 @@ video {
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
+.bg-emerald-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(16 185 129 / var(--tw-bg-opacity));
+}
+
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -2017,6 +2027,11 @@ video {
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
}
+.bg-yellow-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 240 138 / var(--tw-bg-opacity));
+}
+
.bg-yellow-50 {
--tw-bg-opacity: 1;
background-color: rgb(254 252 232 / var(--tw-bg-opacity));
@@ -2027,21 +2042,6 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
-.bg-amber-500 {
- --tw-bg-opacity: 1;
- background-color: rgb(245 158 11 / var(--tw-bg-opacity));
-}
-
-.bg-emerald-500 {
- --tw-bg-opacity: 1;
- background-color: rgb(16 185 129 / var(--tw-bg-opacity));
-}
-
-.bg-yellow-200 {
- --tw-bg-opacity: 1;
- background-color: rgb(254 240 138 / var(--tw-bg-opacity));
-}
-
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -3313,16 +3313,16 @@ video {
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
-.dark\:border-yellow-800:is(.dark *) {
- --tw-border-opacity: 1;
- border-color: rgb(133 77 14 / var(--tw-border-opacity));
-}
-
.dark\:border-yellow-600:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(202 138 4 / var(--tw-border-opacity));
}
+.dark\:border-yellow-800:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(133 77 14 / var(--tw-border-opacity));
+}
+
.dark\:bg-amber-800\/30:is(.dark *) {
background-color: rgb(146 64 14 / 0.3);
}