Blog Infos
Author
Published
Topics
, , , ,
Author
Published

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

Image Source

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?
  1. 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.
  2. 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.
  3. 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:

  1. Detection: DataStore initializes and detects the old SharedPreferences file.
  2. Migration: The SharedPreferencesMigration callback is triggered.
  3. Encryption: The new data is encrypted and written to the .pb file
// 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:

  1. EncryptedSharedPreferences Dependency: Remove androidx.security:security-crypto from your build.gradle.kts if you aren’t using it elsewhere.
  2. Legacy Wrapper Classes: Delete any helper classes like EncryptedPreferences.kt or EncPref.kt that were wrapping the old SharedPreferences.
  3. Old XML Files: The SharedPreferencesMigration does not automatically delete the old XML file (to prevent data loss if migration fails). Once you are confident, you can manually delete the old secret_shared_prefs.xml using context.deleteSharedPreferences("secret_shared_prefs").

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

References
  1. Sample app in GitHub
  2. Android Security Guidelines
  3. Jetpack Datastore
  4. Android Shared Preferences
  5. Google Tink
  6. 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

Menu