DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

Fixing the bad write doesn't fix the bad data: wiping a poisoned on-device cache with a schema version

Most bug fixes stop the bug from happening again. Some bugs leave something behind that the fix cannot touch: records the old code wrote wrongly, now sitting on users' phones, which the new code will read back faithfully for ever. This post is about one of those in QuickDial and the blunt migration I shipped for it: a schema version on a small SharedPreferences store, and a wipe of everything written before it.

Disclosure: I'm the developer of QuickDial, and this post is published from my account. It was written and published by an AI agent working on my behalf. QuickDial is an independent app, not affiliated with WhatsApp LLC or Meta.

The store

QuickDial puts a call button on an open personal chat so you can switch to a normal phone call in one tap. To do that it needs the chat's number. When the chat's title is a saved contact name, it looks the number up in Contacts. When the person is not saved, the app can read the number once from the chat's contact-info screen, with the user's confirmation, and remember it locally: chat name in, number out.

That memory is a tiny key-value store:

  • the key is the SHA-256 of the normalised chat title, as 64 hex characters, so the store holds no names in plain text;
  • the value is the number in one canonical, dialable form (an early version stored it exactly as scraped, so a number written as 0989... was later dialled like that).

The bug, and why the fix wasn't enough

The contact-info screen belongs to another app, and it repaints. For a moment after you move from one contact to the next, the screen can still show the previous person's number under the new person's name. The old code read it in that window and filed the number under the wrong name.

The read was fixed. But every mapping written by the old code was still on the phone, and the store had no way to tell a good entry from a bad one: both are just a hash and a number. A user would tap "call" on a chat and ring a stranger, with nothing on screen to explain why.

The migration: drop all of it

The choice was between a selective repair and a full wipe. A selective repair needs a rule for which entries are wrong, and there wasn't one. Any rule I could write would look exactly like the original bug: code deciding, without evidence, which number belongs to which name.

So the store now carries a schema version, and opening it at a lower version deletes every mapping:

private const val SCHEMA_VERSION = 2
private const val KEY_SCHEMA_VERSION = "__schema_version"
private const val KEY_UNREPORTED_DROPS = "__unreported_drops"

private fun migrateIfNeeded(prefs: SharedPreferences) {
    if (prefs.getInt(KEY_SCHEMA_VERSION, 1) >= SCHEMA_VERSION) return
    val doomed = mappingKeys(prefs.all.keys)
    val editor = prefs.edit()
    doomed.forEach(editor::remove)
    editor.putInt(KEY_SCHEMA_VERSION, SCHEMA_VERSION)
    if (doomed.isNotEmpty()) editor.putInt(KEY_UNREPORTED_DROPS, doomed.size)
    editor.apply()
}

private fun prefs(context: Context) =
    context.applicationContext
        .getSharedPreferences("remembered_chat_numbers", Context.MODE_PRIVATE)
        .also(::migrateIfNeeded)
Enter fullscreen mode Exit fullscreen mode

The cost is asymmetric, and that is the argument. A missing entry costs one extra lookup: the user confirms the number again. A wrong entry that survives costs a call to a stranger.

Make the blast radius a pure function

Metadata and data live in the same file, so the one dangerous decision is which keys get deleted. That decision is a pure function, separate from Android:

fun mappingKeys(allKeys: Set<String>): Set<String> =
    allKeys.filterTo(mutableSetOf()) { k ->
        k.length == 64 && k.all { it in '0'..'9' || it in 'a'..'f' }
    }
Enter fullscreen mode Exit fullscreen mode

Metadata keys start with __, so they can never be 64 hex characters, and the version marker cannot delete itself. Because the function takes a plain set, a JVM unit test can pin it: real hashes in, metadata out, near-misses (63 characters, upper-case hex) left alone. A mistake here would silently delete too much or keep a poisoned entry, and I'd rather catch that in a test than on someone's phone.

Tell the user once

The wipe is invisible from the outside: the call button simply asks for a number it used to know. So the migration records how many entries it dropped, and the main screen shows that once and then forgets it:

fun consumeUnreportedDropCount(context: Context): Int {
    val prefs = prefs(context)
    val count = prefs.getInt(KEY_UNREPORTED_DROPS, 0)
    if (count > 0) prefs.edit().remove(KEY_UNREPORTED_DROPS).apply()
    return count
}
Enter fullscreen mode Exit fullscreen mode

The count exists only on the device that held the entries, so the device is the only place that can report it. Zero means there was nothing to drop, and the user sees nothing.

Details that matter

  • Run the migration where the store is opened, not in Application.onCreate. Every read goes through prefs(), so no code path can read old entries before the wipe.
  • Write the version in the same edit as the deletes. If the process dies first, the next open simply runs the wipe again.
  • Normalise before you store. The other half of the fix: the value is written in one canonical form, so a later change to how numbers are formatted cannot create a second kind of entry.
  • Keep the version number boring. It goes up only when old entries can no longer be trusted, not for every format change you could read around.

Takeaways

  • When a bug wrote bad data, the fix has two parts: stop the bad writes, and decide what to do with the ones already on users' devices.
  • If you can't tell good records from bad, drop them all and let the app ask again. A selective repair with no evidence is the bug again.
  • Version local stores from day one; it's one integer, and it is what makes a later wipe possible.
  • Keep "which keys get deleted" in a pure, tested function.
  • If the user will notice the loss, tell them once.

QuickDial is on Google Play as "QuickDial: Direct Chat & Call": https://play.google.com/store/apps/details?id=com.ashwar.quickdialbridge. It works with WhatsApp and WhatsApp Business; independent app, not affiliated with WhatsApp LLC or Meta.

Top comments (0)