K/V backup using single file

Tests are still broken until restore has also been implemented with single file approach
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
index 11ded3d..31a7436 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
@@ -268,7 +268,9 @@
                 return TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
             }
         }
-        val result = kv.performBackup(packageInfo, data, flags)
+        val token = settingsManager.getToken() ?: error("no token in performFullBackup")
+        val salt = metadataManager.salt
+        val result = kv.performBackup(packageInfo, data, flags, token, salt)
         if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) {
             // hook in here to back up APKs of apps that are otherwise not allowed for backup
             backUpApksOfNotBackedUpPackages()
@@ -360,7 +362,7 @@
         val token = settingsManager.getToken() ?: error("no token in clearBackupData")
         val salt = metadataManager.salt
         try {
-            kv.clearBackupData(packageInfo)
+            kv.clearBackupData(packageInfo, token, salt)
         } catch (e: IOException) {
             Log.w(TAG, "Error clearing K/V backup data for $packageName", e)
             return TRANSPORT_ERROR
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
index a3fc645..5e119b4 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
@@ -18,13 +18,14 @@
             metadataManager = get()
         )
     }
+    single<KvDbManager> { KvDbManagerImpl(androidContext()) }
     single {
         KVBackup(
-            plugin = get<BackupPlugin>().kvBackupPlugin,
+            plugin = get(),
             settingsManager = get(),
             inputFactory = get(),
             crypto = get(),
-            nm = get()
+            dbManager = get()
         )
     }
     single {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
index 043ab1b..a436125 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
@@ -9,16 +9,21 @@
 import android.content.pm.PackageInfo
 import android.os.ParcelFileDescriptor
 import android.util.Log
-import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
 import com.stevesoltys.seedvault.crypto.Crypto
-import com.stevesoltys.seedvault.encodeBase64
 import com.stevesoltys.seedvault.header.VERSION
 import com.stevesoltys.seedvault.header.getADForKV
 import com.stevesoltys.seedvault.settings.SettingsManager
-import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
 import java.io.IOException
+import java.util.zip.GZIPOutputStream
 
-class KVBackupState(internal val packageInfo: PackageInfo)
+class KVBackupState(
+    internal val packageInfo: PackageInfo,
+    val token: Long,
+    val name: String,
+    val db: KVDb
+) {
+    var needsUpload: Boolean = false
+}
 
 const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
 
@@ -26,11 +31,11 @@
 
 @Suppress("BlockingMethodInNonBlockingContext")
 internal class KVBackup(
-    private val plugin: KVBackupPlugin,
+    private val plugin: BackupPlugin,
     private val settingsManager: SettingsManager,
     private val inputFactory: InputFactory,
     private val crypto: Crypto,
-    private val nm: BackupNotificationManager
+    private val dbManager: KvDbManager
 ) {
 
     private var state: KVBackupState? = null
@@ -39,14 +44,18 @@
 
     fun getCurrentPackage() = state?.packageInfo
 
-    fun getQuota(): Long {
-        return if (settingsManager.isQuotaUnlimited()) Long.MAX_VALUE else plugin.getQuota()
+    fun getQuota(): Long = if (settingsManager.isQuotaUnlimited()) {
+        Long.MAX_VALUE
+    } else {
+        DEFAULT_QUOTA_KEY_VALUE_BACKUP
     }
 
     suspend fun performBackup(
         packageInfo: PackageInfo,
         data: ParcelFileDescriptor,
-        flags: Int
+        flags: Int,
+        token: Long,
+        salt: String
     ): Int {
         val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0
         val isIncremental = flags and FLAG_INCREMENTAL != 0
@@ -73,7 +82,9 @@
         if (state != null) {
             throw AssertionError("Have state for ${state.packageInfo.packageName}")
         }
-        this.state = KVBackupState(packageInfo)
+        val name = crypto.getNameForPackage(salt, packageName)
+        val db = dbManager.getDb(packageName)
+        this.state = KVBackupState(packageInfo, token, name, db)
 
         // no need for backup when no data has changed
         if (dataNotChanged) {
@@ -82,12 +93,7 @@
         }
 
         // check if we have existing data for the given package
-        val hasDataForPackage = try {
-            plugin.hasDataForPackage(packageInfo)
-        } catch (e: IOException) {
-            Log.e(TAG, "Error checking for existing data for ${packageInfo.packageName}.", e)
-            return backupError(TRANSPORT_ERROR)
-        }
+        val hasDataForPackage = dbManager.existsDb(packageName)
         if (isIncremental && !hasDataForPackage) {
             Log.w(
                 TAG, "Requested incremental, but transport currently stores no data" +
@@ -101,80 +107,36 @@
         if (isNonIncremental && hasDataForPackage) {
             Log.w(TAG, "Requested non-incremental, deleting existing data.")
             try {
-                clearBackupData(packageInfo)
+                clearBackupData(packageInfo, token, salt)
             } catch (e: IOException) {
                 Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e)
             }
         }
 
         // parse and store the K/V updates
-        return storeRecords(packageInfo, data)
+        return storeRecords(data)
     }
 
-    private suspend fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int {
-        val backupSequence: Iterable<Result<KVOperation>>
-        val pmRecordNumber: Int?
-        if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER) {
-            // Since the package manager has many small keys to store,
-            // and this can be slow, especially on cloud-based storage,
-            // we get the entire data set first, so we can show progress notifications.
-            val list = parseBackupStream(data).toList()
-            backupSequence = list
-            pmRecordNumber = list.size
-        } else {
-            backupSequence = parseBackupStream(data).asIterable()
-            pmRecordNumber = null
-        }
+    private fun storeRecords(data: ParcelFileDescriptor): Int {
+        val state = this.state ?: error("No state in storeRecords")
         // apply the delta operations
-        var i = 1
-        for (result in backupSequence) {
+        for (result in parseBackupStream(data)) {
             if (result is Result.Error) {
                 Log.e(TAG, "Exception reading backup input", result.exception)
                 return backupError(TRANSPORT_ERROR)
             }
+            state.needsUpload = true
             val op = (result as Result.Ok).result
-            try {
-                storeRecord(packageInfo, op, i++, pmRecordNumber)
-            } catch (e: IOException) {
-                Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e)
-                // Returning something more forgiving such as TRANSPORT_PACKAGE_REJECTED
-                // will still make the entire backup fail.
-                // TODO However, TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED might buy us a retry,
-                //  we would just need to be careful not to create an infinite loop
-                //  for permanent errors.
-                return backupError(TRANSPORT_ERROR)
+            if (op.value == null) {
+                Log.e(TAG, "Deleting record with key ${op.key}")
+                state.db.delete(op.key)
+            } else {
+                state.db.put(op.key, op.value)
             }
         }
         return TRANSPORT_OK
     }
 
-    @Throws(IOException::class)
-    private suspend fun storeRecord(
-        packageInfo: PackageInfo,
-        op: KVOperation,
-        currentNum: Int,
-        pmRecordNumber: Int?
-    ) {
-        // update notification for package manager backup
-        if (pmRecordNumber != null) {
-            nm.onPmKvBackup(op.key, currentNum, pmRecordNumber)
-        }
-        // check if record should get deleted
-        if (op.value == null) {
-            Log.e(TAG, "Deleting record with base64Key ${op.base64Key}")
-            plugin.deleteRecord(packageInfo, op.base64Key)
-        } else {
-            plugin.getOutputStreamForRecord(packageInfo, op.base64Key).use { outputStream ->
-                outputStream.write(ByteArray(1) { VERSION })
-                val ad = getADForKV(VERSION, packageInfo.packageName)
-                crypto.newEncryptingStream(outputStream, ad).use { encryptedStream ->
-                    encryptedStream.write(op.value)
-                    encryptedStream.flush()
-                }
-            }
-        }
-    }
-
     /**
      * Parses a backup stream into individual key/value operations
      */
@@ -194,12 +156,11 @@
             }
             // encode key
             val key = changeSet.key
-            val base64Key = key.encodeBase64()
             val dataSize = changeSet.dataSize
 
             // read value
             val value = if (dataSize >= 0) {
-                Log.v(TAG, "  Delta operation key $key   size $dataSize   key64 $base64Key")
+                Log.v(TAG, "  Delta operation key $key   size $dataSize")
                 val bytes = ByteArray(dataSize)
                 val bytesRead = try {
                     changeSet.readEntityData(bytes, 0, dataSize)
@@ -213,19 +174,31 @@
                 bytes
             } else null
             // add change operation to the sequence
-            Result.Ok(KVOperation(key, base64Key, value))
+            Result.Ok(KVOperation(key, value))
         }
     }
 
     @Throws(IOException::class)
-    suspend fun clearBackupData(packageInfo: PackageInfo) {
-        plugin.removeDataOfPackage(packageInfo)
+    suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) {
+        Log.i(TAG, "Clearing K/V data of ${packageInfo.packageName}")
+        val name = state?.name ?: crypto.getNameForPackage(salt, packageInfo.packageName)
+        plugin.removeData(token, name)
+        if (!dbManager.deleteDb(packageInfo.packageName)) throw IOException()
     }
 
-    fun finishBackup(): Int {
-        Log.i(TAG, "Finish K/V Backup of ${state!!.packageInfo.packageName}")
-        plugin.packageFinished(state!!.packageInfo)
-        state = null
+    @Throws(IOException::class)
+    suspend fun finishBackup(): Int {
+        val state = this.state ?: error("No state in finishBackup")
+        val packageName = state.packageInfo.packageName
+        Log.i(TAG, "Finish K/V Backup of $packageName")
+
+        try {
+            if (state.needsUpload) uploadDb(state.token, state.name, packageName, state.db)
+        } catch (e: IOException) {
+            return TRANSPORT_ERROR
+        } finally {
+            this.state = null
+        }
         return TRANSPORT_OK
     }
 
@@ -234,17 +207,43 @@
      * because [finishBackup] is not called when we don't return [TRANSPORT_OK].
      */
     private fun backupError(result: Int): Int {
-        "Resetting state because of K/V Backup error of ${state!!.packageInfo.packageName}".let {
-            Log.i(TAG, it)
-        }
-        plugin.packageFinished(state!!.packageInfo)
-        state = null
+        val state = this.state ?: error("No state in backupError")
+        val packageName = state.packageInfo.packageName
+        Log.i(TAG, "Resetting state because of K/V Backup error of $packageName")
+
+        state.db.close()
+
+        this.state = null
         return result
     }
 
+    @Throws(IOException::class)
+    private suspend fun uploadDb(
+        token: Long,
+        name: String,
+        packageName: String,
+        db: KVDb
+    ) {
+        db.vacuum()
+        db.close()
+
+        plugin.getOutputStream(token, name).use { outputStream ->
+            outputStream.write(ByteArray(1) { VERSION })
+            val ad = getADForKV(VERSION, packageName)
+            crypto.newEncryptingStream(outputStream, ad).use { encryptedStream ->
+                GZIPOutputStream(encryptedStream).use { gZipStream ->
+                    dbManager.getDbInputStream(packageName).use { inputStream ->
+                        inputStream.copyTo(gZipStream)
+                    }
+                    // TODO remove log
+                    Log.d(TAG, "=> Uploaded db file for $packageName")
+                }
+            }
+        }
+    }
+
     private class KVOperation(
         val key: String,
-        val base64Key: String,
         /**
          * value is null when this is a deletion operation
          */
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVDbManager.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVDbManager.kt
new file mode 100644
index 0000000..7689220
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVDbManager.kt
@@ -0,0 +1,125 @@
+package com.stevesoltys.seedvault.transport.backup
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE
+import android.database.sqlite.SQLiteOpenHelper
+import android.provider.BaseColumns
+import java.io.File
+import java.io.FileInputStream
+import java.io.InputStream
+
+interface KvDbManager {
+    fun getDb(packageName: String): KVDb
+    fun getDbInputStream(packageName: String): InputStream
+    fun existsDb(packageName: String): Boolean
+    fun deleteDb(packageName: String): Boolean
+}
+
+class KvDbManagerImpl(private val context: Context) : KvDbManager {
+
+    override fun getDb(packageName: String): KVDb {
+        return KVDbImpl(context, getFileName(packageName))
+    }
+
+    private fun getFileName(packageName: String) = "kv_$packageName.db"
+
+    private fun getDbFile(packageName: String): File {
+        return context.getDatabasePath(getFileName(packageName))
+    }
+
+    override fun getDbInputStream(packageName: String): InputStream {
+        return FileInputStream(getDbFile(packageName))
+    }
+
+    override fun existsDb(packageName: String): Boolean {
+        return getDbFile(packageName).isFile
+    }
+
+    override fun deleteDb(packageName: String): Boolean {
+        return getDbFile(packageName).delete()
+    }
+}
+
+interface KVDb {
+    fun put(key: String, value: ByteArray)
+    fun get(key: String): ByteArray?
+    fun getAll(): List<Pair<String, ByteArray>>
+    fun delete(key: String)
+    fun vacuum()
+    fun close()
+}
+
+class KVDbImpl(context: Context, fileName: String) :
+    SQLiteOpenHelper(context, fileName, null, DATABASE_VERSION), KVDb {
+
+    companion object {
+        private const val DATABASE_VERSION = 1
+
+        private object KVEntry : BaseColumns {
+            const val TABLE_NAME = "kv_entry"
+            const val COLUMN_NAME_KEY = "key"
+            const val COLUMN_NAME_VALUE = "value"
+        }
+
+        private const val SQL_CREATE_ENTRIES =
+            "CREATE TABLE IF NOT EXISTS ${KVEntry.TABLE_NAME} (" +
+                "${KVEntry.COLUMN_NAME_KEY} TEXT PRIMARY KEY," +
+                "${KVEntry.COLUMN_NAME_VALUE} BLOB)"
+    }
+
+    override fun onCreate(db: SQLiteDatabase) {
+        db.execSQL(SQL_CREATE_ENTRIES)
+    }
+
+    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
+    }
+
+    override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
+    }
+
+    override fun vacuum() = writableDatabase.execSQL("VACUUM")
+
+    override fun put(key: String, value: ByteArray) {
+        val values = ContentValues().apply {
+            put(KVEntry.COLUMN_NAME_KEY, key)
+            put(KVEntry.COLUMN_NAME_VALUE, value)
+        }
+        writableDatabase.insertWithOnConflict(KVEntry.TABLE_NAME, null, values, CONFLICT_REPLACE)
+    }
+
+    override fun get(key: String): ByteArray? = readableDatabase.query(
+        KVEntry.TABLE_NAME,
+        arrayOf(KVEntry.COLUMN_NAME_VALUE),
+        "${KVEntry.COLUMN_NAME_KEY} = ?",
+        arrayOf(key),
+        null,
+        null,
+        null
+    ).use { cursor ->
+        if (!cursor.moveToNext()) null
+        else cursor.getBlob(0)
+    }
+
+    override fun getAll(): List<Pair<String, ByteArray>> = readableDatabase.query(
+        KVEntry.TABLE_NAME,
+        arrayOf(KVEntry.COLUMN_NAME_KEY, KVEntry.COLUMN_NAME_VALUE),
+        null,
+        null,
+        null,
+        null,
+        null
+    ).use { cursor ->
+        val list = ArrayList<Pair<String, ByteArray>>(cursor.count)
+        while (cursor.moveToNext()) {
+            list.add(Pair(cursor.getString(0), cursor.getBlob(1)))
+        }
+        list
+    }
+
+    override fun delete(key: String) {
+        writableDatabase.delete(KVEntry.TABLE_NAME, "${KVEntry.COLUMN_NAME_KEY} = ?", arrayOf(key))
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
index ea8606a..06ab624 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
@@ -90,6 +90,7 @@
     /**
      * This is expected to get called before [onOptOutAppBackup] and [onBackupUpdate].
      */
+    // TODO remove?
     fun onPmKvBackup(packageName: String, transferred: Int, expected: Int) {
         val text = "@pm@ record for $packageName"
         if (expectedApps == null) {
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
index 7c52c3d..73b2508 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
@@ -21,13 +21,11 @@
 import com.stevesoltys.seedvault.transport.backup.ApkBackup
 import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
 import com.stevesoltys.seedvault.transport.backup.BackupPlugin
-import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
 import com.stevesoltys.seedvault.transport.backup.FullBackup
-import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
 import com.stevesoltys.seedvault.transport.backup.InputFactory
 import com.stevesoltys.seedvault.transport.backup.KVBackup
-import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
 import com.stevesoltys.seedvault.transport.backup.PackageService
+import com.stevesoltys.seedvault.transport.backup.TestKvDbManager
 import com.stevesoltys.seedvault.transport.restore.FullRestore
 import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
 import com.stevesoltys.seedvault.transport.restore.KVRestore
@@ -61,23 +59,12 @@
     private val cryptoImpl = CryptoImpl(keyManager, cipherFactory, headerReader)
     private val metadataReader = MetadataReaderImpl(cryptoImpl)
     private val notificationManager = mockk<BackupNotificationManager>()
+    private val dbManager = TestKvDbManager()
 
     private val backupPlugin = mockk<BackupPlugin>()
-    private val kvBackupPlugin = mockk<KVBackupPlugin>()
-    private val kvBackup = KVBackup(
-        plugin = kvBackupPlugin,
-        settingsManager = settingsManager,
-        inputFactory = inputFactory,
-        crypto = cryptoImpl,
-        nm = notificationManager
-    )
-    private val fullBackupPlugin = mockk<FullBackupPlugin>()
-    private val fullBackup = FullBackup(
-        plugin = backupPlugin,
-        settingsManager = settingsManager,
-        inputFactory = inputFactory,
-        crypto = cryptoImpl
-    )
+    private val kvBackup =
+        KVBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl, dbManager)
+    private val fullBackup = FullBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl)
     private val apkBackup = mockk<ApkBackup>()
     private val packageService: PackageService = mockk()
     private val backup = BackupCoordinator(
@@ -121,12 +108,8 @@
     private val key2 = "RestoreKey2"
     private val key264 = key2.encodeBase64()
 
-    init {
-        @Suppress("deprecation")
-        every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin
-        @Suppress("deprecation")
-        every { backupPlugin.fullBackupPlugin } returns fullBackupPlugin
-    }
+    // as we use real crypto, we need a real name for packageInfo
+    private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName)
 
     @Test
     fun `test key-value backup and restore with 2 records`() = runBlocking {
@@ -135,8 +118,9 @@
         val bOutputStream = ByteArrayOutputStream()
         val bOutputStream2 = ByteArrayOutputStream()
 
+        every { settingsManager.getToken() } returns token
+        every { metadataManager.salt } returns salt
         // read one key/value record and write it to output stream
-        coEvery { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
         every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
         every { backupDataInput.readNextHeader() } returns true andThen true andThen false
         every { backupDataInput.key } returns key andThen key2
@@ -145,31 +129,13 @@
             appData.copyInto(value.captured) // write the app data into the passed ByteArray
             appData.size
         }
-        coEvery {
-            kvBackupPlugin.getOutputStreamForRecord(
-                packageInfo,
-                key64
-            )
-        } returns bOutputStream
         every { backupDataInput.readEntityData(capture(value2), 0, appData2.size) } answers {
             appData2.copyInto(value2.captured) // write the app data into the passed ByteArray
             appData2.size
         }
         coEvery {
-            kvBackupPlugin.getOutputStreamForRecord(
-                packageInfo,
-                key264
-            )
-        } returns bOutputStream2
-        every { kvBackupPlugin.packageFinished(packageInfo) } just Runs
-        coEvery {
-            apkBackup.backupApkIfNecessary(
-                packageInfo,
-                UNKNOWN_ERROR,
-                any()
-            )
+            apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any())
         } returns packageMetadata
-        every { settingsManager.getToken() } returns token
         coEvery {
             backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
         } returns metadataOutputStream
@@ -180,8 +146,13 @@
             metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
         } just Runs
 
-        // start and finish K/V backup
+        // start K/V backup
         assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
+
+        // upload DB
+        coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream
+
+        // finish K/V backup
         assertEquals(TRANSPORT_OK, backup.finishBackup())
 
         // start restore
@@ -231,8 +202,9 @@
         val appData = ByteArray(size).apply { Random.nextBytes(this) }
         val bOutputStream = ByteArrayOutputStream()
 
+        every { settingsManager.getToken() } returns token
+        every { metadataManager.salt } returns salt
         // read one key/value record and write it to output stream
-        coEvery { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
         every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
         every { backupDataInput.readNextHeader() } returns true andThen false
         every { backupDataInput.key } returns key
@@ -241,13 +213,6 @@
             appData.copyInto(value.captured) // write the app data into the passed ByteArray
             appData.size
         }
-        coEvery {
-            kvBackupPlugin.getOutputStreamForRecord(
-                packageInfo,
-                key64
-            )
-        } returns bOutputStream
-        every { kvBackupPlugin.packageFinished(packageInfo) } just Runs
         coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
         every { settingsManager.getToken() } returns token
         coEvery {
@@ -257,8 +222,13 @@
             metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
         } just Runs
 
-        // start and finish K/V backup
+        // start K/V backup
         assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
+
+        // upload DB
+        coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream
+
+        // finish K/V backup
         assertEquals(TRANSPORT_OK, backup.finishBackup())
 
         // start restore
@@ -297,16 +267,13 @@
         val packageMetadata = metadata.packageMetadataMap[packageInfo.packageName]!!
         metadata.packageMetadataMap[packageInfo.packageName] =
             packageMetadata.copy(backupType = BackupType.FULL)
-        // as we use real crypto, we need a real name for packageInfo
-        val name = cryptoImpl.getNameForPackage(salt, packageInfo.packageName)
 
         // return streams from plugin and app data
         val bOutputStream = ByteArrayOutputStream()
         val bInputStream = ByteArrayInputStream(appData)
-        coEvery { backupPlugin.getOutputStream(token, name) } returns bOutputStream
+        coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream
         every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
         every { settingsManager.isQuotaUnlimited() } returns false
-        every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
         coEvery {
             apkBackup.backupApkIfNecessary(
                 packageInfo,
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
index 6f0ee3d..f4374c7 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
@@ -222,7 +222,7 @@
     fun `clearing KV backup data throws`() = runBlocking {
         every { settingsManager.getToken() } returns token
         every { metadataManager.salt } returns salt
-        coEvery { kv.clearBackupData(packageInfo) } throws IOException()
+        coEvery { kv.clearBackupData(packageInfo, token, salt) } throws IOException()
 
         assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
     }
@@ -231,7 +231,7 @@
     fun `clearing full backup data throws`() = runBlocking {
         every { settingsManager.getToken() } returns token
         every { metadataManager.salt } returns salt
-        coEvery { kv.clearBackupData(packageInfo) } just Runs
+        coEvery { kv.clearBackupData(packageInfo, token, salt) } just Runs
         coEvery { full.clearBackupData(packageInfo, token, salt) } throws IOException()
 
         assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
@@ -241,7 +241,7 @@
     fun `clearing backup data succeeds`() = runBlocking {
         every { settingsManager.getToken() } returns token
         every { metadataManager.salt } returns salt
-        coEvery { kv.clearBackupData(packageInfo) } just Runs
+        coEvery { kv.clearBackupData(packageInfo, token, salt) } just Runs
         coEvery { full.clearBackupData(packageInfo, token, salt) } just Runs
 
         assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo))
@@ -264,7 +264,7 @@
         every {
             metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
         } just Runs
-        every { kv.finishBackup() } returns result
+        coEvery { kv.finishBackup() } returns result
         every { metadataOutputStream.close() } just Runs
 
         assertEquals(result, backup.finishBackup())
@@ -416,8 +416,12 @@
 
         every { settingsManager.canDoBackupNow() } returns true
         every { metadataManager.isLegacyFormat } returns false
+        every { settingsManager.getToken() } returns token
+        every { metadataManager.salt } returns salt
         // do actual @pm@ backup
-        coEvery { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
+        coEvery {
+            kv.performBackup(packageInfo, fileDescriptor, 0, token, salt)
+        } returns TRANSPORT_OK
         // now check if we have opt-out apps that we need to back up APKs for
         every { packageService.notBackedUpPackages } returns notAllowedPackages
         // update notification
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
index 4e44c9b..cf31145 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
@@ -39,7 +39,10 @@
     fun `checkFullBackupSize exceeds quota`() {
         every { settingsManager.isQuotaUnlimited() } returns false
 
-        assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1))
+        assertEquals(
+            TRANSPORT_QUOTA_EXCEEDED,
+            backup.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1)
+        )
     }
 
     @Test
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
index 5624328..a4476b6 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
@@ -8,12 +8,10 @@
 import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
 import android.app.backup.BackupTransport.TRANSPORT_OK
 import android.content.pm.PackageInfo
-import com.stevesoltys.seedvault.Utf8
 import com.stevesoltys.seedvault.getRandomString
 import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
 import com.stevesoltys.seedvault.header.VERSION
 import com.stevesoltys.seedvault.header.getADForKV
-import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
 import io.mockk.CapturingSlot
 import io.mockk.Runs
 import io.mockk.coEvery
@@ -21,34 +19,30 @@
 import io.mockk.just
 import io.mockk.mockk
 import io.mockk.verify
-import io.mockk.verifyOrder
 import kotlinx.coroutines.runBlocking
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Assertions.assertFalse
 import org.junit.jupiter.api.Assertions.assertTrue
 import org.junit.jupiter.api.Test
+import java.io.ByteArrayInputStream
 import java.io.IOException
-import java.util.Base64
 import kotlin.random.Random
 
 @Suppress("BlockingMethodInNonBlockingContext")
 internal class KVBackupTest : BackupTest() {
 
-    private val plugin = mockk<KVBackupPlugin>()
+    private val plugin = mockk<BackupPlugin>()
     private val dataInput = mockk<BackupDataInput>()
-    private val notificationManager = mockk<BackupNotificationManager>()
+    private val dbManager = mockk<KvDbManager>()
 
-    private val backup = KVBackup(
-        plugin = plugin,
-        settingsManager = settingsManager,
-        inputFactory = inputFactory,
-        crypto = crypto,
-        nm = notificationManager
-    )
+    private val backup = KVBackup(plugin, settingsManager, inputFactory, crypto, dbManager)
 
+    private val db = mockk<KVDb>()
+    private val packageName = packageInfo.packageName
     private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
-    private val key64 = Base64.getEncoder().encodeToString(key.toByteArray(Utf8))
     private val dataValue = Random.nextBytes(23)
+    private val dbBytes = Random.nextBytes(42)
+    private val inputStream = ByteArrayInputStream(dbBytes)
 
     @Test
     fun `has no initial state`() {
@@ -59,82 +53,35 @@
     fun `simple backup with one record`() = runBlocking {
         singleRecordBackup()
 
-        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
         assertTrue(backup.hasState())
+        assertEquals(packageInfo, backup.getCurrentPackage())
         assertEquals(TRANSPORT_OK, backup.finishBackup())
         assertFalse(backup.hasState())
     }
 
     @Test
-    fun `@pm@ backup shows notification`() = runBlocking {
-        // init plugin and give back two keys
-        initPlugin(true, pmPackageInfo)
-        createBackupDataInput()
-        every { dataInput.readNextHeader() } returnsMany listOf(true, true, false)
-        every { dataInput.key } returnsMany listOf("key1", "key2")
-        // we don't care about values, so just use the same one always
-        every { dataInput.dataSize } returns dataValue.size
-        every { dataInput.readEntityData(any(), 0, dataValue.size) } returns dataValue.size
-
-        // store first record and show notification for it
-        every { notificationManager.onPmKvBackup("key1", 1, 2) } just Runs
-        coEvery { plugin.getOutputStreamForRecord(pmPackageInfo, "a2V5MQ") } returns outputStream
-        every { outputStream.write(ByteArray(1) { VERSION }) } just Runs
-
-        // store second record and show notification for it
-        every { notificationManager.onPmKvBackup("key2", 2, 2) } just Runs
-        coEvery { plugin.getOutputStreamForRecord(pmPackageInfo, "a2V5Mg") } returns outputStream
-
-        // encrypt to and close output stream
-        every { crypto.newEncryptingStream(outputStream, any()) } returns encryptedOutputStream
-        every { encryptedOutputStream.write(any<ByteArray>()) } just Runs
-        every { encryptedOutputStream.flush() } just Runs
-        every { encryptedOutputStream.close() } just Runs
-        every { outputStream.flush() } just Runs
-        every { outputStream.close() } just Runs
-
-        assertEquals(TRANSPORT_OK, backup.performBackup(pmPackageInfo, data, 0))
-        assertTrue(backup.hasState())
-
-        every { plugin.packageFinished(pmPackageInfo) } just Runs
-
-        assertEquals(TRANSPORT_OK, backup.finishBackup())
-        assertFalse(backup.hasState())
-
-        // verify that notifications were shown
-        verifyOrder {
-            notificationManager.onPmKvBackup("key1", 1, 2)
-            notificationManager.onPmKvBackup("key2", 2, 2)
-        }
-    }
-
-    @Test
     fun `incremental backup with no data gets rejected`() = runBlocking {
-        coEvery { plugin.hasDataForPackage(packageInfo) } returns false
-        every { plugin.packageFinished(packageInfo) } just Runs
+        initPlugin(false)
+        every { db.close() } just Runs
 
         assertEquals(
             TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED,
-            backup.performBackup(packageInfo, data, FLAG_INCREMENTAL)
+            backup.performBackup(packageInfo, data, FLAG_INCREMENTAL, token, salt)
         )
         assertFalse(backup.hasState())
     }
 
     @Test
-    fun `check for existing data throws exception`() = runBlocking {
-        coEvery { plugin.hasDataForPackage(packageInfo) } throws IOException()
-        every { plugin.packageFinished(packageInfo) } just Runs
-
-        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
-        assertFalse(backup.hasState())
-    }
-
-    @Test
     fun `non-incremental backup with data clears old data first`() = runBlocking {
         singleRecordBackup(true)
-        coEvery { plugin.removeDataOfPackage(packageInfo) } just Runs
+        coEvery { plugin.removeData(token, name) } just Runs
+        every { dbManager.deleteDb(packageName) } returns true
 
-        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL))
+        assertEquals(
+            TRANSPORT_OK,
+            backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL, token, salt)
+        )
         assertTrue(backup.hasState())
         assertEquals(TRANSPORT_OK, backup.finishBackup())
         assertFalse(backup.hasState())
@@ -144,11 +91,11 @@
     fun `ignoring exception when clearing data when non-incremental backup has data`() =
         runBlocking {
             singleRecordBackup(true)
-            coEvery { plugin.removeDataOfPackage(packageInfo) } throws IOException()
+            coEvery { plugin.removeData(token, name) } throws IOException()
 
             assertEquals(
                 TRANSPORT_OK,
-                backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)
+                backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL, token, salt)
             )
             assertTrue(backup.hasState())
             assertEquals(TRANSPORT_OK, backup.finishBackup())
@@ -157,15 +104,18 @@
 
     @Test
     fun `package with no new data comes back ok right away`() = runBlocking {
+        every { crypto.getNameForPackage(salt, packageName) } returns name
+        every { dbManager.getDb(packageName) } returns db
         every { data.close() } just Runs
 
-        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED))
+        assertEquals(
+            TRANSPORT_OK,
+            backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED, token, salt)
+        )
         assertTrue(backup.hasState())
 
         verify { data.close() }
 
-        every { plugin.packageFinished(packageInfo) } just Runs
-
         assertEquals(TRANSPORT_OK, backup.finishBackup())
         assertFalse(backup.hasState())
     }
