SecureAndroidPersist — Android Persistence and Encryption Made Simple

Ioannis Anifantakis
7 min readOct 17, 2024

--

Introduction

Securing user data in modern mobile apps is more important than ever. Whether you’re storing sensitive user credentials, API tokens, or other confidential information, SecureAndroidPersist makes it easy to encrypt, persist, and manage that data. This library is designed to give developers robust security with minimal setup and coding effort.

You can find the AndroidSerurePersist library here
but I urge you to read through before doing so.

What this library does

SecureAndroidPersist is an Android library that streamlines the encryption and persistence of sensitive data. It can handle SharedPreferences, DataStore, complex objects, files, and raw data — all while seamlessly integrating into your existing code.

With property delegation, you can treat encrypted data just like any other variable, making it feel natural and straightforward to use.

Key Benefits

  • Easily implement secure encrypted storage solutions without extensive configuration.
  • Effortlessly encrypt raw data and files.
  • Automatically serialize and protect complex objects.
  • Use Kotlin property delegation to handle encrypted data like regular variables.

Feature Highlights

This library offers a wide range of features for securely persisting data, while also providing encryption services for raw data and files when needed.

Feature Set 1 — Encrypted Persistence

  • Secure Preferences Management: Encrypt and decrypt data with SharedPreferences and DataStore, no extra steps needed.
  • Complex Data Types: Save and restore custom objects and collections, fully encrypted.
  • File Encryption: Securely encrypt files so they remain protected even if stored externally.
  • Kotlin Property Delegation: Treat encrypted preferences as normal variables.
  • Jetpack Compose State Persistence: Easily persist and restore UI state securely with Compose, avoiding extra boilerplate.
  • Asynchronous Operations: Use non-blocking operations with DataStore for smooth performance.

Feature Set 2 — Raw Encryption

  • Raw Data and File Encryption: Encrypt and decrypt any raw data or files as needed.
  • External Key Management: Utilize custom keys stored remotely or shared across devices for flexible encryption strategies.

Why Choose SecureAndroidPersist?

  • Security: Your data is protected with robust, proven encryption techniques.
  • Simplicity: A developer-friendly API means less code, fewer headaches.
  • Versatility: Works with primitives, complex objects, files, and integrates smoothly with Android components.
  • Performance: Non-blocking operations ensure a responsive user experience.
  • Flexibility: Gives the option to manage keys externally and adapt the approach to your own security policies.

Library installation

Since SecureAndroidPersist is available via Jitpack, first add the Jitpack repository to your settings.gradle or project-level build.gradle:

// at your settings.gradle you have this code

repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io") // <-- add this line
}

Then add the dependencies to your app/build.gradle:

// core library
implementation("com.github.ioannisa.secured-android-persist:secure-persist:2.5.1")

// additional library to automatically handle state in Jetpack Compose
implementation("com.github.ioannisa.secured-android-persist:secure-persist-compose:2.5.1")

Code Samples — Encrypted Persistence

First, you need to initialize the PersistManager class which requires a context.

val persistManager = PersistManager(context)

The library allows you to take two paths to handling encrypted persisted data, and that is via the sharedPrefs and the dataStorePrefs instance variables.

sharedPrefs and dataStorePrefs instance variable

This allows you full access to the EncryptedSharedPreferences with zero-configuration.

Using the SharedPrefferences with this library utilizes the EncryptedSharePreferences for native security, thus this library does not include non-encrypted usage of SharedPreferences

  • sharedPrefs directly
  • sharedPrefs via property delegation

You will come to see that using sharedPrefs via property delegation is very easy and the defacto way to go when using this library.

SharedPrefs directly

Let the following code snippets show you how to use the sharedPrefs instance directly:

sharedPrefs:

For sharedPrefs things are pretty straight-forward:

// "put" an encrypted sharedPref for an integer for key "myKey"
persistManager.sharedPrefs.put(key="myKey", value="string value")

// "get" an encrypted sharedPref's value
persistManager.sharedPrefs.get(key="myKey", defaultValue="not found")

