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