@@ -175,9 +125,9 @@
         initPlugin(false)
         createBackupDataInput()
         every { dataInput.readNextHeader() } throws IOException()
-        every { plugin.packageFinished(packageInfo) } just Runs
+        every { db.close() } just Runs
 
-        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0, token, salt))
         assertFalse(backup.hasState())
     }
 
@@ -189,9 +139,9 @@
         every { dataInput.key } returns key
         every { dataInput.dataSize } returns dataValue.size
         every { dataInput.readEntityData(any(), 0, dataValue.size) } throws IOException()
-        every { plugin.packageFinished(packageInfo) } just Runs
+        every { db.close() } just Runs
 
-        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0, token, salt))
         assertFalse(backup.hasState())
     }
 
@@ -199,24 +149,46 @@
     fun `no data records`() = runBlocking {
         initPlugin(false)
         getDataInput(listOf(false))
-        every { plugin.packageFinished(packageInfo) } just Runs
 
-        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
         assertTrue(backup.hasState())
         assertEquals(TRANSPORT_OK, backup.finishBackup())
         assertFalse(backup.hasState())
     }
 
     @Test
+    fun `null data deletes key`() = runBlocking {
+        initPlugin(true)
+        createBackupDataInput()
+        every { dataInput.readNextHeader() } returns true andThen false
+        every { dataInput.key } returns key
+        every { dataInput.dataSize } returns -1 // just documented by example code in LocalTransport
+        every { db.delete(key) } just Runs
+
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
+        assertTrue(backup.hasState())
+
+        uploadData()
+
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
     fun `exception while writing version`() = runBlocking {
         initPlugin(false)
-        getDataInput(listOf(true))
-        coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
+        getDataInput(listOf(true, false))
+        every { db.put(key, dataValue) } just Runs
+
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
+        assertTrue(backup.hasState())
+
+        every { db.vacuum() } just Runs
+        every { db.close() } just Runs
+        coEvery { plugin.getOutputStream(token, name) } returns outputStream
         every { outputStream.write(ByteArray(1) { VERSION }) } throws IOException()
         every { outputStream.close() } just Runs
-        every { plugin.packageFinished(packageInfo) } just Runs
-
-        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertEquals(TRANSPORT_ERROR, backup.finishBackup())
         assertFalse(backup.hasState())
 
         verify { outputStream.close() }
@@ -225,64 +197,40 @@
     @Test
     fun `exception while writing encrypted value to output stream`() = runBlocking {
         initPlugin(false)
-        getDataInput(listOf(true))
-        writeVersionAndEncrypt()
-        every { encryptedOutputStream.write(dataValue) } throws IOException()
-        every { plugin.packageFinished(packageInfo) } just Runs
-
-        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
-        assertFalse(backup.hasState())
-
-        verify { outputStream.close() }
-    }
-
-    @Test
-    fun `exception while flushing output stream`() = runBlocking {
-        initPlugin(false)
-        getDataInput(listOf(true))
-        writeVersionAndEncrypt()
-        every { encryptedOutputStream.write(dataValue) } just Runs
-        every { encryptedOutputStream.flush() } throws IOException()
-        every { encryptedOutputStream.close() } just Runs
-        every { outputStream.close() } just Runs
-        every { plugin.packageFinished(packageInfo) } just Runs
-
-        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
-        assertFalse(backup.hasState())
-
-        verify { outputStream.close() }
-    }
-
-    @Test
-    fun `ignoring exception while closing output stream`() = runBlocking {
-        initPlugin(false)
         getDataInput(listOf(true, false))
-        writeVersionAndEncrypt()
-        every { encryptedOutputStream.write(dataValue) } just Runs
-        every { encryptedOutputStream.flush() } just Runs
-        every { encryptedOutputStream.close() } just Runs
-        every { outputStream.close() } just Runs
-        every { plugin.packageFinished(packageInfo) } just Runs
+        every { db.put(key, dataValue) } just Runs
 
-        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0, token, salt))
         assertTrue(backup.hasState())
-        assertEquals(TRANSPORT_OK, backup.finishBackup())
+
+        every { db.vacuum() } just Runs
+        every { db.close() } just Runs
+        coEvery { plugin.getOutputStream(token, name) } returns outputStream
+        every { outputStream.write(ByteArray(1) { VERSION }) } just Runs
+        val ad = getADForKV(VERSION, packageInfo.packageName)
+        every { crypto.newEncryptingStream(outputStream, ad) } returns encryptedOutputStream
+        every { encryptedOutputStream.write(any<ByteArray>()) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.finishBackup())
         assertFalse(backup.hasState())
+
+        verify {
+            encryptedOutputStream.close()
+            outputStream.close()
+        }
     }
 
     private fun singleRecordBackup(hasDataForPackage: Boolean = false) {
         initPlugin(hasDataForPackage)
+        every { db.put(key, dataValue) } just Runs
         getDataInput(listOf(true, false))
-        writeVersionAndEncrypt()
-        every { encryptedOutputStream.write(dataValue) } just Runs
-        every { encryptedOutputStream.flush() } just Runs
-        every { encryptedOutputStream.close() } just Runs
-        every { outputStream.close() } just Runs
-        every { plugin.packageFinished(packageInfo) } just Runs
+        uploadData()
     }
 
     private fun initPlugin(hasDataForPackage: Boolean = false, pi: PackageInfo = packageInfo) {
-        coEvery { plugin.hasDataForPackage(pi) } returns hasDataForPackage
+        every { dbManager.existsDb(pi.packageName) } returns hasDataForPackage
+        every { crypto.getNameForPackage(salt, pi.packageName) } returns name
+        every { dbManager.getDb(pi.packageName) } returns db
     }
 
     private fun createBackupDataInput() {
@@ -301,11 +249,19 @@
         }
     }
 