// delete a key from sharedPreferenes
persistManager.sharedPrefs.delete(key="myKey")

// you can also add and delete complex objects with automatic serialization
data class AuthInfo(
val accessToken: String = "",
val refreshToken: String = "",
val expiresIn: Long = 0L
)

// "put"
persistManager.sharedPrefs.put(key="authKey", value=AuthInfo())

// "get"
val auth = persistManager.sharedPrefs.get("authKey", defaultValue=AuthInfo())

dataStorePrefs:

If you know DataStore, then you know it uses coroutines. We take two routes. One is to use normally via coroutines, and the other is directly with coroutines working behind the scenes for your comfort.

dataStorePrefs uses encryption by default, unless you set encrypted=false.

dataStorePrefs using coroutines:

using coroutines we have the following functions

  • put to put data
  • get to get data
  • delete to delete data

// Encrypt and save a preference
CoroutineScope(Dispatchers.IO).launch {
persistManager.dataStorePrefs.put("key1", "secureValue")
}

// Decrypt and retrieve a preference
CoroutineScope(Dispatchers.IO).launch {
val value = persistManager.dataStorePrefs.get("key1", "defaultValue")
println("Retrieved value: $value")
}

// Delete a preference
CoroutineScope(Dispatchers.IO).launch {
persistManager.dataStorePrefs.delete("key1")
}

dataStorePrefs handling coroutines on the background:

For that we can use the “direct” variant of the dataStorePrefs. avoiding to execute from a suspended function.

  • putDirect to put data (non-blocking)
  • getDirect to get data (blocking)
  • deleteDirec to delete data (non-blocking)
// assuming encryption - Encrypt and store in a non-blocking way to DataStore
persistManager.dataStorePrefs.putDirect("key1", "secureValue")

// assuming encryption - Decrypt and get in a blocking way the value from DataStore
val value = persistManager.dataStorePrefs.getDirect("key1", "defaultValue")

// no encryption - store unencrypted in a non-blocking way to DataStore
persistManager.dataStorePrefs.putDirect("key1", "secureValue", encrypted = false)

// no encryption - get unencrypted in a blocking way the value from DataStore
val value = persistManager.dataStorePrefs.getDirect("key1", "defaultValue", encrypted = false)

// Delete the DataStore preference without using coroutines
persistManager.dataStorePrefs.deleteDirect("key1")

SharedPrefs and dataStorePrefs delegated

Let the following code snippets show you how to use the sharedPrefs instance via property delegation and as you see it is the preferred way to go.

Here the logic is “intuition”. We treat the preferences as normal variables. Here the idea is identical for both the sharedPrefs and for dataStorePrefs

// lets make a counter using sharedPref
var pref1 by persistManager.sharedPrefs.preference(1000)
pref1 = 100
pref1++
pref1++

// lets make a counter using dataStorePref
var pref1 by persistManager.dataStorePrefs.preference(1000)
pref1 = 100
pref1++
pref1++

Persitence — Jetpack Compose State

The library supports seamless integration also with jetpack compose’s state by allowing you to create mutable state properties that utilize the underlaying structure seamlessly.

You can literally say from the following example that persisting securely to EncryptedSharedPreferences and DataStore Preferences using state cannot get any easier!

import androidx.lifecycle.ViewModel
import eu.anifantakis.lib.securepersist.PersistManager
import eu.anifantakis.lib.securepersist.compose.mutableStateOf

class CounterViewModel(
persistManager: PersistManager
) : ViewModel() {

// If key is unspecified, property name becomes the key

// Defaults to EncryptedSharedPreferences and uses the property name as the key
var count1 by persistManager.sharedPrefs.mutableStateOf(1000)
private set

// Sets a custom key and uses DataStorePreferences with encryption
var count2 by persistManager.dataStorePrefs.mutableStateOf(
defaultValue = 2000,
key = "counter2Key"
)
private set

// Uses the property name as the key and sets storage to Unencrypted DataStorePreferences
var count3 by persistManager.dataStorePrefs.mutableStateOf(
defaultValue = 3000,
encrypted = false
)
private set

fun increment() {
count1++
count2++
count3++
}
}

