From 065f5095f960981b798c6b57c38146d1b22f43f2 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 12 May 2026 12:32:58 +0200 Subject: [PATCH 1/6] Add iOS copy TOTP code after autofill scaffolding (#2006) --- ...ialProviderViewController+Credential.swift | 13 +++ .../RCTNativeVaultManager.mm | 12 +++ .../ios/NativeVaultManager/VaultManager.swift | 37 ++++++++ .../Constants/VaultConstants.swift | 3 +- .../Components/CredentialCardView.swift | 39 +++++---- .../Selection/CredentialProviderView.swift | 18 ++-- .../ios/VaultUI/en.lproj/Localizable.strings | 2 + .../ios/VaultUtils/AutofillSettings.swift | 30 +++++++ apps/mobile-app/package-lock.json | 25 ------ apps/mobile-app/package.json | 1 - apps/mobile-app/specs/NativeVaultManager.ts | 8 ++ apps/mobile-app/utils/TotpUtility.ts | 86 ++++++++++++++++--- 12 files changed, 214 insertions(+), 60 deletions(-) create mode 100644 apps/mobile-app/ios/VaultUtils/AutofillSettings.swift diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift index 1eb961a27..28422df7c 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 @@ -124,6 +125,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..b5344b5d4 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,42 @@ public class VaultManager: NSObject { resolve(nil) } + @objc + func getAutofillCopyTotpOnFill(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + guard let defaults = UserDefaults(suiteName: VaultConstants.userDefaultsSuite) else { + resolve(true) + return + } + // Default to true when key has never been written. + if defaults.object(forKey: VaultConstants.autofillCopyTotpOnFillKey) == nil { + resolve(true) + return + } + resolve(defaults.bool(forKey: VaultConstants.autofillCopyTotpOnFillKey)) + } + + @objc + func setAutofillCopyTotpOnFill(_ enabled: Bool, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + guard let defaults = UserDefaults(suiteName: VaultConstants.userDefaultsSuite) else { + reject("AUTOFILL_SETTING_ERROR", "App Group UserDefaults unavailable", nil) + return + } + defaults.set(enabled, forKey: VaultConstants.autofillCopyTotpOnFillKey) + 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 index 0c8fbad5b..0fdd3db5a 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift @@ -5,7 +5,7 @@ import VaultModels public struct VaultConstants { static let keychainService = "net.aliasvault.autofill" static let keychainAccessGroup = "group.net.aliasvault.autofill" - static let userDefaultsSuite = "group.net.aliasvault.autofill" + public static let userDefaultsSuite = "group.net.aliasvault.autofill" static let vaultMetadataKey = "aliasvault_vault_metadata" static let encryptionKeyKey = "aliasvault_encryption_key" @@ -17,6 +17,7 @@ public struct VaultConstants { static let offlineModeKey = "aliasvault_offline_mode" static let pinEnabledKey = "aliasvault_pin_enabled" 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) static let isDirtyKey = "aliasvault_is_dirty" diff --git a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift index ee29172ec..380ab7bc5 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift @@ -8,15 +8,13 @@ private let locBundle = Bundle.vaultUI public struct AutofillCredentialCard: View { let credential: AutofillCredential let action: () -> Void - let onCopy: () -> 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) { self.credential = credential self.action = action - self.onCopy = onCopy } private var colors: ColorConstants.Colors.Type { @@ -72,15 +70,15 @@ 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() - } }, label: { Label(String(localized: "copy_username", bundle: locBundle), systemImage: "person") }) @@ -91,10 +89,6 @@ public struct AutofillCredentialCard: View { 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() - } }, label: { Label(String(localized: "copy_password", bundle: locBundle), systemImage: "key") }) @@ -105,18 +99,28 @@ public struct AutofillCredentialCard: View { 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() - } }, 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 + copyToastMessage = String(localized: "totp_code_copied", bundle: locBundle) + showCopyToast = true + }, 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() } @@ -188,7 +192,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..523b02da6 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift @@ -90,9 +90,6 @@ public struct CredentialProviderView: View { username: username, password: password ) - }, - onCopy: { - viewModel.cancel() } ) } @@ -221,7 +218,6 @@ private struct AutofillCredentialCardWithSelection: View { let credential: AutofillCredential let isChoosingTextToInsert: Bool let onSelect: (String, String) -> Void - let onCopy: () -> Void @State private var showSelectionSheet = false @State private var totpCode: String? @@ -239,10 +235,22 @@ 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 ?? "") } - }, onCopy: onCopy) + }) .confirmationDialog( String(localized: "select_text_to_insert", bundle: locBundle), isPresented: $showSelectionSheet, 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..ea8e85852 --- /dev/null +++ b/apps/mobile-app/ios/VaultUtils/AutofillSettings.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Read-only access to autofill-related preferences shared between the main app and +/// the iOS Autofill extension via the App Group UserDefaults suite. +/// +/// The setter side lives in NativeVaultManager (main app target) and writes to the +/// same suite. Keys and the suite identifier must stay in sync with VaultConstants. +public enum AutofillSettings { + /// App Group identifier — must match `VaultConstants.userDefaultsSuite` and + /// the autofill entitlements. + private static let suiteName = "group.net.aliasvault.autofill" + + /// Key — must match `VaultConstants.autofillCopyTotpOnFillKey`. + private static let copyTotpOnFillKey = "aliasvault_autofill_copy_totp_on_fill" + + private static var sharedDefaults: UserDefaults? { + UserDefaults(suiteName: suiteName) + } + + /// 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 { + guard let defaults = sharedDefaults else { return true } + if defaults.object(forKey: copyTotpOnFillKey) == nil { + return true + } + return defaults.bool(forKey: copyTotpOnFillKey) + } +} 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 } : {}), + }; +} From 3b052efead2ffe2544bea6ce3b85503a9645a31c Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 12 May 2026 20:15:32 +0200 Subject: [PATCH 2/6] Move VaultConstants to VaultUtils shared project (#2006) --- ...ialProviderViewController+Credential.swift | 1 - .../ios/NativeVaultManager/VaultManager.swift | 17 ++-------- .../Constants/VaultConstants.swift | 34 ------------------- .../Services/WebApiService.swift | 1 + .../ios/VaultStoreKit/VaultStore+Auth.swift | 1 + .../ios/VaultStoreKit/VaultStore+Cache.swift | 1 + .../ios/VaultStoreKit/VaultStore+Crypto.swift | 1 + .../VaultStoreKit/VaultStore+Database.swift | 1 + .../VaultStoreKit/VaultStore+Metadata.swift | 1 + .../ios/VaultStoreKit/VaultStore+Mutate.swift | 1 + .../ios/VaultStoreKit/VaultStore+Pin.swift | 1 + .../ios/VaultStoreKit/VaultStore.swift | 1 + .../ios/VaultUtils/AutofillSettings.swift | 28 +++++++-------- .../ios/VaultUtils/VaultConstants.swift | 34 +++++++++++++++++++ 14 files changed, 58 insertions(+), 65 deletions(-) delete mode 100644 apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift create mode 100644 apps/mobile-app/ios/VaultUtils/VaultConstants.swift diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift index 28422df7c..d9031c57a 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift @@ -12,7 +12,6 @@ import VaultUtils extension CredentialProviderViewController: CredentialProviderDelegate { // MARK: - CredentialProviderDelegate Implementation - func setupCredentialView(vaultStore: VaultStore, serviceUrl: String?) throws -> UIViewController { // Create the ViewModel with injected behaviors let viewModel = CredentialProviderViewModel( diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index b5344b5d4..6b71c8b31 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -396,27 +396,14 @@ public class VaultManager: NSObject { @objc func getAutofillCopyTotpOnFill(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { - guard let defaults = UserDefaults(suiteName: VaultConstants.userDefaultsSuite) else { - resolve(true) - return - } - // Default to true when key has never been written. - if defaults.object(forKey: VaultConstants.autofillCopyTotpOnFillKey) == nil { - resolve(true) - return - } - resolve(defaults.bool(forKey: VaultConstants.autofillCopyTotpOnFillKey)) + resolve(AutofillSettings.shouldCopyTotpOnFill) } @objc func setAutofillCopyTotpOnFill(_ enabled: Bool, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { - guard let defaults = UserDefaults(suiteName: VaultConstants.userDefaultsSuite) else { - reject("AUTOFILL_SETTING_ERROR", "App Group UserDefaults unavailable", nil) - return - } - defaults.set(enabled, forKey: VaultConstants.autofillCopyTotpOnFillKey) + AutofillSettings.shouldCopyTotpOnFill = enabled resolve(nil) } 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 0fdd3db5a..000000000 --- a/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift +++ /dev/null @@ -1,34 +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" - public 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" - public static let autofillCopyTotpOnFillKey = "aliasvault_autofill_copy_totp_on_fill" - - // 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/VaultUtils/AutofillSettings.swift b/apps/mobile-app/ios/VaultUtils/AutofillSettings.swift index ea8e85852..ff253d913 100644 --- a/apps/mobile-app/ios/VaultUtils/AutofillSettings.swift +++ b/apps/mobile-app/ios/VaultUtils/AutofillSettings.swift @@ -1,30 +1,28 @@ import Foundation -/// Read-only access to autofill-related preferences shared between the main app and +/// Read/write access to autofill-related preferences shared between the main app and /// the iOS Autofill extension via the App Group UserDefaults suite. /// -/// The setter side lives in NativeVaultManager (main app target) and writes to the -/// same suite. Keys and the suite identifier must stay in sync with VaultConstants. +/// All underlying identifiers (suite name + key names) come from `VaultConstants` +/// so they are defined exactly once across the project. public enum AutofillSettings { - /// App Group identifier — must match `VaultConstants.userDefaultsSuite` and - /// the autofill entitlements. - private static let suiteName = "group.net.aliasvault.autofill" - - /// Key — must match `VaultConstants.autofillCopyTotpOnFillKey`. - private static let copyTotpOnFillKey = "aliasvault_autofill_copy_totp_on_fill" - private static var sharedDefaults: UserDefaults? { - UserDefaults(suiteName: suiteName) + 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 { - guard let defaults = sharedDefaults else { return true } - if defaults.object(forKey: copyTotpOnFillKey) == nil { - return true + 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) } - return defaults.bool(forKey: copyTotpOnFillKey) } } 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 +} From d5b0e70ab926d148eca18b6157a8ad80605df9b9 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 12 May 2026 20:40:21 +0200 Subject: [PATCH 3/6] Update toast pill UI to show at bottom (#2006) --- .../Components/CredentialCardView.swift | 74 ++++++++++--------- .../Selection/CredentialProviderView.swift | 26 ++++++- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift index 380ab7bc5..4dd26e8c9 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift @@ -8,13 +8,17 @@ private let locBundle = Bundle.vaultUI public struct AutofillCredentialCard: View { let credential: AutofillCredential let action: () -> 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) { + public init( + credential: AutofillCredential, + action: @escaping () -> Void, + onCopy: @escaping (String) -> Void = { _ in } + ) { self.credential = credential self.action = action + self.onCopy = onCopy } private var colors: ColorConstants.Colors.Type { @@ -77,8 +81,7 @@ public struct AutofillCredentialCard: View { if let username = credential.username, !username.isEmpty { Button(action: { UIPasteboard.general.string = username - copyToastMessage = String(localized: "username_copied", bundle: locBundle) - showCopyToast = true + onCopy(String(localized: "username_copied", bundle: locBundle)) }, label: { Label(String(localized: "copy_username", bundle: locBundle), systemImage: "person") }) @@ -87,8 +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 + onCopy(String(localized: "password_copied", bundle: locBundle)) }, label: { Label(String(localized: "copy_password", bundle: locBundle), systemImage: "key") }) @@ -97,8 +99,7 @@ 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 + onCopy(String(localized: "email_copied", bundle: locBundle)) }, label: { Label(String(localized: "copy_email", bundle: locBundle), systemImage: "envelope") }) @@ -110,8 +111,7 @@ public struct AutofillCredentialCard: View { !code.isEmpty { Button(action: { UIPasteboard.general.string = code - copyToastMessage = String(localized: "totp_code_copied", bundle: locBundle) - showCopyToast = true + onCopy(String(localized: "totp_code_copied", bundle: locBundle)) }, label: { Label(String(localized: "copy_totp_code", bundle: locBundle), systemImage: "number") }) @@ -140,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) } } diff --git a/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift b/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift index 523b02da6..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) @@ -90,7 +91,8 @@ public struct CredentialProviderView: View { username: username, password: password ) - } + }, + onCopy: presentToast ) } } @@ -160,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() @@ -170,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. @@ -218,6 +239,7 @@ private struct AutofillCredentialCardWithSelection: View { let credential: AutofillCredential let isChoosingTextToInsert: Bool let onSelect: (String, String) -> Void + let onCopy: (String) -> Void @State private var showSelectionSheet = false @State private var totpCode: String? @@ -250,7 +272,7 @@ private struct AutofillCredentialCardWithSelection: View { // Fill both username and password immediately for normal autofill onSelect(identifier, credential.password ?? "") } - }) + }, onCopy: onCopy) .confirmationDialog( String(localized: "select_text_to_insert", bundle: locBundle), isPresented: $showSelectionSheet, From 2b4b649a0e1fd29f3b63a484c5ab412642319a07 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 12 May 2026 22:09:07 +0200 Subject: [PATCH 4/6] Add Android copy TOTP code after autofill scaffolding (#2006) --- .../android/app/src/main/AndroidManifest.xml | 7 + .../app/autofill/AutofillFillActivity.kt | 146 ++++++++++++++++++ .../app/autofill/AutofillService.kt | 146 ++++++------------ .../app/autofill/utils/AutofillFieldMapper.kt | 61 ++++++++ .../nativevaultmanager/NativeVaultManager.kt | 51 ++++++ .../net/aliasvault/app/utils/TotpGenerator.kt | 91 +++++++++++ .../aliasvault/app/vaultstore/VaultStore.kt | 17 ++ .../vaultstore/repositories/ItemRepository.kt | 16 ++ .../app/src/main/res/values/styles.xml | 1 + .../app/(tabs)/settings/android-autofill.tsx | 67 ++++++-- .../app/(tabs)/settings/ios-autofill.tsx | 143 ++++++++++++++--- apps/mobile-app/components/items/ItemCard.tsx | 2 +- .../components/items/details/TotpEditor.tsx | 52 +++---- .../components/items/details/TotpSection.tsx | 57 ++++--- 14 files changed, 660 insertions(+), 197 deletions(-) create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillFillActivity.kt create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/AutofillFieldMapper.kt create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/TotpGenerator.kt 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 +