-    private fun writeVersionAndEncrypt() {
-        coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
+    private fun uploadData() {
+        every { db.vacuum() } just Runs
+        every { db.close() } just Runs
+
+        coEvery { plugin.getOutputStream(token, name) } returns outputStream
         every { outputStream.write(ByteArray(1) { VERSION }) } just Runs
         val ad = getADForKV(VERSION, packageInfo.packageName)
         every { crypto.newEncryptingStream(outputStream, ad) } returns encryptedOutputStream
+        every { encryptedOutputStream.write(any<ByteArray>()) } just Runs // gzip header
+        every { encryptedOutputStream.write(any(), any(), any()) } just Runs // stream copy
+        every { dbManager.getDbInputStream(packageName) } returns inputStream
+        every { encryptedOutputStream.close() } just Runs
+        every { outputStream.close() } just Runs
     }
 
 }
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/TestKvDbManager.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/TestKvDbManager.kt
new file mode 100644
index 0000000..34a5e0d
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/TestKvDbManager.kt
@@ -0,0 +1,138 @@
+package com.stevesoltys.seedvault.transport.backup
+
+import com.stevesoltys.seedvault.getRandomString
+import com.stevesoltys.seedvault.toByteArrayFromHex
+import com.stevesoltys.seedvault.toHexString
+import junit.framework.Assert.assertEquals
+import junit.framework.Assert.assertFalse
+import junit.framework.Assert.assertNull
+import junit.framework.Assert.assertTrue
+import org.json.JSONObject
+import org.junit.jupiter.api.Assertions.assertArrayEquals
+import org.junit.jupiter.api.Test
+import java.io.ByteArrayInputStream
+import java.io.InputStream
+import kotlin.random.Random
+
+class TestKvDbManager : KvDbManager {
+
+    private var db: TestKVDb? = null
+
+    override fun getDb(packageName: String): KVDb {
+        return TestKVDb().apply { db = this }
+    }
+
+    override fun getDbInputStream(packageName: String): InputStream {
+        return ByteArrayInputStream(db!!.serialize().toByteArray())
+    }
+
+    override fun existsDb(packageName: String): Boolean {
+        return db != null
+    }
+
+    override fun deleteDb(packageName: String): Boolean {
+        clearDb()
+        return true
+    }
+
+    fun clearDb() {
+        this.db = null
+    }
+
+    fun readDbFromStream(inputStream: InputStream) {
+        this.db = TestKVDb.deserialize(String(inputStream.readBytes()))
+    }
+}
+
+class TestKVDb(private val json: JSONObject = JSONObject()) : KVDb {
+
+    override fun put(key: String, value: ByteArray) {
+        json.put(key, value.toHexString(spacer = ""))
+    }
+
+    override fun get(key: String): ByteArray? {
+        return json.getByteArray(key)
+    }
+
+    override fun getAll(): List<Pair<String, ByteArray>> {
+        val list = ArrayList<Pair<String, ByteArray>>(json.length())
+        json.keys().forEach { key ->
+            val bytes = json.getByteArray(key)
+            if (bytes != null) list.add(Pair(key, bytes))
+        }
+        return list
+    }
+
+    override fun delete(key: String) {
+        json.remove(key)
+    }
+
+    override fun vacuum() {
+    }
+
+    override fun close() {
+    }
+
+    fun serialize(): String {
+        return json.toString()
+    }
+
+    companion object {
+        fun deserialize(str: String): TestKVDb {
+            return TestKVDb(JSONObject(str))
+        }
+    }
+
+    private fun JSONObject.getByteArray(key: String): ByteArray? {
+        val str = optString(key, "")
+        if (str.isNullOrEmpty()) return null
+        return str.toByteArrayFromHex()
+    }
+
+}
+
+class TestKvDbManagerTest {
+
+    private val dbManager = TestKvDbManager()
+
+    private val key1 = getRandomString(12)
+    private val key2 = getRandomString(12)
+    private val bytes1 = Random.nextBytes(23)
+    private val bytes2 = Random.nextBytes(23)
+
+    @Test
+    fun test() {
+        assertFalse(dbManager.existsDb("foo"))
+
+        val db = dbManager.getDb("foo")
+        db.put(key1, bytes1)
+        db.put(key2, bytes2)
+        assertTrue(dbManager.existsDb("foo"))
+
+        assertArrayEquals(bytes1, db.get(key1))
+        assertArrayEquals(bytes2, db.get(key2))
+
+        val list = db.getAll()
+        assertEquals(2, list.size)
+        assertEquals(key1, list[0].first)
+        assertArrayEquals(bytes1, list[0].second)
+        assertEquals(key2, list[1].first)
+        assertArrayEquals(bytes2, list[1].second)
+
+        val dbBytes = dbManager.getDbInputStream("foo").readBytes()
+
+        assertTrue(dbManager.existsDb("foo"))
+        dbManager.clearDb()
+        assertFalse(dbManager.existsDb("foo"))
+
+        dbManager.readDbFromStream(ByteArrayInputStream(dbBytes))
+        assertTrue(dbManager.existsDb("foo"))
+        assertArrayEquals(bytes1, db.get(key1))
+        assertArrayEquals(bytes2, db.get(key2))
+        assertNull(db.get("bar"))
+
+        db.delete(key2)
+        assertNull(db.get(key2))
+    }
+
+}