We Migrated Away From a Google-Recommended Library Because Google Broke It

EncryptedSharedPreferences was Google's own recommended way to store secrets on Android. It's now deprecated and unmaintained, and was the known source of the keyset corruption this app had to defend against.

We Migrated Away From a Google-Recommended Library Because Google Broke It

The commit message says everything the diff doesn't need to: "Encrypt the app password without a deprecated library." Photo Sweep's Android client used to store its one piece of secret state — the app password from Login Flow v2 — using androidx.security.crypto.EncryptedSharedPreferences, Google's own recommended abstraction for exactly this. That library is now deprecated, unmaintained, and was the actual cause of the corruption bug this app had to work around before it was replaced.

EncryptedSharedPreferences deprecated and corruption-prone, replaced by direct Android Keystore use
EncryptedSharedPreferences deprecated and corruption-prone, replaced by direct Android Keystore use

One fewer dependency, one fewer failure mode.

EncryptedSharedPreferences wraps SharedPreferences with AES encryption under a key it manages internally through Jetpack Security. It's not a bad idea — the problem is entirely about what happened to the library afterward: Google stopped maintaining it, and a known keyset-corruption bug persisted with no fix coming. Some devices would end up with a keyset the library itself could no longer read, silently locking users out of stored credentials with no path back except clearing app data — for this app, meaning a forced re-login, but for others potentially real data loss.

Depending on a security library that no longer receives security fixes is its own problem independent of any specific bug. For an app whose whole pitch is "nothing leaves your server, and what's stored locally is encrypted," carrying a deprecated crypto dependency is a bad look even before the corruption bug is factored in.

What replaced it

The fix wasn't a different library — it was no library. AccountStore.kt now talks to the Android Keystore directly:

private fun generateKey(): SecretKey {
    val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
    generator.init(
        KeyGenParameterSpec.Builder(
            KEY_ALIAS,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)
            .build(),
    )
    return generator.generateKey()
}

AES-256-GCM, with the key generated inside and never leaving the hardware Keystore — this process can request encrypt and decrypt operations against it but can never read the key material itself. A fresh initialization vector is generated per encryption and stored alongside the ciphertext, because reusing an IV under GCM breaks the scheme outright:

fun encrypt(plain: String): String {
    val cipher = Cipher.getInstance(TRANSFORMATION)
    cipher.init(Cipher.ENCRYPT_MODE, key)
    val body = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
    return Base64.encodeToString(cipher.iv + body, Base64.NO_WRAP)
}

The failure mode that matters more: degrading safely

The interesting design decision isn't the happy path — it's what happens when the Keystore itself is unusable, which does happen on some devices after a factory reset or a restored backup invalidates a key:

fun decrypt(stored: String): String? = try {
    // ...
} catch (e: Exception) {
    Log.w(TAG, "Stored account could not be decrypted; treating it as signed out", e)
    null
}

A value that can't be decrypted is treated as a session that's expired, not a crash and not — critically — a trigger to fall back to storing the credential unencrypted. The class doc is explicit about the alternative it refuses: "Being asked to sign in again after a restart is a far smaller cost than a credential sitting in clear text." On a device whose Keystore can't be used at all, the app password lives in memory for that session only, never touches disk, and the user re-authenticates after a restart. That's a worse user experience on a narrow slice of devices, chosen deliberately over a better user experience that would be a real security regression.

The broader point

"Google recommends it" and "Google will keep it working" turned out to be two different promises, and only the first one held. Removing the dependency didn't just fix the corruption bug — it removed the library from the trust chain entirely, leaving one less thing that could be deprecated out from under this app again. For a project that also cares about F-Droid, which disqualifies apps carrying deprecated or non-FOSS dependencies, it was a straightforwardly better place to end up regardless of the bug that forced the question.

Series: Photo Sweep.