Navigating the shift from Android’s legacy secure storage to the modern DataStore + Tink era.

In my previous article, we explored how to secure EncryptedSharedPreferences using Google Tink. It was a robust solution for its time, bridging the gap between security and the familiar SharedPreferences API.
But the Android ecosystem moves fast. As we look toward 2026, the writing is on the wall: EncryptedSharedPreferences is effectively dead.
With the release of androidx.security:security-crypto:1.1.0-alpha07, Google officially deprecated
EncryptedSharedPreferences. It served us well, but it was plagued by performance bottlenecks (strict mode violations on the main thread) and the dreaded “keyset corruption” exceptions that haunted Crashlytics logs on specific OEM devices.
It’s time to move on. The future is Jetpack DataStore combined with Google Tink.
Why the Shift?
- Performance: SharedPreferences does synchronous I/O on the calling thread. EncryptedSharedPreferences made this worse by adding heavy cryptographic operations. DataStore is built on Coroutines and Flow, ensuring all I/O happens off the main thread.
- Reliability: EncryptedSharedPreferences relied on the Android Keystore in ways that were sometimes brittle across different manufacturers. By decoupling the storage (DataStore) from the encryption (Tink), we gain more control and stability.
- Type Safety: With Proto DataStore, we get compile-time type safety, eliminating the parsing errors common with key-value pairs.
The Architecture: DataStore + Tink
We aren’t just swapping libraries; we are upgrading our architecture.
- Storage: Proto DataStore (persists typed objects to disk).
- Encryption: Google Tink (encrypts the data stream before it hits the disk).
This approach encrypts the entire file, not just individual values, offering superior security and performance.
Step 1: The Dependencies
First, let’s bring in the modern stack. We need DataStore for storage, Protobuf for the schema, and Tink for the heavy lifting.
| // app/build.gradle.kts | |
| plugins { | |
| id("com.google.protobuf") version "0.9.5" | |
| } | |
| dependencies { | |
| // DataStore | |
| implementation("androidx.datastore:datastore:1.2.0") | |
| implementation("com.google.protobuf:protobuf-javalite:4.33.1") | |
| // Google Tink | |
| implementation("com.google.crypto.tink:tink-android:1.19.0") | |
| } | |
| protobuf { | |
| protoc { | |
| artifact = "com.google.protobuf:protoc:4.33.1" | |
| } | |
| generateProtoTasks { | |
| all().forEach { task -> | |
| task.builtins { | |
| create("java") { | |
| option("lite") | |
| } | |
| } | |
| } | |
| } | |
| } |
Step 2: The Schema
Define your data structure in src/main/proto/user_prefs.proto.
syntax = "proto3";
option java_package = "com.encryptedsharedprefrencesdemo.data.local";
option java_multiple_files = true;
message UserPreferences {
bool is_login = 1;
string user_id = 2;
}
Step 3: The CryptoManager
We need a helper to handle the Tink keyset. We use Tink’s StreamingAead primitive, which is designed specifically for encrypting streams of data—perfect for DataStore.
| class CryptoManager(context: Context) { | |
| private val aead = AndroidKeysetManager.Builder() | |
| .withSharedPref(context, "master_keyset", "master_key_preference") | |
| .withKeyTemplate(KeyTemplates.get("AES128_GCM_HKDF_4KB")) | |
| .withMasterKeyUri("android-keystore://master_key") | |
| .build() | |
| .keysetHandle | |
| .getPrimitive(StreamingAead::class.java) | |
| fun encrypt(outputStream: OutputStream): OutputStream { | |
| return aead.newEncryptingStream(outputStream, ByteArray(0)) | |
| } | |
| fun decrypt(inputStream: InputStream): InputStream { | |
| return aead.newDecryptingStream(inputStream, ByteArray(0)) | |
| } | |
| } |
Step 4: The Encrypted Serializer
This is the magic glue. DataStore delegates reading and writing to a Serializer. We intercept this process to inject our encryption layer.
| @Singleton | |
| class UserPreferencesSerializer @Inject constructor( | |
| private val cryptoManager: CryptoManager | |
| ) : Serializer<UserPreferences> { | |
| override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance() | |
| override suspend fun readFrom(input: InputStream): UserPreferences { | |
| return try { | |
| // Decrypt the stream before parsing | |
| val decryptedStream = cryptoManager.decrypt(input) | |
| UserPreferences.parseFrom(decryptedStream) | |
| } catch (exception: InvalidProtocolBufferException) { | |
| throw CorruptionException("Cannot read proto.", exception) | |
| } | |
| } | |
| override suspend fun writeTo(t: UserPreferences, output: OutputStream) { | |
| // Encrypt the stream while writing | |
| val encryptedStream = cryptoManager.encrypt(output) | |
| t.writeTo(encryptedStream) | |
| encryptedStream.close() // Important: close to finalize encryption blocks | |
| } | |
| } |
Step 5: The Repository & Migration
You can’t just leave your users’ data behind. DataStore provides a seamless migration API.
| @Singleton | |
| class DataStoreRepository @Inject constructor( | |
| private val context: Context, | |
| cryptoManager: CryptoManager | |
| ) { | |
| private val Context.userPreferencesStore: DataStore<UserPreferences> by dataStore( | |
| fileName = "user_prefs.pb", | |
| serializer = UserPreferencesSerializer(cryptoManager), | |
| produceMigrations = { context -> | |
| listOf( | |
| SharedPreferencesMigration( | |
| context, | |
| "secret_shared_prefs" // Your old SharedPrefs name | |
| ) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences -> | |
| // Map old key-values to your new Proto object | |
| // Note: If migrating from EncryptedSharedPreferences, ensure keys match | |
| // or manually decrypt before this step if needed. | |
| currentData | |
| } | |
| ) | |
| } | |
| ) | |
| val userPreferencesFlow: Flow<UserPreferences> = context.userPreferencesStore.data | |
| .catch { exception -> | |
| if (exception is IOException) { | |
| emit(UserPreferences.getDefaultInstance()) | |
| } else { | |
| throw exception | |
| } | |
| } | |
| // ... update methods ... | |
| } |
Step 6: Verifying the Migration
Trust, but verify. How do we know the migration actually worked? In our implementation, we added logging to the migration block in
DataStoreRepository and the serializer. Here is what a successful migration looks like in Logcat:
- Detection: DataStore initializes and detects the old SharedPreferences file.
- Migration: The
SharedPreferencesMigrationcallback is triggered. - Encryption: The new data is encrypted and written to the
.pbfile
// 1. Migration starts D/DataStore: Starting migration from SharedPreferences... // 2. Data is mapped D/DataStore: Migrating - isLogin: true, userId: OLD_USER_999 // 3. New data is encrypted and saved D/DataStore: Writing to DataStore (Encrypting...) // 4. Subsequent reads decrypt the data D/DataStore: Reading from DataStore (Decrypting...) D/DataStore: Retrieved userId after migration: OLD_USER_999
If you inspect the file system at /data/data/your.package/files/datastore/user_prefs.pb, you will see a binary file. Attempting to open it as text will show unreadable encrypted content—proof that Tink is doing its job.
Step 7: The Cleanup
Once you have verified that your users are successfully migrating (track this via analytics events in your migration callback!), you can start removing the legacy code.
In a future release, you can safely delete:
- EncryptedSharedPreferences Dependency: Remove
androidx.security:security-cryptofrom your build.gradle.kts if you aren’t using it elsewhere. - Legacy Wrapper Classes: Delete any helper classes like EncryptedPreferences.kt or
EncPref.ktthat were wrapping the old SharedPreferences. - Old XML Files: The
SharedPreferencesMigrationdoes not automatically delete the old XML file (to prevent data loss if migration fails). Once you are confident, you can manually delete the oldsecret_shared_prefs.xmlusingcontext.deleteSharedPreferences("secret_shared_prefs").
Job Offers
References
- Sample app in GitHub
- Android Security Guidelines
- Jetpack Datastore
- Android Shared Preferences
- Google Tink
- Encrypted Shared Preferences
Please feel free to reach out to me on LinkedIn and Twitter
Conclusion
The transition from EncryptedSharedPreferences to DataStore + Tink is more than a deprecation fix — it’s a significant upgrade in the quality and security of your application.
By 2026, synchronous storage access on the main thread will likely be flagged as a strict violation by default in Android. Don’t wait for the crash reports. Migrate today.
This article was previously published on proandroiddev.com



