From 8a2c664e5a6be222c84600392360c46933aa6c9f Mon Sep 17 00:00:00 2001 From: Aisling Date: Sun, 11 Oct 2026 14:51:54 +0100 Subject: [PATCH] feat: auto-fill OTP verification codes from Android notifications --- airsync-mac/Core/AppState.swift | 22 ++++++ .../Core/Util/OTP/OtpCodeExtractor.swift | 68 +++++++++++++++++++ airsync-mac/Localization/en.json | 2 + .../Screens/Settings/SyncSettingsView.swift | 6 ++ 4 files changed, 98 insertions(+) create mode 100644 airsync-mac/Core/Util/OTP/OtpCodeExtractor.swift diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 9c207c37..f69626f0 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -91,6 +91,7 @@ class AppState: ObservableObject { self.menubarNotificationStyle = UserDefaults.standard.string(forKey: "menubarNotificationStyle") ?? "both" self.isClipboardSyncEnabled = UserDefaults.standard.bool(forKey: "isClipboardSyncEnabled") + self.isOtpAutofillEnabled = UserDefaults.standard.object(forKey: "isOtpAutofillEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isOtpAutofillEnabled") self.autoStartAtLogin = UserDefaults.standard.bool(forKey: "autoStartAtLogin") self.keepRunningAfterQuit = UserDefaults.standard.bool(forKey: "keepRunningAfterQuit") self.shortcutScopes = UserDefaults.standard.dictionary(forKey: "shortcutScopes") as? [String: String] ?? [:] @@ -388,6 +389,12 @@ class AppState: ObservableObject { } } } + /// Copies detected OTP / 2FA codes from Android notifications to the Mac clipboard. + @Published var isOtpAutofillEnabled: Bool { + didSet { + UserDefaults.standard.set(isOtpAutofillEnabled, forKey: "isOtpAutofillEnabled") + } + } @Published var shouldRefreshQR: Bool = false @Published var isConnectionWeak: Bool = false @Published var webSocketStatus: WebSocketStatus = .stopped @@ -1343,6 +1350,10 @@ class AppState: ObservableObject { } } let isAppSilentOnMac = UserDefaults.standard.appSilentNotifications[notif.package] ?? false + if contentChanged && self.isOtpAutofillEnabled, + let code = OtpCodeExtractor.extract(from: notif.title, body: notif.body) { + self.copyVerificationCodeToClipboard(code) + } if notif.priority != "silent" && !isAppSilentOnMac && contentChanged { var appIcon: NSImage? = nil if let iconPath = self.androidApps[notif.package]?.iconUrl { @@ -1361,6 +1372,17 @@ class AppState: ObservableObject { } } + /// Copies a detected verification code to the Mac clipboard (local only, + /// never synced back to Android) so it can be pasted into the Mac app or site. + private func copyVerificationCodeToClipboard(_ code: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(code, forType: .string) + self.lastClipboardValue = code + self.lastClipboardChangeCount = pasteboard.changeCount + print("[otp] Verification code copied to clipboard") + } + func postNativeNotification( id: String, appName: String, diff --git a/airsync-mac/Core/Util/OTP/OtpCodeExtractor.swift b/airsync-mac/Core/Util/OTP/OtpCodeExtractor.swift new file mode 100644 index 00000000..16a1908a --- /dev/null +++ b/airsync-mac/Core/Util/OTP/OtpCodeExtractor.swift @@ -0,0 +1,68 @@ +// +// OtpCodeExtractor.swift +// airsync-mac +// +// Detects one-time verification codes (OTP / 2FA) in notification text so the +// Mac can offer them for paste without the user reaching for the phone. +// + +import Foundation + +struct OtpCodeExtractor { + /// Keywords indicating the text carries a verification code. This gate keeps + /// plain numbers (years, prices, counts) from triggering a clipboard copy. + private static let keywords = [ + "code", "otp", "passcode", "verification", "verify", + "authenticate", "authentication", "2fa", "two-factor", "two factor", + "one-time", "one time", "onetime", "security", "login", + "sign-in", "sign in", "confirm", "activation", "validate" + ] + + /// Triple-grouped codes like "73 91 08" (separator must be consistent). + private static let grouped3Pattern = #"(? String? { + let text = "\(title)\n\(body)" + let lowered = text.lowercased() + guard keywords.contains(where: { lowered.contains($0) }) else { return nil } + + if let triple = matchGroups(pattern: grouped3Pattern, in: text, groups: [1, 3, 4]) { + return triple + } + if let pair = matchGroups(pattern: grouped2Pattern, in: text, groups: [1, 2]) { + return pair + } + return matchGroups(pattern: plainPattern, in: text, groups: [1]) + } + + private static func matchGroups(pattern: String, in text: String, groups: [Int]) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(text.startIndex..