From fb5d4dfecaef6baea4b38affe1224f0ab4abe380 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 5 Nov 2025 20:38:42 +0100 Subject: [PATCH] Improve Android autofill matching to prevent android packages resulting in false positives (#1332) --- .../app/autofill/AutofillService.kt | 48 ++++++++++++++++ .../app/autofill/utils/CredentialMatcher.kt | 46 ++++++++++++++- .../app/nativevaultmanager/AutofillTest.kt | 57 ++++++++++++++++++- 3 files changed, 148 insertions(+), 3 deletions(-) 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 c7e18f01e..d86b7fc89 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 @@ -194,6 +194,11 @@ class AutofillService : AutofillService() { createCredentialDataset(fieldFinder, credential), ) } + + // Add "Open app" option at the bottom (when search text is not shown and there are matches) + if (!showSearchText) { + responseBuilder.addDataset(createOpenAppDataset(fieldFinder)) + } } callback(responseBuilder.build()) @@ -546,4 +551,47 @@ class AutofillService : AutofillService() { return dataSetBuilder.build() } + + /** + * Create a dataset for the "open app" option. + * @param fieldFinder The field finder + * @return The dataset + */ + private fun createOpenAppDataset(fieldFinder: FieldFinder): Dataset { + // Create presentation for the "open app" option with AliasVault logo + val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo) + presentation.setTextViewText( + R.id.text, + getString(R.string.autofill_open_app), + ) + + val dataSetBuilder = Dataset.Builder(presentation) + + // Create deep link URL to open the credentials page + val appInfo = fieldFinder.getAppInfo() + val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" + val deepLinkUrl = "net.aliasvault.app://credentials?serviceUrl=$encodedUrl" + + // Add a click listener to open AliasVault app with deep link + val intent = Intent(Intent.ACTION_VIEW).apply { + data = android.net.Uri.parse(deepLinkUrl) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val pendingIntent = PendingIntent.getActivity( + this@AutofillService, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + dataSetBuilder.setAuthentication(pendingIntent.intentSender) + + // Add placeholder values to satisfy Android's requirement that at least one value must be set + if (fieldFinder.autofillableFields.isNotEmpty()) { + for (field in fieldFinder.autofillableFields) { + dataSetBuilder.setValue(field.first, AutofillValue.forText("")) + } + } + + return dataSetBuilder.build() + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt index 946ca39b8..6cd48a3ba 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt @@ -8,6 +8,42 @@ import net.aliasvault.app.vaultstore.models.Credential */ object CredentialMatcher { + /** + * Common top-level domains (TLDs) that should be excluded from matching. + * This prevents false matches when dealing with reversed domain names (Android package names). + */ + private val commonTlds = setOf( + // Generic TLDs + "com", "net", "org", "edu", "gov", "mil", "int", + // Country code TLDs + "nl", "de", "uk", "fr", "it", "es", "pl", "be", "ch", "at", "se", "no", "dk", "fi", + "pt", "gr", "cz", "hu", "ro", "bg", "hr", "sk", "si", "lt", "lv", "ee", "ie", "lu", + "us", "ca", "mx", "br", "ar", "cl", "co", "ve", "pe", "ec", + "au", "nz", "jp", "cn", "in", "kr", "tw", "hk", "sg", "my", "th", "id", "ph", "vn", + "za", "eg", "ng", "ke", "ug", "tz", "ma", + "ru", "ua", "by", "kz", "il", "tr", "sa", "ae", "qa", "kw", + // New gTLDs (common ones) + "app", "dev", "io", "ai", "tech", "shop", "store", "online", "site", "website", + "blog", "news", "media", "tv", "video", "music", "pro", "info", "biz", "name", + ) + + /** + * Check if a string is likely an Android package name (reversed domain). + * Android package names start with TLD followed by dot (e.g., "com.example", "nl.app"). + * @param text The text to check + * @return True if it looks like an Android package name + */ + private fun isAndroidPackageName(text: String): Boolean { + if (!text.contains(".")) { + return false + } + + val firstPart = text.substringBefore(".").lowercase() + + // Check if first part is a common TLD - this indicates reversed domain (package name) + return commonTlds.contains(firstPart) + } + /** * Extract domain from URL, handling both full URLs and partial domains. * @param urlString URL or domain string @@ -23,6 +59,12 @@ object CredentialMatcher { // Check if it starts with a protocol val hasProtocol = domain.startsWith("http://") || domain.startsWith("https://") + // If no protocol and starts with TLD + dot, it's likely an Android package name + // Return it as-is for package name matching logic + if (!hasProtocol && isAndroidPackageName(domain)) { + return "" + } + // Remove protocol if present if (hasProtocol) { domain = domain.replace("https://", "").replace("http://", "") @@ -170,8 +212,8 @@ object CredentialMatcher { } return text.lowercase() - // Replace common separators and punctuation with spaces - .replace(Regex("[|,;:\\-–—/\\\\()\\[\\]{}'\" ~!@#$%^&*+=<>?]"), " ") + // Replace common separators and punctuation with spaces (including dots) + .replace(Regex("[|,;:\\-–—/\\\\()\\[\\]{}'\" ~!@#$%^&*+=<>?.]"), " ") .split(Regex("\\s+")) .filter { word -> word.length > 3 // Filter out short words diff --git a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt index f4101c2da..cb72a7eb7 100644 --- a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt +++ b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt @@ -295,7 +295,62 @@ class AutofillTest { assertEquals("Reddit", matches[0].service.name) } - // [#20] - Test multi-part TLDs like .com.au don't match incorrectly + // [#20] - Test reversed domain (Android package name) doesn't match on TLD + @Test + fun testReversedDomainTldCheck() { + // Test that dumpert.nl credential doesn't match nl.marktplaats.android package + // They both contain "nl" in the name but shouldn't match since "nl" is just a TLD + val reversedDomainCredentials = listOf( + createTestCredential("Dumpert.nl", "", "user@dumpert.nl"), + createTestCredential("Marktplaats.nl", "", "user@marktplaats.nl"), + ) + + val matches = CredentialMatcher.filterCredentialsByAppInfo( + reversedDomainCredentials, + "nl.marktplaats.android", + ) + + // Should only match Marktplaats, not Dumpert (even though both have "nl") + assertEquals(1, matches.size) + assertEquals("Marktplaats.nl", matches[0].service.name) + } + + // [#21] - Test Android package names are properly detected and handled + @Test + fun testAndroidPackageNameDetection() { + val packageCredentials = listOf( + createTestCredential("Google App", "com.google.android.googlequicksearchbox", "user@google.com"), + createTestCredential("Facebook", "com.facebook.katana", "user@facebook.com"), + createTestCredential("WhatsApp", "com.whatsapp", "user@whatsapp.com"), + createTestCredential("Generic Site", "example.com", "user@example.com"), + ) + + // Test com.google.android package matches + val googleMatches = CredentialMatcher.filterCredentialsByAppInfo( + packageCredentials, + "com.google.android.googlequicksearchbox", + ) + assertEquals(1, googleMatches.size) + assertEquals("Google App", googleMatches[0].service.name) + + // Test com.facebook package matches + val facebookMatches = CredentialMatcher.filterCredentialsByAppInfo( + packageCredentials, + "com.facebook.katana", + ) + assertEquals(1, facebookMatches.size) + assertEquals("Facebook", facebookMatches[0].service.name) + + // Test that web domain doesn't match package name + val webMatches = CredentialMatcher.filterCredentialsByAppInfo( + packageCredentials, + "https://example.com", + ) + assertEquals(1, webMatches.size) + assertEquals("Generic Site", webMatches[0].service.name) + } + + // [#22] - Test multi-part TLDs like .com.au don't match incorrectly @Test fun testMultiPartTldNoFalseMatches() { // Create test data with different .com.au domains