Skip to content

Commit 7b8af3c

Browse files
authored
Merge pull request #5 from codegax/copilot/fix-user-field-detection
Fix username field detection in autofill service
2 parents 93759e3 + e4f5715 commit 7b8af3c

File tree

1 file changed

+109
-32
lines changed

1 file changed

+109
-32
lines changed

app/src/main/java/com/nexpass/passwordmanager/autofill/service/PasswordAutofillService.kt

Lines changed: 109 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ class PasswordAutofillService : AutofillService() {
5252
private const val NOTIFICATION_DELAY_MS = 8000L // Wait 8 seconds after field focus before showing notification
5353
private const val NEXPASS_PACKAGE_DEBUG = "com.nexpass.passwordmanager.debug"
5454
private const val NEXPASS_PACKAGE_RELEASE = "com.nexpass.passwordmanager"
55+
56+
// Regex pattern for detecting username-related ID fields (with word boundaries)
57+
private val USERNAME_ID_PATTERN = Regex("\\b(user_?id|login_?id|uid)\\b")
58+
// Regex patterns for more precise matching with word boundaries
59+
private val USERNAME_PATTERN = Regex("\\b(username|user|login|account|identifier)\\b")
60+
private val EMAIL_PATTERN = Regex("\\b(email|e-mail|e_mail)\\b")
61+
private val PASSWORD_PATTERN = Regex("\\b(password|passwd)\\b")
62+
private val PASS_PATTERN = Regex("\\bpass\\b")
5563
}
5664

