Improve Android autofill matching to prevent android packages resulting in false positives (#1332)

This commit is contained in:
Leendert de Borst
2025-11-05 20:38:42 +01:00
parent 661f0574c5
commit fb5d4dfeca
3 changed files with 148 additions and 3 deletions

View File

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

View File

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

View File

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