Code Samples — Raw Encryption

The encryption algorithms used inside PersistManager have been exposed to you directly so you can utilize them for raw-data encryption, and file encryption.

Encryption utilizes KeyStore for securely storing your keys into your phone’s chip for maximum protection, but also allows you to generate and export/import custom keys incase you want to save raw encrypted data to some remote server.

Initializing is also simple here. For KeyStore it is pretty simple

//initializing for KeyStore
val encryptionManager = EncryptionManager(context)

If you want to have a key that can be stored outside your phone so you can reuse encrypted data stored on some server on multiple devices, you need to generate first that “exportable-key” which can be sent to that remote server, and then initialize EncryptionManager with that key.

// First, generate an external key:
val externalKey = EncryptionManager.generateExternalKey()

// Then, use the generated key to create an instance of EncryptionManager:
val encryptionManager = EncryptionManager(context, externalKey)

Example Encrypting/Decrypting Raw data with KeyStore

val encryptionManager = EncryptionManager(context)

// Encrypt data
val encryptedData = encryptionManager.encryptData("plainText")

// Decrypt data
val decryptedData = encryptionManager.decryptData(encryptedData)
val plainText = String(decryptedData, Charsets.UTF_8)

// Encrypt a value and encode it to a Base64 string
val encryptedValue = encryptionManager.encryptValue("valueToEncrypt")

// Decrypt a Base64 encoded string and return the original value
val decryptedValue: String = encryptionManager.decryptValue(encryptedValue, "defaultValue")

Storing the externalKey (SecretKey) to some other medium

Other than using instance of EncryptionManager, you can use it in a static context too in order to handle some data that was encrypted/decrypted with some other key without having to create more instances.

In this example we will combine this static context demo with SecretKey usage.

Scenario:

— Generate a SecretKey, use it in static context to encrypt some data.

— Create an encoding of that key so you can save it as String to a remote server

— Retrieve the encoded secret-key string from the remote server and decode it to build back again a SecretKey binary.

— Use it to decode the data that was encoded with that key

If you have generated a key and want to securely transmit it to some API or retrieve it, then this library provides two convenience static methods for encoding and decoding that key to a string so you can easily transfer it.

Exporting custom key to save it to some server

// generate a key
val originalKey = EncryptionManager.generateExternalKey()

// encrypt your data with that external key
val encryptedData = EncryptionManager.encryptData("Hello, Secure World!", originalKey)

// encode key to stringify it so you can send it to some server
val encodedKey: String = EncryptionManager.encodeSecretKey(originalKey)

// make network call and save the encoded key string to some server
// ...

Retrieving the custom key from the server to use it in the app

val encodedKey = ... // fetch the string with encoded key from a remote server

// construct a SecretKey from that encodedKey retrieved from the server
val decodedKey: SecretKey = EncryptionManager.decodeSecretKey(encodedKey)

// as you can see, you can decode the encrypted data using the key that was reconstructed from the encoded string
val decryptedText = EncryptionManager.decryptData(encryptedData, decodedKey)

FILES Encryption/Decription

Additionally, EncryptionManager offers encryptFile and decryptFile functions, enabling you to securely encrypt data and store it in a file, as well as read and decrypt the data from the file when needed.

These functions provide a seamless way to handle file-level encryption and decryption, ensuring that sensitive data remains secure during both storage and retrieval.

// Create an EncryptionManager instance
val encryptionManager = EncryptionManager(context, "your_key_alias")

// Specify the input file and the name for the encrypted file
val testFile = File(context.filesDir, "plain.txt")
val encryptedFileName = "encrypted.dat"

// Encrypt the file
encryptionManager.encryptFile(testFile, encryptedFileName)

// Decrypt the file
val decryptedContent: ByteArray = encryptionManager.decryptFile(encryptedFileName)
val decryptedText = String(decryptedContent)

--

--

No responses yet