5765
override fun onFillRequest(
@@ -295,13 +303,17 @@ class PasswordAutofillService : AutofillService() {
295303

296304
// Parse fields from the view structure
297305
val fields = mutableListOf<AutofillField>()
306+
val allTextFields = mutableListOf<AutofillField>()
298307

299308
if (structure.windowNodeCount > 0) {
300309
structure.getWindowNodeAt(0)?.rootViewNode?.let { rootNode ->
301-
parseNode(rootNode, fields)
310+
parseNode(rootNode, fields, allTextFields)
302311
}
303312
}
304313

314+
// Apply heuristics to identify username fields from unidentified text fields
315+
applyUsernameHeuristics(fields, allTextFields)
316+
305317
Log.d(TAG, "Parsed ${fields.size} autofill fields")
306318

307319
return AutofillContext(
@@ -311,12 +323,40 @@ class PasswordAutofillService : AutofillService() {
311323
)
312324
}
313325

326+
/**
327+
* Apply heuristics to identify username fields from unidentified text fields.
328+
* If we have a password field but no username/email field, and there are unidentified
329+
* text fields, we assume the first unidentified text field is the username field.
330+
*/
331+
private fun applyUsernameHeuristics(
332+
identifiedFields: MutableList<AutofillField>,
333+
unidentifiedFields: List<AutofillField>
334+
) {
335+
// Check if we have a password field
336+
val hasPasswordField = identifiedFields.any { it.fieldType == FieldType.PASSWORD }
337+
338+
// Check if we already have a username or email field
339+
val hasUsernameField = identifiedFields.any {
340+
it.fieldType == FieldType.USERNAME || it.fieldType == FieldType.EMAIL
341+
}
342+
343+
// If we have a password but no username, and there are unidentified fields,
344+
// assume the first unidentified field is the username field
345+
if (hasPasswordField && !hasUsernameField && unidentifiedFields.isNotEmpty()) {
346+
val usernameField = unidentifiedFields.first().copy(fieldType = FieldType.USERNAME)
347+
identifiedFields.add(usernameField)
348+
Log.d(TAG, "Applied heuristic: Identified unidentified text field as USERNAME (appears with password field)")
349+
}
350+
}
351+
314352
/**
315353
* Recursively parse view nodes to find autofillable fields.
354+
* Stores both identified fields and potential unidentified text fields for heuristic analysis.
316355
*/
317356
private fun parseNode(
318357
node: android.app.assist.AssistStructure.ViewNode,
319-
fields: MutableList<AutofillField>
358+
fields: MutableList<AutofillField>,
359+
allTextFields: MutableList<AutofillField>
320360
) {
321361
val autofillId = node.autofillId
322362
val autofillType = node.autofillType
@@ -338,26 +378,29 @@ class PasswordAutofillService : AutofillService() {
338378
// Determine field type from multiple sources
339379
val fieldType = determineFieldTypeFromNode(hint, nodeHint, inputType, idEntry, htmlInfo)
340380

341-
// Only add if we can identify the field type
342-
if (fieldType != FieldType.UNKNOWN) {
343-
fields.add(
344-
AutofillField(
345-
autofillId = autofillId,
346-
autofillType = autofillType,
347-
hint = hint,
348-
isFocused = node.isFocused,
349-
fieldType = fieldType
350-
)
351-
)
381+
val field = AutofillField(
382+
autofillId = autofillId,
383+
autofillType = autofillType,
384+
hint = hint,
385+
isFocused = node.isFocused,
386+
fieldType = fieldType
387+
)
352388

389+
// Add to identified fields if we can determine the type
390+
if (fieldType != FieldType.UNKNOWN) {
391+
fields.add(field)
353392
Log.d(TAG, "Found autofill field - Hint: $hint, NodeHint: $nodeHint, InputType: $inputType, IdEntry: $idEntry, Type: $fieldType, Focused: ${node.isFocused}")
393+
} else {
394+
// Store unidentified text fields for potential heuristic analysis
395+
allTextFields.add(field)
396+
Log.d(TAG, "Found unidentified text field - Hint: $hint, NodeHint: $nodeHint, InputType: $inputType, IdEntry: $idEntry")
354397
}
355398
}
356399
}
357400

358401
// Recursively parse child nodes
359402
for (i in 0 until node.childCount) {
360-
node.getChildAt(i)?.let { parseNode(it, fields) }
403+
node.getChildAt(i)?.let { parseNode(it, fields, allTextFields) }
361404
}
362405
}
363406

@@ -402,6 +445,29 @@ class PasswordAutofillService : AutofillService() {
402445
return false
403446
}
404447

448+
/**
449+
* Check if a text matches common username field patterns.
450+
*/
451+
private fun isUsernamePattern(text: String): Boolean {
452+
return USERNAME_PATTERN.containsMatchIn(text) || USERNAME_ID_PATTERN.containsMatchIn(text)
453+
}
454+
455+
/**
456+
* Check if a text matches common email field patterns.
457+
*/
458+
private fun isEmailPattern(text: String): Boolean {
459+
return EMAIL_PATTERN.containsMatchIn(text)
460+
}
461+
462+
/**
463+
* Check if a text matches common password field patterns.
464+
*/
465+
private fun isPasswordPattern(text: String): Boolean {
466+
// Check for "password", "passwd", or "pass" as standalone words
467+
return PASSWORD_PATTERN.containsMatchIn(text) ||
468+
PASS_PATTERN.containsMatchIn(text)
469+
}
470+
405471
/**
406472
* Determine the field type from multiple sources.
407473
*/
@@ -460,9 +526,9 @@ class PasswordAutofillService : AutofillService() {
460526
// Check autofill hints first (most reliable)
461527
autofillHint?.lowercase()?.let { hint ->
462528
when {
463-
hint.contains("password") -> return FieldType.PASSWORD
464-
hint.contains("username") -> return FieldType.USERNAME
465-
hint.contains("email") -> return FieldType.EMAIL
529+
isPasswordPattern(hint) -> return FieldType.PASSWORD
530+
isEmailPattern(hint) -> return FieldType.EMAIL
531+
isUsernamePattern(hint) -> return FieldType.USERNAME
466532
else -> Unit
467533
}
468534
}
@@ -493,45 +559,56 @@ class PasswordAutofillService : AutofillService() {
493559
return FieldType.EMAIL
494560
}
495561

496-
// Check HTML name/id attributes
562+
// Check HTML name/id attributes with expanded patterns
497563
val htmlName = html.attributes?.firstOrNull { it.first == "name" }?.second?.lowercase()
498564
val htmlId = html.attributes?.firstOrNull { it.first == "id" }?.second?.lowercase()
499565

500566
htmlName?.let { name ->
501567
when {
502-
name.contains("password") || name.contains("pass") -> return FieldType.PASSWORD
503-
name.contains("email") -> return FieldType.EMAIL
504-
name.contains("user") || name.contains("login") -> return FieldType.USERNAME
568+
isPasswordPattern(name) -> return FieldType.PASSWORD
569+
isEmailPattern(name) -> return FieldType.EMAIL
570+
isUsernamePattern(name) -> return FieldType.USERNAME
505571
else -> Unit
506572
}
507573
}
508574

509575
htmlId?.let { id ->
510576
when {
511-
id.contains("password") || id.contains("pass") -> return FieldType.PASSWORD
512-
id.contains("email") -> return FieldType.EMAIL
513-
id.contains("user") || id.contains("login") -> return FieldType.USERNAME
577+
isPasswordPattern(id) -> return FieldType.PASSWORD
578+
isEmailPattern(id) -> return FieldType.EMAIL
579+
isUsernamePattern(id) -> return FieldType.USERNAME
580+
else -> Unit
581+
}
582+
}
583+
584+
// Check HTML autocomplete attribute (standard HTML5 attribute)
585+
val htmlAutocomplete = html.attributes?.firstOrNull { it.first == "autocomplete" }?.second?.lowercase()
586+
htmlAutocomplete?.let { autocomplete ->
587+
when {
588+
autocomplete.contains("current-password") || autocomplete.contains("new-password") -> return FieldType.PASSWORD
589+
autocomplete.contains("email") -> return FieldType.EMAIL
590+
autocomplete.contains("username") || autocomplete.contains("nickname") -> return FieldType.USERNAME
514591
else -> Unit
515592
}
516593
}
517594
}
518595

519-
// Check node hint
596+
// Check node hint with expanded patterns
520597
nodeHint?.toString()?.lowercase()?.let { hint ->
521598
when {
522-
hint.contains("password") || hint.contains("pass") -> return FieldType.PASSWORD
523-
hint.contains("email") -> return FieldType.EMAIL
524-
hint.contains("user") || hint.contains("login") -> return FieldType.USERNAME
599+
isPasswordPattern(hint) -> return FieldType.PASSWORD
600+
isEmailPattern(hint) -> return FieldType.EMAIL
601+
isUsernamePattern(hint) -> return FieldType.USERNAME
525602
else -> Unit
526603
}
527604
}
528605

529-
// Check ID entry (resource name)
606+
// Check ID entry (resource name) with expanded patterns
530607
idEntry?.lowercase()?.let { id ->
531608
when {
532-
id.contains("password") || id.contains("pass") -> return FieldType.PASSWORD
533-
id.contains("email") -> return FieldType.EMAIL
534-
id.contains("user") || id.contains("login") -> return FieldType.USERNAME
609+
isPasswordPattern(id) -> return FieldType.PASSWORD
610+
isEmailPattern(id) -> return FieldType.EMAIL
611+
isUsernamePattern(id) -> return FieldType.USERNAME
535612
else -> Unit
536613
}
537614
}

0 commit comments

Comments
 (0)