Merge pull request #2008 from aliasvault/2006-copy-totp-to-clipboard-after-autofill-on-ios-and-android

This commit is contained in:
Leendert de Borst
2026-05-13 06:52:44 +02:00
committed by GitHub
38 changed files with 966 additions and 325 deletions

View File

@@ -75,6 +75,13 @@
android:theme="@style/zxing_CaptureTheme"
android:screenOrientation="portrait" />
<!-- Autofill Fill Activity (transparent; launched via Dataset.setAuthentication on selection) -->
<activity
android:name=".autofill.AutofillFillActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/Theme.App.NoDisplay" />
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>

View File

@@ -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<AutofillId>? {
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<AutofillId>,
fieldTypeOrdinals: IntArray,
): List<Pair<AutofillId, FieldType>> {
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)
}
}
}

View File

@@ -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()
}
/**

View File

@@ -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<Pair<AutofillId, FieldType>>,
): 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() }
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,4 +52,5 @@
<item name="android:windowSplashScreenIconBackgroundColor">@android:color/transparent</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>
<style name="Theme.App.NoDisplay" parent="@android:style/Theme.NoDisplay" />
</resources>

View File

@@ -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<void> => {
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<void> => {
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 {
<ThemedText style={styles.instructionStep}>
{t('settings.androidAutofillSettings.step2')}
</ThemedText>
<View style={styles.buttonContainer}>
{shouldShowAutofillReminder && (
{shouldShowAutofillReminder && (
<View style={styles.buttonContainer}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleAlreadyConfigured}
@@ -277,8 +294,8 @@ export default function AndroidAutofillScreen() : React.ReactNode {
{t('settings.androidAutofillSettings.alreadyConfigured')}
</ThemedText>
</TouchableOpacity>
)}
</View>
</View>
)}
</View>
<View style={styles.advancedOptionsContainer}>
@@ -287,7 +304,7 @@ export default function AndroidAutofillScreen() : React.ReactNode {
onPress={() => setAdvancedOptionsExpanded(!advancedOptionsExpanded)}
>
<ThemedText style={styles.advancedOptionsTitle}>
{t('settings.androidAutofillSettings.advancedOptions')}
{t('settings.advancedOptions')}
</ThemedText>
<ThemedText style={styles.chevron}>
{advancedOptionsExpanded ? '▼' : '▶'}
@@ -296,6 +313,22 @@ export default function AndroidAutofillScreen() : React.ReactNode {
{advancedOptionsExpanded && (
<View>
<View style={styles.settingRow}>
<View style={styles.settingRowText}>
<ThemedText style={styles.settingRowTitle}>
{t('settings.copyTotpOnFill')}
</ThemedText>
<ThemedText style={styles.settingRowDescription}>
{t('settings.copyTotpOnFillDescription')}
</ThemedText>
</View>
<Switch
value={copyTotpOnFill}
onValueChange={handleToggleCopyTotpOnFill}
trackColor={{ false: colors.accentBackground, true: colors.primary }}
thumbColor={colors.primarySurfaceText}
/>
</View>
<View style={styles.settingRow}>
<View style={styles.settingRowText}>
<ThemedText style={styles.settingRowTitle}>

View File

@@ -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<void> => {
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<void> => {
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 {
<ThemedText style={styles.instructionStep}>
{t('settings.iosAutofillSettings.step5')}
</ThemedText>
<ThemedText style={styles.warningText}>
{t('settings.iosAutofillSettings.warningText')}
</ThemedText>
<View style={styles.buttonContainer}>
{shouldShowAutofillReminder && (
{shouldShowAutofillReminder && (
<View style={styles.buttonContainer}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleAlreadyConfigured}
@@ -161,10 +229,45 @@ export default function IosAutofillScreen() : React.ReactNode {
{t('settings.iosAutofillSettings.alreadyConfigured')}
</ThemedText>
</TouchableOpacity>
)}
</View>
</View>
)}
</View>
<View style={styles.advancedOptionsContainer}>
<TouchableOpacity
style={styles.advancedOptionsToggleHeader}
onPress={() => setAdvancedOptionsExpanded(!advancedOptionsExpanded)}
>
<ThemedText style={styles.advancedOptionsTitle}>
{t('settings.advancedOptions')}
</ThemedText>
<ThemedText style={styles.chevron}>
{advancedOptionsExpanded ? '▼' : '▶'}
</ThemedText>
</TouchableOpacity>
{advancedOptionsExpanded && (
<View>
<View style={styles.settingRow}>
<View style={styles.settingRowText}>
<ThemedText style={styles.settingRowTitle}>
{t('settings.copyTotpOnFill')}
</ThemedText>
<ThemedText style={styles.settingRowDescription}>
{t('settings.copyTotpOnFillDescription')}
</ThemedText>
</View>
<Switch
value={copyTotpOnFill}
onValueChange={handleToggleCopyTotpOnFill}
trackColor={{ false: colors.accentBackground, true: colors.primary }}
thumbColor={colors.primarySurfaceText}
/>
</View>
</View>
)}
</View>
</ThemedScrollView>
</ThemedContainer>
);
}
}

View File

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

View File

@@ -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<TotpEditorProps> = ({
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<TotpEditorProps> = ({
// 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

View File

@@ -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<TotpSectionProps> = ({ 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<TotpSectionProps> = ({ 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<string, string>): Record<string, string> => {
const newCodes: Record<string, string> = {};
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<void> => {
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<string, string> = {};
for (const { id, value } of results) {
next[id] = value || prev[id] || 'Error';
}
return next;
});
return newCodes;
};
const initialCodes: Record<string, string> = {};
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import Foundation
import VaultUtils
/**
* Native Swift WebAPI service for making HTTP requests to the AliasVault server.

View File

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

View File

@@ -1,5 +1,6 @@
import Foundation
import Security
import VaultUtils
/// Extension for the VaultStore class to handle cache management
extension VaultStore {

View File

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

View File

@@ -1,5 +1,6 @@
import Foundation
import SQLite
import VaultUtils
/// Extension for the VaultStore class to handle database management
extension VaultStore {

View File

@@ -1,5 +1,6 @@
import Foundation
import VaultModels
import VaultUtils
/// Extension for the VaultStore class to handle metadata management
extension VaultStore {

View File

@@ -1,5 +1,6 @@
import Foundation
import VaultModels
import VaultUtils
/// Vault upload model that matches the API contract
public struct VaultUpload: Codable {

View File

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

View File

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

View File

@@ -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: {}
)
}

View File

@@ -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 ?? "")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,10 +67,18 @@ export interface Spec extends TurboModule {
openAutofillSettingsPage(): Promise<void>;
getAutofillShowSearchText(): Promise<boolean>;
setAutofillShowSearchText(showSearchText: boolean): Promise<void>;
getAutofillCopyTotpOnFill(): Promise<boolean>;
setAutofillCopyTotpOnFill(enabled: boolean): Promise<void>;
// Clipboard management
copyToClipboardWithExpiration(text: string, expirationSeconds: number, localOnly: boolean): Promise<void>;
// 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<string | null>;
// Battery optimization management
isIgnoringBatteryOptimizations(): Promise<boolean>;
requestIgnoreBatteryOptimizations(): Promise<string>;

View File

@@ -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<string> {
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 } : {}),
};
}

View File

@@ -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);
}