/satchel

:school_satchel: A fast, secure and modular key-value storage with batteries-included for Android and JVM.

Primary LanguageKotlinMIT LicenseMIT

JitPack Android API Github Actions Codacy Kotlin ktlint License MIT

Satchel is a powerful and flexible key-value storage with batteries-included for Android and JVM.

It's backed by Coroutines and great third-party libraries (Tink, Kryo and Protobuf to name a few).

Features

Supported types

  • Double and List<Double>
  • Float and List<Float>
  • Int and List<Int>
  • Long and List<Long>
  • Boolean and List<Boolean>
  • String and List<String>
  • Serializable¹

¹ Not supported by satchel-serializer-protobuf-lite

Setup

  1. Add the JitPack repository to your project level build.gradle:
allprojects {
    repositories {
        maven { url 'https://jitpack.io' }
    }
}
  1. Next, add the desired dependencies to the module build.gradle:
dependencies {
    // Core (required)
    implementation "com.github.adrielcafe.satchel:satchel-core:$currentVersion"

    // Storers
    implementation "com.github.adrielcafe.satchel:satchel-storer-encrypted-file:$currentVersion"

    // Encrypters
    implementation "com.github.adrielcafe.satchel:satchel-encrypter-cipher:$currentVersion"
    implementation "com.github.adrielcafe.satchel:satchel-encrypter-jose4j:$currentVersion"
    implementation "com.github.adrielcafe.satchel:satchel-encrypter-tink-android:$currentVersion"
    implementation "com.github.adrielcafe.satchel:satchel-encrypter-tink-jvm:$currentVersion"

    // Serializers
    implementation "com.github.adrielcafe.satchel:satchel-serializer-base64-android:$currentVersion"
    implementation "com.github.adrielcafe.satchel:satchel-serializer-base64-jvm:$currentVersion"
    implementation "com.github.adrielcafe.satchel:satchel-serializer-gzip:$currentVersion"
    implementation "com.github.adrielcafe.satchel:satchel-serializer-kryo:$currentVersion"
    implementation "com.github.adrielcafe.satchel:satchel-serializer-protobuf-lite:$currentVersion"
}

Current version: JitPack

Usage

Take a look at the sample app for a working example.

Global instance

First initialize Satchel's global instance by calling Satchel.init():

Satchel.init(
    storer = FileSatchelStorer(storageFile),
    encrypter = BypassSatchelEncrypter,
    serializer = RawSatchelSerializer
)

Now you can use Satchel.storage everywhere:

Satchel.storage["key"] = "value"

It's also possible to check if Satchel was already initialized:

if (Satchel.isInitialized.not()) {
    // Init
}

Local instance

Use Satchel.with() to create a local instance:

val satchel = Satchel.with(
    storer = FileSatchelStorer(storageFile),
    encrypter = BypassSatchelEncrypter,
    serializer = RawSatchelSerializer
)

And start using it:

satchel["key"] = "value"

API

Satchel has a simple and familiar API based on MutableMap and SharedPreferences:

satchel.apply {
    val firstName = get<String>("firstName")

    val notificationsEnabled = getOrDefault("notificationsEnabled", false)

    val favoritePostIds = getOrDefault("favoritePostIds") { emptySet<Int>() }

    val registeredAt = getOrSet("registeredAt", currentTimestamp)

    val lastName = getOrSet("lastName") { "Doe" }

    set("username", "john.doe")

    setIfAbsent("lastName", lastName)

    keys.forEach { key ->
        // ...
    }

    when {
        isEmpty -> { /* ... */ }
        size == 1 -> { /* ... */ }
        contains("username") -> { /* ... */ }
    }

    remove("favoritePostIds")

    clear()
}

But unlike SharedPreferences, there's no apply() or commit(). Changes are saved asynchronously every time a write operation (set(), remove() and clear()) happens.

Delegates

It's possible to delegate the job of get and set the value of a specific key:

private var favoritePostIds by satchel.value(key = "favoritePostIds", defaultValue = emptySet<Int>())

// Will call set(key, value)
favoritePostIds = setOf(1, 2, 3)

// Will call getOrDefault(key, defaultValue)
showFavoritePosts(favoritePostIds)

If you doesn't specify a default value, it will return a nullable value:

private var username by satchel.value<String>("username")

username?.let(::showProfile)

Events

You can be notified every time the storage changes, just call addListener() to register a listener in the specified CoroutineScope:

satchel.addListener(lifecycleScope) { event ->
    when (event) {
        is SatchelEvent.Set -> { /* ... */ }
        is SatchelEvent.Remove -> { /* ... */ }
        is SatchelEvent.Clear -> { /* ... */ }
    }
}

Modules

Satchel has 3 different categories of modules:

  • Storers: responsible for reading and writing to the file system
  • Encrypters: responsible for encryption and decryption
  • Serializers: responsible for serialization and deserialization

The core library comes with one stock module for each category: FileSatchelStorer, BypassSatchelEncrypter and RawSatchelSerializer. All the other libraries are optional.

Storers

