Two lines of autofill are worth more than a week of form polish

Compose's autofill is a semantics property on a text field. Declaring content types lets password managers and the platform fill forms correctly, and omitting it is why users abandon sign-up.

6 min read
androidcomposekotlintext

Day 54 — Two lines of autofill beat a week of form polish

Day 54 of 100, closing the text pillar. This is the shortest-effort, highest-impact change in the series, and most apps ship without it.

The symptom

A login screen that password managers ignore:

Column {
    TextField(state = emailState, label = { Text("Email") })
    SecureTextField(state = passwordState, label = { Text("Password") })
    Button(onClick = ::signIn) { Text("Sign in") }
}

It works, and the user's password manager shows nothing. They switch apps, copy the password, switch back, paste, and discover the field cleared when the app went to the background.

For an app people sign into once a month, that friction is measurable in abandoned sessions — and it's invisible to a developer whose credentials are in the emulator's autofill service already.

Why the obvious fix fails

The obvious fix is to make the labels clearer:

TextField(state = emailState, label = { Text("Email address") })

Autofill services don't read your labels. They read semantics — a structured description of what each field is for, which the accessibility and autofill systems share. A field with no declared content type is an anonymous text box, and a password manager correctly declines to guess.

The other non-fix is an autofillHints string copied from a View-system answer. Compose has its own API, and the older Modifier.semantics { contentDescription = "password" } approach conflates autofill with screen-reader labelling — two different jobs.

Autofill reads declared content types from semantics, not from your visible labels

The actual mechanism

Declare the content type in semantics, and the platform does the rest:

TextField(
    state = emailState,
    label = { Text("Email") },
    modifier = Modifier.semantics { contentType = ContentType.EmailAddress },
)

SecureTextField(
    state = passwordState,
    label = { Text("Password") },
    modifier = Modifier.semantics { contentType = ContentType.Password },
)

Two modifiers. The password manager now offers to fill both, and — the part people forget — offers to save the credential after a successful sign-up.

ContentType covers the fields forms actually contain: Username, Password, NewUsername, NewPassword, EmailAddress, PhoneNumber, PersonFullName, PersonFirstName, PersonLastName, PostalAddress, PostalCode, AddressCountry, CreditCardNumber, CreditCardExpirationDate, CreditCardSecurityCode, SmsOtpCode, and more.

Combine them with + where a field serves two roles:

Modifier.semantics { contentType = ContentType.Username + ContentType.EmailAddress }

That's the right declaration for a login field that accepts either — and it lets the manager offer the correct stored value rather than guessing from the label.

New password versus password

The distinction that makes sign-up work:

// Sign IN — offer saved credentials
Modifier.semantics { contentType = ContentType.Password }

// Sign UP — offer to GENERATE a strong one, and save it after
Modifier.semantics { contentType = ContentType.NewPassword }

Using Password on a registration screen means the manager offers the user's existing password rather than generating a new one, which is the small nudge that leads to password reuse. NewPassword is what triggers the generate-and-save flow.

Pair it with the confirm field declared the same way, so the manager fills both.

Autofill also interacts with the validation from Day 38. A generated password can be thirty characters of mixed symbols, so a maxLength of 20 or an InputTransformation that strips punctuation will silently mangle it — and the user finds out at their next sign-in, not at sign-up. Password fields should constrain as little as possible.

One-time codes

The SMS code screen is the one users hate most, and it's one declaration:

TextField(
    state = otpState,
    modifier = Modifier.semantics { contentType = ContentType.SmsOtpCode },
    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
)

The code appears as a keyboard suggestion the moment the SMS arrives. No SMS-read permission, no message parsing — the platform does it, and asking for READ_SMS to achieve the same thing is both worse for the user and a Play Store review risk.

Saving needs a committed action

Filling works from declarations alone. Saving needs the platform to know the form was submitted:

val autofillManager = LocalAutofillManager.current

Button(onClick = {
    signIn(emailState.text.toString(), passwordState.text.toString())
    autofillManager?.commit()          // "this form was submitted"
}) { Text("Sign in") }

Without commit(), a new credential is often not offered for saving, so the user has to type it again next time and concludes autofill doesn't work in your app.

cancel() is the counterpart for a form the user abandoned, which suppresses a save prompt for a half-filled form.

Passkeys are where this is going

Worth knowing while you're in this code: the Credential Manager API covers passkeys, passwords and federated sign-in through one request, and it's the direction Android is pushing sign-in.

Autofill and Credential Manager solve overlapping problems — autofill fills any form, Credential Manager handles authentication specifically and can offer a passkey that replaces the password entirely. An app doing new sign-in work should look at Credential Manager first, and still declare content types for the rest of its forms: addresses, payment fields and the profile screen all benefit from autofill and are outside Credential Manager's scope.

The interaction with state

Two details specific to Compose:

Autofill writes to the state object, so TextFieldState picks it up like any other edit — no callback to wire, and Day 52's undo history records it as one transaction.

Backgrounding must not clear the field. The copy-paste dance at the top of this post fails when the field is remember rather than rememberSaveable, because switching to the password manager can recreate the activity. rememberTextFieldState is saveable by default, which is one more reason Day 38's migration pays off here.

How to prove it

The declarations are assertable in a test, which is worth having so a refactor can't silently drop them:

@Test fun loginFieldsDeclareContentTypes() = runComposeUiTest {
    setContent { LoginScreen() }
    onNodeWithTag("email").assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentType))
    onNodeWithTag("password").assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentType))
}

On device, the real test needs a populated autofill service: set one up in system settings, save a credential for your app once, then reinstall and try to sign in. If nothing is offered, a declaration is missing or commit() never fired.

The end-to-end check worth doing once per release: sign up with a generated password, sign out, sign back in. Any step where you have to type is a step some fraction of users won't complete. Doing it on a device with a third-party manager rather than the Google default is worth it once, since the two behave differently on partially-declared forms.

What this generalizes to

The pillar's closing point: declaring what something is gets you behaviour you didn't implement. A content type gets autofill, a Role gets correct screen-reader announcements, a LinkAnnotation gets link semantics, a heading gets navigation.

Five days of text keep returning to that. AnnotatedString works because the styling is part of the content rather than assembled by layout; TextFieldState works because it declares the whole state rather than a projection; autofill works because the field says what it holds. In each case the payoff came from describing the thing accurately and letting the platform act on the description — which is cheaper than the alternative and arrives with behaviours you would not have thought to build. In each case the payoff came from describing the thing accurately and letting the platform act on the description.

Tomorrow, Day 55 opens the images and graphics pillar.


Day 54 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Autofill in Compose.