The language that followed the wrong person
Language is per account, but the sync keyed on the value alone, so who it belonged to never entered the decision. On a shared device you inherit a stranger's.
By Meet ModiIf a per-account setting is cached and the cache key does not include the account, signing in as someone else leaves the previous person's value in place. For a language preference on a shared device that means a stranger's language, and because a profile with no preference set takes an early return, the wrong value survives precisely the case that should have reset it.
Two bugs with one cause
Shared devices are the normal case for this app, not an edge case. Two reports came in and they looked unrelated until I found the key.
- Sign in as a user whose profile carries no language at all, and the previous user's language stays on screen.
- Sign in as a user whose language we do not ship, and the same thing happens.
The collector that watches the profile and applies its language was keyed on the language value alone. Two different accounts both carrying "hi", or both carrying nothing, looked to it like no change had occurred, so it never ran. Who the value belonged to was simply not part of the comparison.
// Before: distinctUntilChanged over the language alone.
profileFlow.map { it.lang }
.distinctUntilChanged()
.collect { lang -> applyServerLanguage(lang) }
// After: the account is part of the identity of the change.
profileFlow.map { it.id to it.lang }
.distinctUntilChanged()
.collect { (id, lang) -> applyServerLanguage(id, lang) }And the second bug was an early return. applyServerLanguage bailed out before resolution when the language was blank or unsupported, so the fallback path that would have reset to English never ran.
A blank value means two different things
This is the part worth getting right, because the obvious fix is wrong. "This profile has no language" should not always mean "reset to English". It depends on whose language is currently on screen.
- A different account than the language was last applied for: reset to English. Their profile has no opinion, and what is on screen belongs to someone else. On a shared device that is a stranger.
- The same account, or the first sign-in on this install: leave it alone. That is this user's own pick, usually made in the pre-login picker, where there is no session to push it to yet. A profile that has not caught up must not overwrite a choice they made thirty seconds ago.
So the function needs the account, not just the value, and it needs to remember which account the current language was applied for. That stored pair is the whole fix.
Persisting it meant a real migration
The language had been an ignored property on the user entity, reconstructed from the next profile response. That meant a cold start always showed the default briefly, so it needed a real column.
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// Nullable, no default: existing rows get NULL, which the sync
// reads as "no language on this profile yet" rather than English.
db.execSQL("ALTER TABLE User ADD COLUMN lang TEXT")
}
}Nullable rather than defaulted is deliberate and it is the same distinction as above. NULL means "we do not know", which the sync handles. A default of "en" would mean "this user chose English", which is a claim we cannot make about every existing row.
Writing the migration out rather than leaving it to the destructive fallback matters more than it looks here. Falling back drops the user row, and the landing screen suspends on the first non-null user, so the app would deadlock on a blank screen rather than recover.
The general rule
Any value scoped to an account needs the account in its key, in its cache, and in its change detection. Leave the owner out of any one of those three and you get a value that outlives the session it belonged to. The same reasoning applies to the scoped language inside a single feed: a setting should be stored at the lifetime it actually has.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.