If you are developing for Android, I recommend to use the Context.filesDir as the parent folder. If you want to save in the external storage remember to ask for write permission first.

val file = File(context.filesDir, "satchel.storage")

Uses the FileOutputStream and FileInputStream to read and write without do any modification.

val storer = FileSatchelStorer(file)

Uses the EncryptedFile from Jetpack Security to read/write and also takes care of encryption/decryption.

val storer = EncryptedFileSatchelStorer.with(applicationContext, file)

Build your own Storer

Create a class or object that implements the SatchelStorer interface:

object MySatchelStorer : SatchelStorer {
    
    suspend fun store(data: ByteArray) {
        // Save the ByteArray wherever you want
    }

    fun retrieve(): ByteArray {
        // Load and return the stored ByteArray
    }
}

Encrypters

⚠️ Satchel doesn't store your crypto keys, it only uses it. So make sure to store them in a safe place.

Just bypass the encryption/decryption.

val encrypter = BypassSatchelEncrypter

Uses the Cipher for encryption/decryption.

val transformation = "AES"
val key = KeyGenerator
    .getInstance(transformation)
    .apply { init(256) }
    .generateKey()
val cipherKey = CipherKey.SecretKey(key)
val encrypter = CipherSatchelEncrypter.with(cipherKey, transformation)

Uses the Jose4j library for encryption/decryption.

val jwk = RsaJwkGenerator.generateJwk(2048)
val encrypter = Jose4jSatchelEncrypter.with(jwk)

Uses the Tink JVM library for encryption/decryption.

val keyset = KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate())
val encrypter = TinkSatchelEncrypter.with(keyset)

Uses the Tink Android library for encryption/decryption.

val encrypter = TinkSatchelEncrypter.with(applicationContext)

Build your own Encrypter

Create a class or object that implements the SatchelEncrypter interface:

object MySatchelEncrypter : SatchelEncrypter {
    
    suspend fun encrypt(data: ByteArray): ByteArray {
        // Return a encrypted ByteArray
    }

    fun decrypt(data: ByteArray): ByteArray {
        // Return a decrypted ByteArray
    }
}

Serializers

Uses the ObjectOutputStream/ObjectInputStream for serialization/deserialization.

val serializer = RawSatchelSerializer

Uses the GZIPOutputStream/GZIPInputStream for serialization/deserialization.

val serializer = GzipSatchelSerializer

Uses the Base64 from Java 8 for serialization/deserialization.

val serializer = Base64SatchelSerializer

Uses the Base64 from Android for serialization/deserialization.

val serializer = Base64SatchelSerializer

Uses the Kryo library for serialization/deserialization.

val serializer = KryoSatchelSerializer

⚠️ At the moment Kryo 5 only works on Android API 26 and later, this issue explains how to make it work in previous versions.

Uses the Protocol Buffers Java Lite library for serialization/deserialization.

val serializer = ProtobufLiteSatchelSerializer

⚠️ The current implementation doesn't supports Serializable objects.

Build your own Serializer

Create a class or object that implements the SatchelSerializer interface:

object MySatchelSerializer : SatchelSerializer {

    override suspend fun serialize(data: Map<String, Any>): ByteArray {
        // Transform the Map into a ByteArray
    }

    override fun deserialize(data: ByteArray): Map<String, Any> {
        // Transform the ByteArray into a Map
    }
}

Benchmark

The following benchmark consists in reading and writing 1k strings on Satchel and similar libraries. Also we compared all modules (storers, encrypters and serializers) individually to help you choose the fastest ones (if performance is a must for you).

You can run the benchmark by yourself, just execute the following command:

./gradlew benchmark:connectedCheck

The benchmark below was made on a Samsung Galaxy S20.

Similar libraries

For this benchmark, we use a local Satchel instance with the stock modules (FileSatchelStorer, BypassSatchelEncrypter and RawSatchelSerializer) from the core library.

Keep in mind that by using different modules you can get best or worse performance results (see the modules benchmarks below for a detailed comparison).

Read (ns) Write (ns)
Satchel 23.054 217.000
SharedPreferences 341.693 279.346
MMKV 461.807 551.308
Paper 71.388.808 427.568.730
Hawk 18.698.000 1.829.687.614

Storers

Read (ns) Write (ns)
FileSatchelStorer 55.302 47.811
EncryptedFileSatchelStorer 261.962 322.577

Encrypters

Read (ns) Write (ns)
BypassSatchelEncrypter 0 0
CipherSatchelEncrypter 189.423 202.577
Jose4jSatchelEncrypter 394.654 498.538
TinkSatchelEncrypter 46.439 55.134

Serializers

Read (ns) Write (ns)
RawSatchelSerializer 652.769 1.001.346
GzipSatchelSerializer 741.230 1.425.924
Base64SatchelSerializer (Android) 683.231 1.029.077
Base64SatchelSerializer (JVM) 703.769 1.041.000
KryoSatchelSerializer 209.923 170.654
ProtobufLiteSatchelSerializer 629.116 1.319.961