diff --git a/.travis.yml b/.travis.yml
index c4e4e394..dd23f68d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,8 +1,9 @@
+dist: trusty
 language: android
 android:
   components:
-  - build-tools-28.0.3
-  - android-28
+    - build-tools-28.0.3
+    - android-28
 
 licenses:
   - android-sdk-license-.+
diff --git a/app/build.gradle b/app/build.gradle
index 0545a964..cd662b64 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -27,6 +27,14 @@ android {
         targetCompatibility 1.8
         sourceCompatibility 1.8
     }
+    testOptions {
+        unitTests.all {
+            useJUnitPlatform()
+            testLogging {
+                events "passed", "skipped", "failed"
+            }
+        }
+    }
 
     // optional signingConfigs
     def keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -43,6 +51,7 @@ android {
             }
         }
         buildTypes.release.signingConfig = signingConfigs.release
+        buildTypes.debug.signingConfig = signingConfigs.release
     }
 }
 
@@ -70,15 +79,17 @@ preBuild.doLast {
     }
 }
 
+// To produce these binaries, in latest AOSP source tree, run
+// $ make
+def aospDeps = fileTree(include: [
+        // out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar
+        'android.jar',
+        // out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar
+        'libcore.jar'
+], dir: 'libs')
+
 dependencies {
-    // To produce these binaries, in latest AOSP source tree, run
-    // $ make
-    compileOnly fileTree(include: [
-            // out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar
-            'android.jar',
-            // out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar
-            'libcore.jar'
-    ], dir: 'libs')
+    compileOnly aospDeps
 
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
 
@@ -90,4 +101,11 @@ dependencies {
     implementation 'com.google.android.material:material:1.0.0'
     implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
     implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
+
+    lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'
+
+    testImplementation aospDeps
+    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.0'
+    testImplementation 'io.mockk:mockk:1.9.3'
+    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.0'
 }
diff --git a/app/libs/commons-io-2.6.jar b/app/libs/commons-io-2.6.jar
deleted file mode 100644
index 00556b11..00000000
Binary files a/app/libs/commons-io-2.6.jar and /dev/null differ
diff --git a/app/src/main/java/com/stevesoltys/backup/Backup.kt b/app/src/main/java/com/stevesoltys/backup/Backup.kt
index 707bd642..8d03e025 100644
--- a/app/src/main/java/com/stevesoltys/backup/Backup.kt
+++ b/app/src/main/java/com/stevesoltys/backup/Backup.kt
@@ -4,6 +4,8 @@ import android.app.Application
 import android.app.backup.IBackupManager
 import android.content.Context.BACKUP_SERVICE
 import android.os.ServiceManager.getService
+import com.stevesoltys.backup.crypto.KeyManager
+import com.stevesoltys.backup.crypto.KeyManagerImpl
 
 const val JOB_ID_BACKGROUND_BACKUP = 1
 
@@ -17,6 +19,9 @@ class Backup : Application() {
         val backupManager: IBackupManager by lazy {
             IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE))
         }
+        val keyManager: KeyManager by lazy {
+            KeyManagerImpl()
+        }
     }
 
 }
diff --git a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
index d414cb5d..b4d01bb8 100644
--- a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
+++ b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
@@ -3,7 +3,6 @@ package com.stevesoltys.backup
 import android.app.NotificationChannel
 import android.app.NotificationManager
 import android.app.NotificationManager.IMPORTANCE_MIN
-import android.app.backup.BackupManager
 import android.app.backup.BackupProgress
 import android.app.backup.IBackupObserver
 import android.content.Context
@@ -13,12 +12,11 @@ import android.util.Log.isLoggable
 import androidx.core.app.NotificationCompat
 import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
 import androidx.core.app.NotificationCompat.PRIORITY_LOW
-import com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport
 
 private const val CHANNEL_ID = "NotificationBackupObserver"
 private const val NOTIFICATION_ID = 1
 
-private val TAG = NotificationBackupObserver::class.java.name
+private val TAG = NotificationBackupObserver::class.java.simpleName
 
 class NotificationBackupObserver(
         private val context: Context,
@@ -94,11 +92,11 @@ class NotificationBackupObserver(
         if (isLoggable(TAG, INFO)) {
             Log.i(TAG, "Backup finished. Status: $status")
         }
-        if (status == BackupManager.SUCCESS) getBackupTransport(context).backupFinished()
         nm.cancel(NOTIFICATION_ID)
     }
 
     private fun getAppName(packageId: String): CharSequence {
+        if (packageId == "@pm@") return packageId
         val appInfo = pm.getApplicationInfo(packageId, 0)
         return pm.getApplicationLabel(appInfo)
     }
diff --git a/app/src/main/java/com/stevesoltys/backup/activity/restore/RestoreBackupActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/restore/RestoreBackupActivityController.java
index 9daa8799..43374240 100644
--- a/app/src/main/java/com/stevesoltys/backup/activity/restore/RestoreBackupActivityController.java
+++ b/app/src/main/java/com/stevesoltys/backup/activity/restore/RestoreBackupActivityController.java
@@ -29,7 +29,8 @@ import java.util.zip.ZipInputStream;
 
 import libcore.io.IoUtils;
 
-import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY;
+import static com.stevesoltys.backup.transport.backup.plugins.DocumentsStorageKt.DIRECTORY_FULL_BACKUP;
+
 
 /**
  * @author Steve Soltys
@@ -84,7 +85,7 @@ class RestoreBackupActivityController {
         while ((zipEntry = inputStream.getNextEntry()) != null) {
             String zipEntryPath = zipEntry.getName();
 
-            if (zipEntryPath.startsWith(DEFAULT_FULL_BACKUP_DIRECTORY)) {
+            if (zipEntryPath.startsWith(DIRECTORY_FULL_BACKUP)) {
                 String fileName = new File(zipEntryPath).getName();
                 results.add(fileName);
             }
diff --git a/app/src/main/java/com/stevesoltys/backup/crypto/CipherFactory.kt b/app/src/main/java/com/stevesoltys/backup/crypto/CipherFactory.kt
new file mode 100644
index 00000000..22bf719d
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/crypto/CipherFactory.kt
@@ -0,0 +1,31 @@
+package com.stevesoltys.backup.crypto
+
+import javax.crypto.Cipher
+import javax.crypto.Cipher.DECRYPT_MODE
+import javax.crypto.Cipher.ENCRYPT_MODE
+import javax.crypto.spec.GCMParameterSpec
+
+private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"
+private const val GCM_AUTHENTICATION_TAG_LENGTH = 128
+
+interface CipherFactory {
+    fun createEncryptionCipher(): Cipher
+    fun createDecryptionCipher(iv: ByteArray): Cipher
+}
+
+class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFactory {
+
+    override fun createEncryptionCipher(): Cipher {
+        return Cipher.getInstance(CIPHER_TRANSFORMATION).apply {
+            init(ENCRYPT_MODE, keyManager.getBackupKey())
+        }
+    }
+
+    override fun createDecryptionCipher(iv: ByteArray): Cipher {
+        return Cipher.getInstance(CIPHER_TRANSFORMATION).apply {
+            val spec = GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH, iv)
+            init(DECRYPT_MODE, keyManager.getBackupKey(), spec)
+        }
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt b/app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt
new file mode 100644
index 00000000..7d615b2a
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt
@@ -0,0 +1,138 @@
+package com.stevesoltys.backup.crypto
+
+import com.stevesoltys.backup.header.*
+import java.io.EOFException
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+/**
+ * A backup stream starts with a version byte followed by an encrypted [VersionHeader].
+ *
+ * The header will be encrypted with AES/GCM to provide authentication.
+ * It can be written using [encryptHeader] and read using [decryptHeader].
+ * The latter throws a [SecurityException],
+ * if the expected version and package name do not match the encrypted header.
+ *
+ * After the header, follows one or more data segments.
+ * Each segment begins with a clear-text [SegmentHeader]
+ * that contains the length of the segment
+ * and a nonce acting as the initialization vector for the encryption.
+ * The segment can be written using [encryptSegment] and read using [decryptSegment].
+ * The latter throws a [SecurityException],
+ * if the length of the segment is specified larger than allowed.
+ */
+interface Crypto {
+
+    /**
+     * Encrypts a backup stream header ([VersionHeader]) and writes it to the given [OutputStream].
+     *
+     * The header using a small segment containing only
+     * the version number, the package name and (optionally) the key of a key/value stream.
+     */
+    @Throws(IOException::class)
+    fun encryptHeader(outputStream: OutputStream, versionHeader: VersionHeader)
+
+    /**
+     * Encrypts a new backup segment from the given cleartext payload
+     * and writes it to the given [OutputStream].
+     *
+     * A segment starts with a [SegmentHeader] which includes the length of the segment
+     * and a nonce which is used as initialization vector for the encryption.
+     *
+     * After the header follows the encrypted payload.
+     * Larger backup streams such as from a full backup are encrypted in multiple segments
+     * to avoid having to load the entire stream into memory when doing authenticated encryption.
+     *
+     * The given cleartext can later be decrypted
+     * by calling [decryptSegment] on the same byte stream.
+     */
+    @Throws(IOException::class)
+    fun encryptSegment(outputStream: OutputStream, cleartext: ByteArray)
+
+    /**
+     * Reads and decrypts a [VersionHeader] from the given [InputStream]
+     * and ensures that the expected version, package name and key match
+     * what is found in the header.
+     * If a mismatch is found, a [SecurityException] is thrown.
+     *
+     * @return The read [VersionHeader] present in the beginning of the given [InputStream].
+     */
+    @Throws(IOException::class, SecurityException::class)
+    fun decryptHeader(inputStream: InputStream, expectedVersion: Byte, expectedPackageName: String,
+                      expectedKey: String? = null): VersionHeader
+
+    /**
+     * Reads and decrypts a segment from the given [InputStream].
+     *
+     * @return The decrypted segment payload as passed into [encryptSegment]
+     */
+    @Throws(EOFException::class, IOException::class, SecurityException::class)
+    fun decryptSegment(inputStream: InputStream): ByteArray
+}
+
+class CryptoImpl(
+        private val cipherFactory: CipherFactory,
+        private val headerWriter: HeaderWriter,
+        private val headerReader: HeaderReader) : Crypto {
+
+    @Throws(IOException::class)
+    override fun encryptHeader(outputStream: OutputStream, versionHeader: VersionHeader) {
+        val bytes = headerWriter.getEncodedVersionHeader(versionHeader)
+
+        encryptSegment(outputStream, bytes)
+    }
+
+    @Throws(IOException::class)
+    override fun encryptSegment(outputStream: OutputStream, cleartext: ByteArray) {
+        val cipher = cipherFactory.createEncryptionCipher()
+
+        check(cipher.getOutputSize(cleartext.size) <= MAX_SEGMENT_LENGTH)
+
+        val encrypted = cipher.doFinal(cleartext)
+        val segmentHeader = SegmentHeader(encrypted.size.toShort(), cipher.iv)
+        headerWriter.writeSegmentHeader(outputStream, segmentHeader)
+        outputStream.write(encrypted)
+    }
+
+    @Throws(IOException::class, SecurityException::class)
+    override fun decryptHeader(inputStream: InputStream, expectedVersion: Byte,
+                               expectedPackageName: String, expectedKey: String?): VersionHeader {
+        val decrypted = decryptSegment(inputStream, MAX_VERSION_HEADER_SIZE)
+        val header = headerReader.getVersionHeader(decrypted)
+
+        if (header.version != expectedVersion) {
+            throw SecurityException("Invalid version '${header.version.toInt()}' in header, expected '${expectedVersion.toInt()}'.")
+        }
+        if (header.packageName != expectedPackageName) {
+            throw SecurityException("Invalid package name '${header.packageName}' in header, expected '$expectedPackageName'.")
+        }
+        if (header.key != expectedKey) {
+            throw SecurityException("Invalid key '${header.key}' in header, expected '$expectedKey'.")
+        }
+
+        return header
+    }
+
+    @Throws(EOFException::class, IOException::class, SecurityException::class)
+    override fun decryptSegment(inputStream: InputStream): ByteArray {
+        return decryptSegment(inputStream, MAX_SEGMENT_LENGTH)
+    }
+
+    @Throws(EOFException::class, IOException::class, SecurityException::class)
+    fun decryptSegment(inputStream: InputStream, maxSegmentLength: Int): ByteArray {
+        val segmentHeader = headerReader.readSegmentHeader(inputStream)
+        if (segmentHeader.segmentLength > maxSegmentLength) {
+            throw SecurityException("Segment length too long: ${segmentHeader.segmentLength} > $maxSegmentLength")
+        }
+
+        val buffer = ByteArray(segmentHeader.segmentLength.toInt())
+        val bytesRead = inputStream.read(buffer)
+        if (bytesRead == -1) throw EOFException()
+        if (bytesRead != buffer.size) throw IOException()
+        val cipher = cipherFactory.createDecryptionCipher(segmentHeader.nonce)
+
+        return cipher.doFinal(buffer)
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt b/app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt
similarity index 52%
rename from app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt
rename to app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt
index 319e219c..793e34d2 100644
--- a/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt
+++ b/app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt
@@ -1,4 +1,4 @@
-package com.stevesoltys.backup.security
+package com.stevesoltys.backup.crypto
 
 import android.os.Build.VERSION.SDK_INT
 import android.security.keystore.KeyProperties.*
@@ -8,11 +8,34 @@ import java.security.KeyStore.SecretKeyEntry
 import javax.crypto.SecretKey
 import javax.crypto.spec.SecretKeySpec
 
-private const val KEY_SIZE = 256
+internal const val KEY_SIZE = 256
+private const val KEY_SIZE_BYTES = KEY_SIZE / 8
 private const val KEY_ALIAS = "com.stevesoltys.backup"
 private const val ANDROID_KEY_STORE = "AndroidKeyStore"
 
-object KeyManager {
+interface KeyManager {
+    /**
+     * Store a new backup key derived from the given [seed].
+     *
+     * The seed needs to be larger or equal to [KEY_SIZE_BYTES].
+     */
+    fun storeBackupKey(seed: ByteArray)
+
+    /**
+     * @return true if a backup key already exists in the [KeyStore].
+     */
+    fun hasBackupKey(): Boolean
+
+    /**
+     * Returns the backup key, so it can be used for encryption or decryption.
+     *
+     * Note that any attempt to export the key will return null or an empty [ByteArray],
+     * because the key can not leave the [KeyStore]'s hardware security module.
+     */
+    fun getBackupKey(): SecretKey
+}
+
+class KeyManagerImpl : KeyManager {
 
     private val keyStore by lazy {
         KeyStore.getInstance(ANDROID_KEY_STORE).apply {
@@ -20,18 +43,18 @@ object KeyManager {
         }
     }
 
-    fun storeBackupKey(seed: ByteArray) {
-        if (seed.size < KEY_SIZE / 8) throw IllegalArgumentException()
+    override fun storeBackupKey(seed: ByteArray) {
+        if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
         // TODO check if using first 256 of 512 bytes produced by PBKDF2WithHmacSHA512 is safe!
-        val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE / 8, "AES")
+        val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES")
         val ksEntry = SecretKeyEntry(secretKeySpec)
         keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
     }
 
-    fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) &&
+    override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) &&
             keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java)
 
-    fun getBackupKey(): SecretKey {
+    override fun getBackupKey(): SecretKey {
         val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry
         return ksEntry.secretKey
     }
@@ -41,6 +64,7 @@ object KeyManager {
                 .setBlockModes(BLOCK_MODE_GCM)
                 .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
                 .setRandomizedEncryptionRequired(true)
+        // unlocking is required only for decryption, so when restoring from backup
         if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true)
         return builder.build()
     }
diff --git a/app/src/main/java/com/stevesoltys/backup/header/Header.kt b/app/src/main/java/com/stevesoltys/backup/header/Header.kt
new file mode 100644
index 00000000..806b6afa
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/header/Header.kt
@@ -0,0 +1,43 @@
+package com.stevesoltys.backup.header
+
+import java.nio.charset.Charset
+
+internal const val VERSION: Byte = 0
+internal const val MAX_PACKAGE_LENGTH_SIZE = 255
+internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
+internal const val MAX_VERSION_HEADER_SIZE = 1 + Short.SIZE_BYTES * 2 + MAX_PACKAGE_LENGTH_SIZE + MAX_KEY_LENGTH_SIZE
+
+/**
+ * After the first version byte of each backup stream
+ * must follow followed this header encrypted with authentication.
+ */
+data class VersionHeader(
+        internal val version: Byte = VERSION, //  1 byte
+        internal val packageName: String,     // ?? bytes (max 255)
+        internal val key: String? = null      // ?? bytes
+) {
+    init {
+        check(packageName.length <= MAX_PACKAGE_LENGTH_SIZE)
+        key?.let { check(key.length <= MAX_KEY_LENGTH_SIZE) }
+    }
+}
+
+
+internal const val SEGMENT_LENGTH_SIZE: Int = Short.SIZE_BYTES
+internal const val MAX_SEGMENT_LENGTH: Int = Short.MAX_VALUE.toInt()
+internal const val IV_SIZE: Int = 12
+internal const val SEGMENT_HEADER_SIZE = SEGMENT_LENGTH_SIZE + IV_SIZE
+
+/**
+ * Each data segment must start with this header
+ */
+class SegmentHeader(
+        internal val segmentLength: Short,    //  2 bytes
+        internal val nonce: ByteArray         // 12 bytes
+) {
+    init {
+        check(nonce.size == IV_SIZE)
+    }
+}
+
+val Utf8: Charset = Charset.forName("UTF-8")
diff --git a/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt b/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt
new file mode 100644
index 00000000..2cf0b4a6
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt
@@ -0,0 +1,72 @@
+package com.stevesoltys.backup.header
+
+import java.io.EOFException
+import java.io.IOException
+import java.io.InputStream
+import java.nio.ByteBuffer
+
+interface HeaderReader {
+    @Throws(IOException::class, UnsupportedVersionException::class)
+    fun readVersion(inputStream: InputStream): Byte
+
+    @Throws(SecurityException::class)
+    fun getVersionHeader(byteArray: ByteArray): VersionHeader
+
+    @Throws(EOFException::class, IOException::class)
+    fun readSegmentHeader(inputStream: InputStream): SegmentHeader
+}
+
+internal class HeaderReaderImpl : HeaderReader {
+
+    @Throws(IOException::class, UnsupportedVersionException::class)
+    override fun readVersion(inputStream: InputStream): Byte {
+        val version = inputStream.read().toByte()
+        if (version < 0) throw IOException()
+        if (version > VERSION) throw UnsupportedVersionException(version)
+        return version
+    }
+
+    override fun getVersionHeader(byteArray: ByteArray): VersionHeader {
+        val buffer = ByteBuffer.wrap(byteArray)
+        val version = buffer.get()
+
+        val packageLength = buffer.short.toInt()
+        if (packageLength <= 0) throw SecurityException("Invalid package length: $packageLength")
+        if (packageLength > MAX_PACKAGE_LENGTH_SIZE) throw SecurityException("Too large package length: $packageLength")
+        if (packageLength > buffer.remaining()) throw SecurityException("Not enough bytes for package name")
+        val packageName = ByteArray(packageLength)
+                .apply { buffer.get(this) }
+                .toString(Utf8)
+
+        val keyLength = buffer.short.toInt()
+        if (keyLength < 0) throw SecurityException("Invalid key length: $keyLength")
+        if (keyLength > MAX_KEY_LENGTH_SIZE) throw SecurityException("Too large key length: $keyLength")
+        if (keyLength > buffer.remaining()) throw SecurityException("Not enough bytes for key")
+        val key = if (keyLength == 0) null else ByteArray(keyLength)
+                .apply { buffer.get(this) }
+                .toString(Utf8)
+
+        if (buffer.remaining() != 0) throw SecurityException("Found extra bytes in header")
+
+        return VersionHeader(version, packageName, key)
+    }
+
+    @Throws(EOFException::class, IOException::class)
+    override fun readSegmentHeader(inputStream: InputStream): SegmentHeader {
+        val buffer = ByteArray(SEGMENT_HEADER_SIZE)
+        val bytesRead = inputStream.read(buffer)
+        if (bytesRead == -1) throw EOFException()
+        if (bytesRead != SEGMENT_HEADER_SIZE) {
+            throw IOException("Read $bytesRead bytes, but expected $SEGMENT_HEADER_SIZE")
+        }
+
+        val segmentLength = ByteBuffer.wrap(buffer, 0, SEGMENT_LENGTH_SIZE).short
+        if (segmentLength <= 0) throw IOException()
+        val nonce = buffer.copyOfRange(SEGMENT_LENGTH_SIZE, buffer.size)
+
+        return SegmentHeader(segmentLength, nonce)
+    }
+
+}
+
+class UnsupportedVersionException(val version: Byte) : IOException()
diff --git a/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt b/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt
new file mode 100644
index 00000000..36f93b53
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt
@@ -0,0 +1,50 @@
+package com.stevesoltys.backup.header
+
+import java.io.IOException
+import java.io.OutputStream
+import java.nio.ByteBuffer
+
+interface HeaderWriter {
+    @Throws(IOException::class)
+    fun writeVersion(outputStream: OutputStream, header: VersionHeader)
+
+    fun getEncodedVersionHeader(header: VersionHeader): ByteArray
+
+    @Throws(IOException::class)
+    fun writeSegmentHeader(outputStream: OutputStream, header: SegmentHeader)
+}
+
+internal class HeaderWriterImpl : HeaderWriter {
+
+    @Throws(IOException::class)
+    override fun writeVersion(outputStream: OutputStream, header: VersionHeader) {
+        val headerBytes = ByteArray(1)
+        headerBytes[0] = header.version
+        outputStream.write(headerBytes)
+    }
+
+    override fun getEncodedVersionHeader(header: VersionHeader): ByteArray {
+        val packageBytes = header.packageName.toByteArray(Utf8)
+        val keyBytes = header.key?.toByteArray(Utf8)
+        val size = 1 + 2 + packageBytes.size + 2 + (keyBytes?.size ?: 0)
+        return ByteBuffer.allocate(size).apply {
+            put(header.version)
+            putShort(packageBytes.size.toShort())
+            put(packageBytes)
+            if (keyBytes == null) {
+                putShort(0.toShort())
+            } else {
+                putShort(keyBytes.size.toShort())
+                put(keyBytes)
+            }
+        }.array()
+    }
+
+    override fun writeSegmentHeader(outputStream: OutputStream, header: SegmentHeader) {
+        val buffer = ByteBuffer.allocate(SEGMENT_HEADER_SIZE)
+                .putShort(header.segmentLength)
+                .put(header.nonce)
+        outputStream.write(buffer.array())
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java b/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java
deleted file mode 100644
index 868930f2..00000000
--- a/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.stevesoltys.backup.security;
-
-import javax.crypto.*;
-import javax.crypto.spec.IvParameterSpec;
-import java.security.InvalidAlgorithmParameterException;
-import java.security.InvalidKeyException;
-import java.security.NoSuchAlgorithmException;
-
-/**
- * A utility class for encrypting and decrypting data using a {@link Cipher}.
- *
- * @author Steve Soltys
- */
-public class CipherUtil {
-
-    /**
-     * The cipher algorithm.
-     */
-    public static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
-
-    /**
-     * .
-     * Encrypts the given payload using the provided secret key.
-     *
-     * @param payload   The payload.
-     * @param secretKey The secret key.
-     * @param iv        The initialization vector.
-     */
-    public static byte[] encrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
-            NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException,
-            InvalidAlgorithmParameterException, InvalidKeyException {
-
-        return startEncrypt(secretKey, iv).doFinal(payload);
-    }
-
-    /**
-     * Initializes a cipher in {@link Cipher#ENCRYPT_MODE}.
-     *
-     * @param secretKey The secret key.
-     * @param iv        The initialization vector.
-     * @return The initialized cipher.
-     */
-    public static Cipher startEncrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
-            NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
-
-        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
-        cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
-        return cipher;
-    }
-
-    /**
-     * Decrypts the given payload using the provided secret key.
-     *
-     * @param payload   The payload.
-     * @param secretKey The secret key.
-     * @param iv        The initialization vector.
-     */
-    public static byte[] decrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
-            NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException,
-            InvalidAlgorithmParameterException, InvalidKeyException {
-
-        return startDecrypt(secretKey, iv).doFinal(payload);
-    }
-
-    /**
-     * Initializes a cipher in {@link Cipher#DECRYPT_MODE}.
-     *
-     * @param secretKey The secret key.
-     * @param iv        The initialization vector.
-     * @return The initialized cipher.
-     */
-    public static Cipher startDecrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException,
-            NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
-
-        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
-        cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
-        return cipher;
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java b/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java
deleted file mode 100644
index 9323e7b3..00000000
--- a/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.stevesoltys.backup.security;
-
-import javax.crypto.SecretKey;
-import javax.crypto.SecretKeyFactory;
-import javax.crypto.spec.PBEKeySpec;
-import javax.crypto.spec.SecretKeySpec;
-import java.security.NoSuchAlgorithmException;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.KeySpec;
-
-/**
- * A utility class which can be used for generating an AES secret key using PBKDF2.
- *
- * @author Steve Soltys
- */
-public class KeyGenerator {
-
-    /**
-     * The number of iterations for key generation.
-     */
-    private static final int ITERATIONS = 32767;
-
-    /**
-     * The generated key length.
-     */
-    private static final int KEY_LENGTH = 256;
-
-    /**
-     * Generates an AES secret key using PBKDF2.
-     *
-     * @param password The password.
-     * @param salt     The salt.
-     * @return The generated key.
-     */
-    public static SecretKey generate(String password, byte[] salt)
-            throws NoSuchAlgorithmException, InvalidKeySpecException {
-
-        SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
-        KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
-
-        SecretKey secretKey = secretKeyFactory.generateSecret(keySpec);
-        return new SecretKeySpec(secretKey.getEncoded(), "AES");
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/service/PackageService.java b/app/src/main/java/com/stevesoltys/backup/service/PackageService.java
deleted file mode 100644
index 0b7560a6..00000000
--- a/app/src/main/java/com/stevesoltys/backup/service/PackageService.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.stevesoltys.backup.service;
-
-import android.app.backup.IBackupManager;
-import android.content.pm.IPackageManager;
-import android.content.pm.PackageInfo;
-import android.os.RemoteException;
-import android.os.ServiceManager;
-import android.os.UserHandle;
-
-import java.util.List;
-import java.util.Set;
-
-import static com.google.android.collect.Sets.newArraySet;
-
-/**
- * @author Steve Soltys
- */
-public class PackageService {
-
-    private final IBackupManager backupManager;
-
-    private final IPackageManager packageManager;
-
-    private static final Set<String> IGNORED_PACKAGES = newArraySet(
-            "com.android.externalstorage",
-            "com.android.providers.downloads.ui",
-            "com.android.providers.downloads",
-            "com.android.providers.media",
-            "com.android.providers.calendar",
-            "com.android.providers.contacts",
-            "com.stevesoltys.backup"
-    );
-
-    public PackageService() {
-        backupManager = IBackupManager.Stub.asInterface(ServiceManager.getService("backup"));
-        packageManager = IPackageManager.Stub.asInterface(ServiceManager.getService("package"));
-    }
-
-    public String[] getEligiblePackages() throws RemoteException {
-        List<PackageInfo> packages = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).getList();
-        String[] packageArray = packages.stream()
-                .map(packageInfo -> packageInfo.packageName)
-                .filter(packageName -> !IGNORED_PACKAGES.contains(packageName))
-                .toArray(String[]::new);
-
-        return backupManager.filterAppsEligibleForBackup(packageArray);
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt b/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt
new file mode 100644
index 00000000..91dea0cf
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt
@@ -0,0 +1,57 @@
+package com.stevesoltys.backup.service
+
+import android.content.pm.IPackageManager
+import android.content.pm.PackageInfo
+import android.os.RemoteException
+import android.os.ServiceManager.getService
+import android.os.UserHandle
+import android.util.Log
+import com.google.android.collect.Sets.newArraySet
+import com.stevesoltys.backup.Backup
+import java.util.*
+
+private val TAG = PackageService::class.java.simpleName
+
+private val IGNORED_PACKAGES = newArraySet(
+        "com.android.externalstorage",
+        "com.android.providers.downloads.ui",
+        "com.android.providers.downloads",
+        "com.android.providers.media",
+        "com.android.providers.calendar",
+        "com.android.providers.contacts",
+        "com.stevesoltys.backup"
+)
+
+/**
+ * @author Steve Soltys
+ * @author Torsten Grote
+ */
+class PackageService {
+
+    private val backupManager = Backup.backupManager
+    private val packageManager: IPackageManager = IPackageManager.Stub.asInterface(getService("package"))
+
+    val eligiblePackages: Array<String>
+        @Throws(RemoteException::class)
+        get() {
+            val packages: List<PackageInfo> = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).list as List<PackageInfo>
+            val packageList = packages
+                    .map { packageInfo -> packageInfo.packageName }
+                    .filter { packageName -> !IGNORED_PACKAGES.contains(packageName) }
+                    .sorted()
+
+            Log.d(TAG, "Got ${packageList.size} packages: $packageList")
+
+            // TODO why is this filtering out so much?
+            val eligibleApps = backupManager.filterAppsEligibleForBackup(packageList.toTypedArray())
+
+            Log.d(TAG, "Filtering left ${eligibleApps.size} eligible packages: ${Arrays.toString(eligibleApps)}")
+
+            // add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
+            val packageArray = eligibleApps.toMutableList()
+            packageArray.add("@pm@")
+
+            return packageArray.toTypedArray()
+        }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java
index a319b377..8bcbfefa 100644
--- a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java
+++ b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java
@@ -12,8 +12,6 @@ import com.stevesoltys.backup.session.backup.BackupResult;
 import com.stevesoltys.backup.session.backup.BackupSession;
 import com.stevesoltys.backup.session.backup.BackupSessionObserver;
 
-import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport;
-
 /**
  * @author Steve Soltys
  */
@@ -61,9 +59,6 @@ class BackupObserver implements BackupSessionObserver {
 
     @Override
     public void backupSessionCompleted(BackupSession backupSession, BackupResult backupResult) {
-
-        if (backupResult == BackupResult.SUCCESS) getBackupTransport(context).backupFinished();
-
         context.runOnUiThread(() -> {
             if (backupResult == BackupResult.SUCCESS) {
                 Toast.makeText(context, R.string.backup_success, Toast.LENGTH_LONG).show();
diff --git a/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java b/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java
index 6441397a..8e966b8f 100644
--- a/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java
+++ b/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java
@@ -12,12 +12,9 @@ import com.stevesoltys.backup.activity.PopupWindowUtil;
 import com.stevesoltys.backup.activity.restore.RestorePopupWindowListener;
 import com.stevesoltys.backup.service.TransportService;
 import com.stevesoltys.backup.session.restore.RestoreSession;
-import com.stevesoltys.backup.transport.ConfigurableBackupTransport;
 
 import java.util.Set;
 
-import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport;
-
 /**
  * @author Steve Soltys
  */
@@ -28,8 +25,6 @@ public class RestoreService {
     private final TransportService transportService = new TransportService();
 
     public void restorePackages(Set<String> selectedPackages, Uri contentUri, Activity parent, String password) {
-        ConfigurableBackupTransport backupTransport = getBackupTransport(parent.getApplication());
-        backupTransport.prepareRestore(password, contentUri);
         try {
             PopupWindow popupWindow = PopupWindowUtil.showLoadingPopupWindow(parent);
             RestoreObserver restoreObserver = new RestoreObserver(parent, popupWindow, selectedPackages.size());
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt
index 140ea439..eab22e01 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt
@@ -5,6 +5,7 @@ import android.content.ActivityNotFoundException
 import android.content.Intent
 import android.content.Intent.*
 import android.os.Bundle
+import android.provider.DocumentsContract.EXTRA_PROMPT
 import android.widget.Toast
 import android.widget.Toast.LENGTH_LONG
 import androidx.lifecycle.ViewModelProviders
@@ -38,6 +39,7 @@ class BackupLocationFragment : PreferenceFragmentCompat() {
 
     private fun showChooseFolderActivity() {
         val openTreeIntent = Intent(ACTION_OPEN_DOCUMENT_TREE)
+        openTreeIntent.putExtra(EXTRA_PROMPT, getString(R.string.settings_backup_location_picker))
         openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
                 FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
         try {
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt
index a64f4ec8..59643546 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt
@@ -3,9 +3,9 @@ package com.stevesoltys.backup.settings
 import android.app.Application
 import android.util.ByteStringUtils.toHexString
 import androidx.lifecycle.AndroidViewModel
+import com.stevesoltys.backup.Backup
 import com.stevesoltys.backup.LiveEvent
 import com.stevesoltys.backup.MutableLiveEvent
-import com.stevesoltys.backup.security.KeyManager
 import io.github.novacrypto.bip39.*
 import io.github.novacrypto.bip39.Validation.InvalidChecksumException
 import io.github.novacrypto.bip39.Validation.InvalidWordCountException
@@ -48,7 +48,7 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica
         }
         val mnemonic = input.joinToString(" ")
         val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
-        KeyManager.storeBackupKey(seed)
+        Backup.keyManager.storeBackupKey(seed)
 
         // TODO remove once encryption/decryption uses key from KeyStore
         setBackupPassword(getApplication(), toHexString(seed))
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
index ccc0bb35..e36627d7 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
@@ -10,6 +10,7 @@ import android.view.Menu
 import android.view.MenuInflater
 import android.view.MenuItem
 import android.widget.Toast
+import android.widget.Toast.LENGTH_SHORT
 import androidx.lifecycle.ViewModelProviders
 import androidx.preference.Preference.OnPreferenceChangeListener
 import androidx.preference.PreferenceFragmentCompat
@@ -99,7 +100,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
             true
         }
         item.itemId == R.id.action_restore -> {
-            Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show()
+            Toast.makeText(requireContext(), "Not yet implemented", LENGTH_SHORT).show()
             true
         }
         else -> super.onOptionsItemSelected(item)
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
index dd20a14a..4c243eba 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
@@ -4,13 +4,15 @@ import android.app.Application
 import android.content.Intent
 import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
 import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+import android.util.Log
 import androidx.lifecycle.AndroidViewModel
+import com.stevesoltys.backup.Backup
 import com.stevesoltys.backup.LiveEvent
 import com.stevesoltys.backup.MutableLiveEvent
-import com.stevesoltys.backup.security.KeyManager
 import com.stevesoltys.backup.service.backup.requestFullBackup
+import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
 
-private val TAG = SettingsViewModel::class.java.name
+private val TAG = SettingsViewModel::class.java.simpleName
 
 class SettingsViewModel(application: Application) : AndroidViewModel(application) {
 
@@ -27,7 +29,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
     internal val chooseBackupLocation: LiveEvent<Boolean> = mChooseBackupLocation
     internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
 
-    fun recoveryCodeIsSet() = KeyManager.hasBackupKey()
+    fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
     fun locationIsSet() = getBackupFolderUri(getApplication()) != null
 
     fun handleChooseFolderResult(result: Intent?) {
@@ -45,6 +47,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
 
         // notify the UI that the location has been set
         locationWasSet.setEvent(wasEmptyBefore)
+
+        // stop backup service to be sure the old location will get updated
+        app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
+
+        Log.d(TAG, "New storage location chosen: $folderUri")
     }
 
     fun backupNow() = Thread { requestFullBackup(app) }.start()
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java
deleted file mode 100644
index 522a6a14..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java
+++ /dev/null
@@ -1,199 +0,0 @@
-package com.stevesoltys.backup.transport;
-
-import android.app.backup.BackupTransport;
-import android.app.backup.RestoreDescription;
-import android.app.backup.RestoreSet;
-import android.content.Context;
-import android.content.Intent;
-import android.content.pm.PackageInfo;
-import android.net.Uri;
-import android.os.ParcelFileDescriptor;
-import android.util.Log;
-
-import com.stevesoltys.backup.settings.SettingsActivity;
-import com.stevesoltys.backup.transport.component.BackupComponent;
-import com.stevesoltys.backup.transport.component.RestoreComponent;
-import com.stevesoltys.backup.transport.component.provider.ContentProviderBackupComponent;
-import com.stevesoltys.backup.transport.component.provider.ContentProviderRestoreComponent;
-
-import static android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED;
-import static android.os.Build.VERSION.SDK_INT;
-
-/**
- * @author Steve Soltys
- */
-public class ConfigurableBackupTransport extends BackupTransport {
-
-    private static final String TRANSPORT_DIRECTORY_NAME =
-            "com.stevesoltys.backup.transport.ConfigurableBackupTransport";
-
-    private static final String TAG = TRANSPORT_DIRECTORY_NAME;
-
-    private final Context context;
-
-    private final BackupComponent backupComponent;
-
-    private final RestoreComponent restoreComponent;
-
-    ConfigurableBackupTransport(Context context) {
-        this.context = context;
-        backupComponent = new ContentProviderBackupComponent(context);
-        restoreComponent = new ContentProviderRestoreComponent(context);
-    }
-
-    public void prepareRestore(String password, Uri fileUri) {
-        restoreComponent.prepareRestore(password, fileUri);
-    }
-
-    @Override
-    public String transportDirName() {
-        return TRANSPORT_DIRECTORY_NAME;
-    }
-
-    @Override
-    public String name() {
-        // TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName.
-        return this.getClass().getName();
-    }
-
-    @Override
-    public int getTransportFlags() {
-        if (SDK_INT >= 28) return FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED;
-        return 0;
-    }
-
-    @Override
-    public Intent dataManagementIntent() {
-        return new Intent(context, SettingsActivity.class);
-    }
-
-    @Override
-    public boolean isAppEligibleForBackup(PackageInfo targetPackage, boolean isFullBackup) {
-        return true;
-    }
-
-    @Override
-    public long requestBackupTime() {
-        return backupComponent.requestBackupTime();
-    }
-
-    @Override
-    public String dataManagementLabel() {
-        return backupComponent.dataManagementLabel();
-    }
-
-    @Override
-    public int initializeDevice() {
-        return backupComponent.initializeDevice();
-    }
-
-    @Override
-    public String currentDestinationString() {
-        return backupComponent.currentDestinationString();
-    }
-
-    /* Methods related to Backup */
-
-    @Override
-    public int performBackup(PackageInfo packageInfo, ParcelFileDescriptor inFd, int flags) {
-        return backupComponent.performIncrementalBackup(packageInfo, inFd, flags);
-    }
-
-    @Override
-    public int performBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) {
-        Log.w(TAG, "Warning: Legacy performBackup() method called.");
-        return performBackup(targetPackage, fileDescriptor, 0);
-    }
-
-    @Override
-    public int checkFullBackupSize(long size) {
-        return backupComponent.checkFullBackupSize(size);
-    }
-
-    @Override
-    public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor socket, int flags) {
-        // TODO handle flags
-        return performFullBackup(targetPackage, socket);
-    }
-
-    @Override
-    public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) {
-        return backupComponent.performFullBackup(targetPackage, fileDescriptor);
-    }
-
-    @Override
-    public int sendBackupData(int numBytes) {
-        return backupComponent.sendBackupData(numBytes);
-    }
-
-    @Override
-    public void cancelFullBackup() {
-        backupComponent.cancelFullBackup();
-    }
-
-    @Override
-    public int finishBackup() {
-        return backupComponent.finishBackup();
-    }
-
-    @Override
-    public long requestFullBackupTime() {
-        return backupComponent.requestFullBackupTime();
-    }
-
-    @Override
-    public long getBackupQuota(String packageName, boolean isFullBackup) {
-        return backupComponent.getBackupQuota(packageName, isFullBackup);
-    }
-
-    @Override
-    public int clearBackupData(PackageInfo packageInfo) {
-        return backupComponent.clearBackupData(packageInfo);
-    }
-
-    public void backupFinished() {
-        backupComponent.backupFinished();
-    }
-
-    /* Methods related to Restore */
-
-    @Override
-    public long getCurrentRestoreSet() {
-        return restoreComponent.getCurrentRestoreSet();
-    }
-
-    @Override
-    public int startRestore(long token, PackageInfo[] packages) {
-        return restoreComponent.startRestore(token, packages);
-    }
-
-    @Override
-    public int getNextFullRestoreDataChunk(ParcelFileDescriptor socket) {
-        return restoreComponent.getNextFullRestoreDataChunk(socket);
-    }
-
-    @Override
-    public RestoreSet[] getAvailableRestoreSets() {
-        return restoreComponent.getAvailableRestoreSets();
-    }
-
-    @Override
-    public RestoreDescription nextRestorePackage() {
-        return restoreComponent.nextRestorePackage();
-    }
-
-    @Override
-    public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) {
-        return restoreComponent.getRestoreData(outputFileDescriptor);
-    }
-
-    @Override
-    public int abortFullRestore() {
-        return restoreComponent.abortFullRestore();
-    }
-
-    @Override
-    public void finishRestore() {
-        restoreComponent.finishRestore();
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt
new file mode 100644
index 00000000..53e47cf0
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt
@@ -0,0 +1,160 @@
+package com.stevesoltys.backup.transport
+
+import android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED
+import android.app.backup.BackupTransport
+import android.app.backup.RestoreDescription
+import android.app.backup.RestoreSet
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInfo
+import android.os.Build.VERSION.SDK_INT
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import com.stevesoltys.backup.settings.SettingsActivity
+
+const val DEFAULT_RESTORE_SET_TOKEN: Long = 1
+
+private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.backup.transport.ConfigurableBackupTransport"
+private val TAG = ConfigurableBackupTransport::class.java.simpleName
+
+/**
+ * @author Steve Soltys
+ * @author Torsten Grote
+ */
+class ConfigurableBackupTransport internal constructor(private val context: Context) : BackupTransport() {
+
+    private val pluginManager = PluginManager(context)
+    private val backupCoordinator = pluginManager.backupCoordinator
+    private val restoreCoordinator = pluginManager.restoreCoordinator
+
+    override fun transportDirName(): String {
+        return TRANSPORT_DIRECTORY_NAME
+    }
+
+    override fun name(): String {
+        // TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName.
+        return this.javaClass.name
+    }
+
+    override fun getTransportFlags(): Int {
+        return if (SDK_INT >= 28) FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED else 0
+    }
+
+    override fun dataManagementIntent(): Intent {
+        return Intent(context, SettingsActivity::class.java)
+    }
+
+    override fun dataManagementLabel(): String {
+        return "Please file a bug if you see this! 1"
+    }
+
+    override fun currentDestinationString(): String {
+        return "Please file a bug if you see this! 2"
+    }
+
+    // ------------------------------------------------------------------------------------
+    // General backup methods
+    //
+
+    override fun initializeDevice(): Int {
+        return backupCoordinator.initializeDevice()
+    }
+
+    override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean {
+        return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
+    }
+
+    override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
+        return backupCoordinator.getBackupQuota(packageName, isFullBackup)
+    }
+
+    override fun clearBackupData(packageInfo: PackageInfo): Int {
+        return backupCoordinator.clearBackupData(packageInfo)
+    }
+
+    override fun finishBackup(): Int {
+        return backupCoordinator.finishBackup()
+    }
+
+    // ------------------------------------------------------------------------------------
+    // Key/value incremental backup support
+    //
+
+    override fun requestBackupTime(): Long {
+        return backupCoordinator.requestBackupTime()
+    }
+
+    override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int {
+        return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags)
+    }
+
+    override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
+        Log.w(TAG, "Warning: Legacy performBackup() method called.")
+        return performBackup(targetPackage, fileDescriptor, 0)
+    }
+
+    // ------------------------------------------------------------------------------------
+    // Full backup
+    //
+
+    override fun requestFullBackupTime(): Long {
+        return backupCoordinator.requestFullBackupTime()
+    }
+
+    override fun checkFullBackupSize(size: Long): Int {
+        return backupCoordinator.checkFullBackupSize(size)
+    }
+
+    override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int {
+        return backupCoordinator.performFullBackup(targetPackage, socket, flags)
+    }
+
+    override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
+        return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
+    }
+
+    override fun sendBackupData(numBytes: Int): Int {
+        return backupCoordinator.sendBackupData(numBytes)
+    }
+
+    override fun cancelFullBackup() {
+        backupCoordinator.cancelFullBackup()
+    }
+
+    // ------------------------------------------------------------------------------------
+    // Restore
+    //
+
+    override fun getAvailableRestoreSets(): Array<RestoreSet>? {
+        return restoreCoordinator.getAvailableRestoreSets()
+    }
+
+    override fun getCurrentRestoreSet(): Long {
+        return restoreCoordinator.getCurrentRestoreSet()
+    }
+
+    override fun startRestore(token: Long, packages: Array<PackageInfo>): Int {
+        return restoreCoordinator.startRestore(token, packages)
+    }
+
+    override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
+        return restoreCoordinator.getNextFullRestoreDataChunk(socket)
+    }
+
+    override fun nextRestorePackage(): RestoreDescription? {
+        return restoreCoordinator.nextRestorePackage()
+    }
+
+    override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int {
+        return restoreCoordinator.getRestoreData(outputFileDescriptor)
+    }
+
+    override fun abortFullRestore(): Int {
+        return restoreCoordinator.abortFullRestore()
+    }
+
+    override fun finishRestore() {
+        restoreCoordinator.finishRestore()
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java
deleted file mode 100644
index da896889..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.stevesoltys.backup.transport;
-
-import android.app.Service;
-import android.content.Context;
-import android.content.Intent;
-import android.os.IBinder;
-import android.util.Log;
-
-/**
- * @author Steve Soltys
- */
-public class ConfigurableBackupTransportService extends Service {
-
-    private static final String TAG = ConfigurableBackupTransportService.class.getName();
-
-    private static ConfigurableBackupTransport backupTransport = null;
-
-    public static ConfigurableBackupTransport getBackupTransport(Context context) {
-
-        if (backupTransport == null) {
-            backupTransport = new ConfigurableBackupTransport(context);
-        }
-
-        return backupTransport;
-    }
-
-    @Override
-    public void onCreate() {
-        super.onCreate();
-        Log.d(TAG, "Service created.");
-    }
-
-    @Override
-    public IBinder onBind(Intent intent) {
-        return getBackupTransport(getApplicationContext()).getBinder();
-    }
-
-    @Override
-    public void onDestroy() {
-        super.onDestroy();
-        Log.d(TAG, "Service destroyed.");
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt
new file mode 100644
index 00000000..f178245d
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt
@@ -0,0 +1,37 @@
+package com.stevesoltys.backup.transport
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import android.util.Log
+
+private val TAG = ConfigurableBackupTransportService::class.java.simpleName
+
+/**
+ * @author Steve Soltys
+ * @author Torsten Grote
+ */
+class ConfigurableBackupTransportService : Service() {
+
+    private var transport: ConfigurableBackupTransport? = null
+
+    override fun onCreate() {
+        super.onCreate()
+        transport = ConfigurableBackupTransport(applicationContext)
+        Log.d(TAG, "Service created.")
+    }
+
+    override fun onBind(intent: Intent): IBinder {
+        val transport = this.transport ?: throw IllegalStateException()
+        return transport.binder.apply {
+            Log.d(TAG, "Transport bound.")
+        }
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        transport = null
+        Log.d(TAG, "Service destroyed.")
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
new file mode 100644
index 00000000..0c0c6471
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
@@ -0,0 +1,56 @@
+package com.stevesoltys.backup.transport
+
+import android.content.Context
+import android.os.Build
+import com.stevesoltys.backup.Backup
+import com.stevesoltys.backup.crypto.CipherFactoryImpl
+import com.stevesoltys.backup.crypto.CryptoImpl
+import com.stevesoltys.backup.header.HeaderReaderImpl
+import com.stevesoltys.backup.header.HeaderWriterImpl
+import com.stevesoltys.backup.settings.getBackupFolderUri
+import com.stevesoltys.backup.transport.backup.BackupCoordinator
+import com.stevesoltys.backup.transport.backup.FullBackup
+import com.stevesoltys.backup.transport.backup.InputFactory
+import com.stevesoltys.backup.transport.backup.KVBackup
+import com.stevesoltys.backup.transport.backup.plugins.DocumentsProviderBackupPlugin
+import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
+import com.stevesoltys.backup.transport.restore.FullRestore
+import com.stevesoltys.backup.transport.restore.KVRestore
+import com.stevesoltys.backup.transport.restore.OutputFactory
+import com.stevesoltys.backup.transport.restore.RestoreCoordinator
+import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestorePlugin
+
+class PluginManager(context: Context) {
+
+    // We can think about using an injection framework such as Dagger to simplify this.
+
+    private val storage = DocumentsStorage(context, getBackupFolderUri(context), getDeviceName())
+
+    private val headerWriter = HeaderWriterImpl()
+    private val headerReader = HeaderReaderImpl()
+    private val cipherFactory = CipherFactoryImpl(Backup.keyManager)
+    private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
+
+
+    private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager)
+    private val inputFactory = InputFactory()
+    private val kvBackup = KVBackup(backupPlugin.kvBackupPlugin, inputFactory, headerWriter, crypto)
+    private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto)
+
+    internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup)
+
+
+    private val restorePlugin = DocumentsProviderRestorePlugin(storage)
+    private val outputFactory = OutputFactory()
+    private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto)
+    private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto)
+
+    internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
+
+
+    private fun getDeviceName(): String {
+        // TODO add device specific unique ID to the end
+        return "${Build.MANUFACTURER} ${Build.MODEL}"
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
new file mode 100644
index 00000000..65ad91b0
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
@@ -0,0 +1,144 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.app.backup.BackupTransport.TRANSPORT_ERROR
+import android.app.backup.BackupTransport.TRANSPORT_OK
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import java.io.IOException
+
+private val TAG = BackupCoordinator::class.java.simpleName
+
+/**
+ * @author Steve Soltys
+ * @author Torsten Grote
+ */
+class BackupCoordinator(
+        private val plugin: BackupPlugin,
+        private val kv: KVBackup,
+        private val full: FullBackup) {
+
+    private var calledInitialize = false
+    private var calledClearBackupData = false
+
+    // ------------------------------------------------------------------------------------
+    // Transport initialization and quota
+    //
+
+    /**
+     * Initialize the storage for this device, erasing all stored data.
+     * The transport may send the request immediately, or may buffer it.
+     * After this is called,
+     * [finishBackup] will be called to ensure the request is sent and received successfully.
+     *
+     * If the transport returns anything other than [TRANSPORT_OK] from this method,
+     * the OS will halt the current initialize operation and schedule a retry in the near future.
+     * Even if the transport is in a state
+     * such that attempting to "initialize" the backend storage is meaningless -
+     * for example, if there is no current live data-set at all,
+     * or there is no authenticated account under which to store the data remotely -
+     * the transport should return [TRANSPORT_OK] here
+     * and treat the initializeDevice() / finishBackup() pair as a graceful no-op.
+     *
+     * @return One of [TRANSPORT_OK] (OK so far) or
+     * [TRANSPORT_ERROR] (to retry following network error or other failure).
+     */
+    fun initializeDevice(): Int {
+        Log.i(TAG, "Initialize Device!")
+        return try {
+            plugin.initializeDevice()
+            // [finishBackup] will only be called when we return [TRANSPORT_OK] here
+            // so we remember that we initialized successfully
+            calledInitialize = true
+            TRANSPORT_OK
+        } catch (e: IOException) {
+            Log.e(TAG, "Error initializing device", e)
+            TRANSPORT_ERROR
+        }
+    }
+
+    fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean {
+        // We need to exclude the DocumentsProvider used to store backup data.
+        // Otherwise, it gets killed when we back it up, terminating our backup.
+        return targetPackage.packageName != plugin.providerPackageName
+    }
+
+    fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
+        Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
+        val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
+        Log.i(TAG, "Reported quota of $quota bytes.")
+        return quota
+    }
+
+    // ------------------------------------------------------------------------------------
+    // Key/value incremental backup support
+    //
+
+    fun requestBackupTime() = kv.requestBackupTime()
+
+    fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int) =
+            kv.performBackup(packageInfo, data, flags)
+
+    // ------------------------------------------------------------------------------------
+    // Full backup
+    //
+
+    fun requestFullBackupTime() = full.requestFullBackupTime()
+
+    fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size)
+
+    fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int) =
+            full.performFullBackup(targetPackage, fileDescriptor, flags)
+
+    fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
+
+    fun cancelFullBackup() = full.cancelFullBackup()
+
+    // Clear and Finish
+
+    /**
+     * Erase the given application's data from the backup destination.
+     * This clears out the given package's data from the current backup set,
+     * making it as though the app had never yet been backed up.
+     * After this is called, [finishBackup] must be called
+     * to ensure that the operation is recorded successfully.
+     *
+     * @return the same error codes as [performFullBackup].
+     */
+    fun clearBackupData(packageInfo: PackageInfo): Int {
+        val packageName = packageInfo.packageName
+        Log.i(TAG, "Clear Backup Data of $packageName.")
+        try {
+            kv.clearBackupData(packageInfo)
+        } catch (e: IOException) {
+            Log.w(TAG, "Error clearing K/V backup data for $packageName", e)
+            return TRANSPORT_ERROR
+        }
+        try {
+            full.clearBackupData(packageInfo)
+        } catch (e: IOException) {
+            Log.w(TAG, "Error clearing full backup data for $packageName", e)
+            return TRANSPORT_ERROR
+        }
+        calledClearBackupData = true
+        return TRANSPORT_OK
+    }
+
+    fun finishBackup(): Int = when {
+        kv.hasState() -> {
+            if (full.hasState()) throw IllegalStateException()
+            kv.finishBackup()
+        }
+        full.hasState() -> {
+            if (kv.hasState()) throw IllegalStateException()
+            full.finishBackup()
+        }
+        calledInitialize || calledClearBackupData -> {
+            calledInitialize = false
+            calledClearBackupData = false
+            TRANSPORT_OK
+        }
+        else -> throw IllegalStateException()
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt
new file mode 100644
index 00000000..b3d6f5dc
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt
@@ -0,0 +1,27 @@
+package com.stevesoltys.backup.transport.backup
+
+import java.io.IOException
+
+interface BackupPlugin {
+
+    val kvBackupPlugin: KVBackupPlugin
+
+    val fullBackupPlugin: FullBackupPlugin
+
+    /**
+     * Initialize the storage for this device, erasing all stored data.
+     */
+    @Throws(IOException::class)
+    fun initializeDevice()
+
+    /**
+     * Returns the package name of the app that provides the backend storage
+     * which is used for the current backup location.
+     *
+     * Plugins are advised to cache this as it will be requested frequently.
+     *
+     * @return null if no package name could be found
+     */
+    val providerPackageName: String?
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt
new file mode 100644
index 00000000..0a56c59b
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt
@@ -0,0 +1,191 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.app.backup.BackupTransport.*
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import com.stevesoltys.backup.crypto.Crypto
+import com.stevesoltys.backup.header.HeaderWriter
+import com.stevesoltys.backup.header.VersionHeader
+import libcore.io.IoUtils.closeQuietly
+import org.apache.commons.io.IOUtils
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+private class FullBackupState(
+        internal val packageInfo: PackageInfo,
+        internal val inputFileDescriptor: ParcelFileDescriptor,
+        internal val inputStream: InputStream,
+        internal val outputStream: OutputStream) {
+    internal val packageName: String = packageInfo.packageName
+    internal var size: Long = 0
+}
+
+const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong()
+
+private val TAG = FullBackup::class.java.simpleName
+
+class FullBackup(
+        private val plugin: FullBackupPlugin,
+        private val inputFactory: InputFactory,
+        private val headerWriter: HeaderWriter,
+        private val crypto: Crypto) {
+
+    private var state: FullBackupState? = null
+
+    fun hasState() = state != null
+
+    fun requestFullBackupTime(): Long {
+        Log.i(TAG, "Request full backup time")
+        return 0
+    }
+
+    fun getQuota(): Long = plugin.getQuota()
+
+    fun checkFullBackupSize(size: Long): Int {
+        Log.i(TAG, "Check full backup size of $size bytes.")
+        return when {
+            size <= 0 -> TRANSPORT_PACKAGE_REJECTED
+            size > plugin.getQuota() -> TRANSPORT_QUOTA_EXCEEDED
+            else -> TRANSPORT_OK
+        }
+    }
+
+    /**
+     * Begin the process of sending a packages' full-data archive to the backend.
+     * The description of the package whose data will be delivered is provided,
+     * as well as the socket file descriptor on which the transport will receive the data itself.
+     *
+     * If the package is not eligible for backup,
+     * the transport should return [TRANSPORT_PACKAGE_REJECTED].
+     * In this case the system will simply proceed with the next candidate if any,
+     * or finish the full backup operation if all apps have been processed.
+     *
+     * After the transport returns [TRANSPORT_OK] from this method,
+     * the OS will proceed to call [sendBackupData] one or more times
+     * to deliver the packages' data as a streamed tarball.
+     * The transport should not read() from the socket except as instructed to
+     * via the [sendBackupData] method.
+     *
+     * After all data has been delivered to the transport, the system will call [finishBackup].
+     * At this point the transport should commit the data to its datastore, if appropriate,
+     * and close the socket that had been provided in [performFullBackup].
+     *
+     * If the transport returns [TRANSPORT_OK] from this method,
+     * then the OS will always provide a matching call to [finishBackup]
+     * even if sending data via [sendBackupData] failed at some point.
+     *
+     * @param targetPackage The package whose data is to follow.
+     * @param socket The socket file descriptor through which the data will be provided.
+     * If the transport returns [TRANSPORT_PACKAGE_REJECTED] here,
+     * it must still close this file descriptor now;
+     * otherwise it should be cached for use during succeeding calls to [sendBackupData],
+     * and closed in response to [finishBackup].
+     * @param flags [FLAG_USER_INITIATED] or 0.
+     * @return [TRANSPORT_PACKAGE_REJECTED] to indicate that the package is not to be backed up;
+     * [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data;
+     * [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time.
+     */
+    fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, @Suppress("UNUSED_PARAMETER") flags: Int = 0): Int {
+        if (state != null) throw AssertionError()
+        Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.")
+
+        // get OutputStream to write backup data into
+        val outputStream = try {
+            plugin.getOutputStream(targetPackage)
+        } catch (e: IOException) {
+            Log.e(TAG, "Error getting OutputStream for full backup of ${targetPackage.packageName}", e)
+            return backupError(TRANSPORT_ERROR)
+        }
+
+        // create new state
+        val inputStream = inputFactory.getInputStream(socket)
+        state = FullBackupState(targetPackage, socket, inputStream, outputStream)
+
+        // store version header
+        val state = this.state ?: throw AssertionError()
+        val header = VersionHeader(packageName = state.packageName)
+        try {
+            headerWriter.writeVersion(state.outputStream, header)
+            crypto.encryptHeader(state.outputStream, header)
+        } catch (e: IOException) {
+            Log.e(TAG, "Error writing backup header", e)
+            return backupError(TRANSPORT_ERROR)
+        }
+        return TRANSPORT_OK
+    }
+
+    /**
+     * Method to reset state,
+     * because [finishBackup] is not called
+     * when we don't return [TRANSPORT_OK] from [performFullBackup].
+     */
+    private fun backupError(result: Int): Int {
+        Log.i(TAG, "Resetting state because of full backup error.")
+        state = null
+        return result
+    }
+
+    fun sendBackupData(numBytes: Int): Int {
+        val state = this.state
+                ?: throw AssertionError("Attempted sendBackupData before performFullBackup")
+
+        // check if size fits quota
+        state.size += numBytes
+        val quota = plugin.getQuota()
+        if (state.size > quota) {
+            Log.w(TAG, "Full backup of additional $numBytes exceeds quota of $quota with ${state.size}.")
+            return TRANSPORT_QUOTA_EXCEEDED
+        }
+
+        Log.i(TAG, "Send full backup data of $numBytes bytes.")
+
+        return try {
+            val payload = IOUtils.readFully(state.inputStream, numBytes)
+            crypto.encryptSegment(state.outputStream, payload)
+            TRANSPORT_OK
+        } catch (e: IOException) {
+            Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e)
+            TRANSPORT_ERROR
+        }
+    }
+
+    fun clearBackupData(packageInfo: PackageInfo) {
+        // TODO
+    }
+
+    fun cancelFullBackup() {
+        Log.i(TAG, "Cancel full backup")
+        val state = this.state ?: throw AssertionError("No state when canceling")
+        clearState()
+        try {
+            plugin.cancelFullBackup(state.packageInfo)
+        } catch (e: IOException) {
+            Log.w(TAG, "Error cancelling full backup for ${state.packageName}", e)
+        }
+        // TODO roll back to the previous known-good archive
+    }
+
+    fun finishBackup(): Int {
+        Log.i(TAG, "Finish full backup of ${state!!.packageName}.")
+        return clearState()
+    }
+
+    private fun clearState(): Int {
+        val state = this.state ?: throw AssertionError("Trying to clear empty state.")
+        return try {
+            state.outputStream.flush()
+            closeQuietly(state.outputStream)
+            closeQuietly(state.inputStream)
+            closeQuietly(state.inputFileDescriptor)
+            TRANSPORT_OK
+        } catch (e: IOException) {
+            Log.w(TAG, "Error when clearing state", e)
+            TRANSPORT_ERROR
+        } finally {
+            this.state = null
+        }
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt
new file mode 100644
index 00000000..e1c882d6
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt
@@ -0,0 +1,17 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.content.pm.PackageInfo
+import java.io.IOException
+import java.io.OutputStream
+
+interface FullBackupPlugin {
+
+    fun getQuota(): Long
+
+    @Throws(IOException::class)
+    fun getOutputStream(targetPackage: PackageInfo): OutputStream
+
+    @Throws(IOException::class)
+    fun cancelFullBackup(targetPackage: PackageInfo)
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt
new file mode 100644
index 00000000..78db8dbb
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt
@@ -0,0 +1,21 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.app.backup.BackupDataInput
+import android.os.ParcelFileDescriptor
+import java.io.FileInputStream
+import java.io.InputStream
+
+/**
+ * This class exists for easier testing, so we can mock it and return custom data inputs.
+ */
+class InputFactory {
+
+    fun getBackupDataInput(inputFileDescriptor: ParcelFileDescriptor): BackupDataInput {
+        return BackupDataInput(inputFileDescriptor.fileDescriptor)
+    }
+
+    fun getInputStream(inputFileDescriptor: ParcelFileDescriptor): InputStream {
+        return FileInputStream(inputFileDescriptor.fileDescriptor)
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt
new file mode 100644
index 00000000..5a14f43a
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt
@@ -0,0 +1,200 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.app.backup.BackupTransport.*
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import com.stevesoltys.backup.crypto.Crypto
+import com.stevesoltys.backup.header.HeaderWriter
+import com.stevesoltys.backup.header.Utf8
+import com.stevesoltys.backup.header.VersionHeader
+import libcore.io.IoUtils.closeQuietly
+import java.io.IOException
+import java.util.Base64.getUrlEncoder
+
+class KVBackupState(internal val packageName: String)
+
+const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
+
+private val TAG = KVBackup::class.java.simpleName
+
+class KVBackup(
+        private val plugin: KVBackupPlugin,
+        private val inputFactory: InputFactory,
+        private val headerWriter: HeaderWriter,
+        private val crypto: Crypto) {
+
+    private var state: KVBackupState? = null
+
+    fun hasState() = state != null
+
+    fun requestBackupTime(): Long {
+        Log.i(TAG, "Request K/V backup time")
+        return 0
+    }
+
+    fun getQuota(): Long = plugin.getQuota()
+
+    fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
+        val isIncremental = flags and FLAG_INCREMENTAL != 0
+        val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
+        val packageName = packageInfo.packageName
+
+        when {
+            isIncremental -> {
+                Log.i(TAG, "Performing incremental K/V backup for $packageName")
+            }
+            isNonIncremental -> {
+                Log.i(TAG, "Performing non-incremental K/V backup for $packageName")
+            }
+            else -> {
+                Log.i(TAG, "Performing K/V backup for $packageName")
+            }
+        }
+
+        // initialize state
+        if (this.state != null) throw AssertionError()
+        this.state = KVBackupState(packageInfo.packageName)
+
+        // 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)
+        }
+        if (isIncremental && !hasDataForPackage) {
+            Log.w(TAG, "Requested incremental, but transport currently stores no data $packageName, requesting non-incremental retry.")
+            return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
+        }
+
+        // TODO check if package is over-quota
+
+        if (isNonIncremental && hasDataForPackage) {
+            Log.w(TAG, "Requested non-incremental, deleting existing data.")
+            try {
+                clearBackupData(packageInfo)
+            } catch (e: IOException) {
+                Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e)
+            }
+        }
+
+        // ensure there's a place to store K/V for the given package
+        try {
+            plugin.ensureRecordStorageForPackage(packageInfo)
+        } catch (e: IOException) {
+            Log.e(TAG, "Error ensuring storage for ${packageInfo.packageName}.", e)
+            return backupError(TRANSPORT_ERROR)
+        }
+
+        // parse and store the K/V updates
+        return storeRecords(packageInfo, data)
+    }
+
+    private fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int {
+        // apply the delta operations
+        for (result in parseBackupStream(data)) {
+            if (result is Result.Error) {
+                Log.e(TAG, "Exception reading backup input", result.exception)
+                return backupError(TRANSPORT_ERROR)
+            }
+            val op = (result as Result.Ok).result
+            try {
+                if (op.value == null) {
+                    Log.e(TAG, "Deleting record with base64Key ${op.base64Key}")
+                    plugin.deleteRecord(packageInfo, op.base64Key)
+                } else {
+                    val outputStream = plugin.getOutputStreamForRecord(packageInfo, op.base64Key)
+                    val header = VersionHeader(packageName = packageInfo.packageName, key = op.key)
+                    headerWriter.writeVersion(outputStream, header)
+                    crypto.encryptHeader(outputStream, header)
+                    crypto.encryptSegment(outputStream, op.value)
+                    outputStream.flush()
+                    closeQuietly(outputStream)
+                }
+            } catch (e: IOException) {
+                Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e)
+                return backupError(TRANSPORT_ERROR)
+            }
+        }
+        return TRANSPORT_OK
+    }
+
+    /**
+     * Parses a backup stream into individual key/value operations
+     */
+    private fun parseBackupStream(data: ParcelFileDescriptor): Sequence<Result<KVOperation>> {
+        val changeSet = inputFactory.getBackupDataInput(data)
+
+        // Each K/V pair in the restore set is kept in its own file, named by the record key.
+        // Wind through the data file, extracting individual record operations
+        // and building a sequence of all the updates to apply in this update.
+        return generateSequence {
+            // read the next header or end the sequence in case of error or no more headers
+            try {
+                if (!changeSet.readNextHeader()) return@generateSequence null  // end the sequence
+            } catch (e: IOException) {
+                Log.e(TAG, "Error reading next header", e)
+                return@generateSequence Result.Error(e)
+            }
+            // encode key
+            val key = changeSet.key
+            val base64Key = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8))
+            val dataSize = changeSet.dataSize
+
+            // read and encrypt value
+            val value = if (dataSize >= 0) {
+                Log.v(TAG, "  Delta operation key $key   size $dataSize   key64 $base64Key")
+                val bytes = ByteArray(dataSize)
+                val bytesRead = try {
+                    changeSet.readEntityData(bytes, 0, dataSize)
+                } catch (e: IOException) {
+                    Log.e(TAG, "Error reading entity data for key $key", e)
+                    return@generateSequence Result.Error(e)
+                }
+                if (bytesRead != dataSize) {
+                    Log.w(TAG, "Expecting $dataSize bytes, but only read $bytesRead.")
+                }
+                bytes
+            } else null
+            // add change operation to the sequence
+            Result.Ok(KVOperation(key, base64Key, value))
+        }
+    }
+
+    @Throws(IOException::class)
+    fun clearBackupData(packageInfo: PackageInfo) {
+        plugin.removeDataOfPackage(packageInfo)
+    }
+
+    fun finishBackup(): Int {
+        Log.i(TAG, "Finish K/V Backup of ${state!!.packageName}")
+        state = null
+        return TRANSPORT_OK
+    }
+
+    /**
+     * Method to reset state,
+     * because [finishBackup] is not called when we don't return [TRANSPORT_OK].
+     */
+    private fun backupError(result: Int): Int {
+        Log.i(TAG, "Resetting state because of K/V Backup error of ${state!!.packageName}")
+        state = null
+        return result
+    }
+
+    private class KVOperation(
+            internal val key: String,
+            internal val base64Key: String,
+            /**
+             * value is null when this is a deletion operation
+             */
+            internal val value: ByteArray?
+    )
+
+    private sealed class Result<out T> {
+        class Ok<out T>(val result: T) : Result<T>()
+        class Error(val exception: Exception) : Result<Nothing>()
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt
new file mode 100644
index 00000000..4e1146ee
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt
@@ -0,0 +1,48 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.content.pm.PackageInfo
+import java.io.IOException
+import java.io.OutputStream
+
+interface KVBackupPlugin {
+
+    /**
+     * Get quota for key/value backups.
+     */
+    fun getQuota(): Long
+
+    /**
+     * Return true if there are records stored for the given package.
+     */
+    @Throws(IOException::class)
+    fun hasDataForPackage(packageInfo: PackageInfo): Boolean
+
+    /**
+     * This marks the beginning of a backup operation.
+     *
+     * Make sure that there is a place to store K/V pairs for the given package.
+     * E.g. file-based plugins should a create a directory for the package, if none exists.
+     */
+    @Throws(IOException::class)
+    fun ensureRecordStorageForPackage(packageInfo: PackageInfo)
+
+    /**
+     * Return an [OutputStream] for the given package and key
+     * which will receive the record's encrypted value.
+     */
+    @Throws(IOException::class)
+    fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream
+
+    /**
+     * Delete the record for the given package identified by the given key.
+     */
+    @Throws(IOException::class)
+    fun deleteRecord(packageInfo: PackageInfo, key: String)
+
+    /**
+     * Remove all data associated with the given package.
+     */
+    @Throws(IOException::class)
+    fun removeDataOfPackage(packageInfo: PackageInfo)
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt
new file mode 100644
index 00000000..2d80d581
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt
@@ -0,0 +1,46 @@
+package com.stevesoltys.backup.transport.backup.plugins
+
+import android.content.pm.PackageManager
+import com.stevesoltys.backup.transport.backup.BackupPlugin
+import com.stevesoltys.backup.transport.backup.FullBackupPlugin
+import com.stevesoltys.backup.transport.backup.KVBackupPlugin
+import java.io.IOException
+
+private const val NO_MEDIA = ".nomedia"
+
+class DocumentsProviderBackupPlugin(
+        private val storage: DocumentsStorage,
+        packageManager: PackageManager) : BackupPlugin {
+
+    override val kvBackupPlugin: KVBackupPlugin by lazy {
+        DocumentsProviderKVBackup(storage)
+    }
+
+    override val fullBackupPlugin: FullBackupPlugin by lazy {
+        DocumentsProviderFullBackup(storage)
+    }
+
+    @Throws(IOException::class)
+    override fun initializeDevice() {
+        // get or create root backup dir
+        val rootDir = storage.rootBackupDir ?: throw IOException()
+
+        // create .nomedia file to prevent Android's MediaScanner from trying to index the backup
+        rootDir.createOrGetFile(NO_MEDIA)
+
+        // create backup folders
+        val kvDir = storage.defaultKvBackupDir
+        val fullDir = storage.defaultFullBackupDir
+
+        // wipe existing data
+        kvDir?.deleteContents()
+        fullDir?.deleteContents()
+    }
+
+    override val providerPackageName: String? by lazy {
+        val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null
+        val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null
+        providerInfo.packageName
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt
new file mode 100644
index 00000000..2116b926
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt
@@ -0,0 +1,33 @@
+package com.stevesoltys.backup.transport.backup.plugins
+
+import android.content.pm.PackageInfo
+import android.util.Log
+import com.stevesoltys.backup.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
+import com.stevesoltys.backup.transport.backup.FullBackupPlugin
+import java.io.IOException
+import java.io.OutputStream
+
+private val TAG = DocumentsProviderFullBackup::class.java.simpleName
+
+class DocumentsProviderFullBackup(
+        private val storage: DocumentsStorage) : FullBackupPlugin {
+
+    override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP
+
+    @Throws(IOException::class)
+    override fun getOutputStream(targetPackage: PackageInfo): OutputStream {
+        // TODO test file-size after overwriting bigger file
+        val file = storage.defaultFullBackupDir?.createOrGetFile(targetPackage.packageName)
+                ?: throw IOException()
+        return storage.getOutputStream(file)
+    }
+
+    @Throws(IOException::class)
+    override fun cancelFullBackup(targetPackage: PackageInfo) {
+        val packageName = targetPackage.packageName
+        Log.i(TAG, "Deleting $packageName due to canceled backup...")
+        val file = storage.defaultFullBackupDir?.findFile(packageName) ?: return
+        if (!file.delete()) throw IOException("Failed to delete $packageName")
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt
new file mode 100644
index 00000000..dd6a08ca
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt
@@ -0,0 +1,54 @@
+package com.stevesoltys.backup.transport.backup.plugins
+
+import android.content.pm.PackageInfo
+import androidx.documentfile.provider.DocumentFile
+import com.stevesoltys.backup.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP
+import com.stevesoltys.backup.transport.backup.KVBackupPlugin
+import java.io.IOException
+import java.io.OutputStream
+
+class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBackupPlugin {
+
+    private var packageFile: DocumentFile? = null
+
+    override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP
+
+    @Throws(IOException::class)
+    override fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
+        val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName)
+                ?: return false
+        return packageFile.listFiles().isNotEmpty()
+    }
+
+    @Throws(IOException::class)
+    override fun ensureRecordStorageForPackage(packageInfo: PackageInfo) {
+        // remember package file for subsequent operations
+        packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(packageInfo.packageName)
+    }
+
+    @Throws(IOException::class)
+    override fun removeDataOfPackage(packageInfo: PackageInfo) {
+        // we cannot use the cached this.packageFile here,
+        // because this can be called before [ensureRecordStorageForPackage]
+        val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName) ?: return
+        packageFile.delete()
+    }
+
+    @Throws(IOException::class)
+    override fun deleteRecord(packageInfo: PackageInfo, key: String) {
+        val packageFile = this.packageFile ?: throw AssertionError()
+        packageFile.assertRightFile(packageInfo)
+        val keyFile = packageFile.findFile(key) ?: return
+        keyFile.delete()
+    }
+
+    @Throws(IOException::class)
+    override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream {
+        val packageFile = this.packageFile ?: throw AssertionError()
+        packageFile.assertRightFile(packageInfo)
+        // TODO check what happens if we overwrite a bigger file
+        val keyFile = packageFile.createOrGetFile(key)
+        return storage.getOutputStream(keyFile)
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt
new file mode 100644
index 00000000..20bf10df
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt
@@ -0,0 +1,123 @@
+package com.stevesoltys.backup.transport.backup.plugins
+
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.net.Uri
+import android.util.Log
+import androidx.documentfile.provider.DocumentFile
+import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+const val DIRECTORY_FULL_BACKUP = "full"
+const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
+private const val ROOT_DIR_NAME = ".AndroidBackup"
+private const val MIME_TYPE = "application/octet-stream"
+
+private val TAG = DocumentsStorage::class.java.simpleName
+
+class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String) {
+
+    private val contentResolver = context.contentResolver
+
+    internal val rootBackupDir: DocumentFile? by lazy {
+        val folderUri = parentFolder ?: return@lazy null
+        val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
+        try {
+            parent.createOrGetDirectory(ROOT_DIR_NAME)
+        } catch (e: IOException) {
+            Log.e(TAG, "Error creating root backup dir.", e)
+            null
+        }
+    }
+
+    private val deviceDir: DocumentFile? by lazy {
+        try {
+            rootBackupDir?.createOrGetDirectory(deviceName)
+        } catch (e: IOException) {
+            Log.e(TAG, "Error creating current restore set dir.", e)
+            null
+        }
+    }
+
+    private val defaultSetDir: DocumentFile? by lazy {
+        val currentSetName = DEFAULT_RESTORE_SET_TOKEN.toString()
+        try {
+            deviceDir?.createOrGetDirectory(currentSetName)
+        } catch (e: IOException) {
+            Log.e(TAG, "Error creating current restore set dir.", e)
+            null
+        }
+    }
+
+    val defaultFullBackupDir: DocumentFile? by lazy {
+        try {
+            defaultSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
+        } catch (e: IOException) {
+            Log.e(TAG, "Error creating full backup dir.", e)
+            null
+        }
+    }
+
+    val defaultKvBackupDir: DocumentFile? by lazy {
+        try {
+            defaultSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
+        } catch (e: IOException) {
+            Log.e(TAG, "Error creating K/V backup dir.", e)
+            null
+        }
+    }
+
+    private fun getSetDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
+        if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultSetDir
+        return deviceDir?.findFile(token.toString())
+    }
+
+    fun getKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
+        if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException()
+        return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
+    }
+
+    @Throws(IOException::class)
+    fun getOrCreateKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile {
+        if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException()
+        val setDir = getSetDir(token) ?: throw IOException()
+        return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
+    }
+
+    fun getFullBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
+        if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultFullBackupDir ?: throw IOException()
+        return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP)
+    }
+
+    @Throws(IOException::class)
+    fun getInputStream(file: DocumentFile): InputStream {
+        return contentResolver.openInputStream(file.uri) ?: throw IOException()
+    }
+
+    @Throws(IOException::class)
+    fun getOutputStream(file: DocumentFile): OutputStream {
+        return contentResolver.openOutputStream(file.uri) ?: throw IOException()
+    }
+
+}
+
+@Throws(IOException::class)
+fun DocumentFile.createOrGetFile(name: String, mimeType: String = MIME_TYPE): DocumentFile {
+    return findFile(name) ?: createFile(mimeType, name) ?: throw IOException()
+}
+
+@Throws(IOException::class)
+fun DocumentFile.createOrGetDirectory(name: String): DocumentFile {
+    return findFile(name) ?: createDirectory(name) ?: throw IOException()
+}
+
+@Throws(IOException::class)
+fun DocumentFile.deleteContents() {
+    for (file in listFiles()) file.delete()
+}
+
+fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
+    if (name != packageInfo.packageName) throw AssertionError()
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java
deleted file mode 100644
index 92715d61..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.stevesoltys.backup.transport.component;
-
-import android.content.pm.PackageInfo;
-import android.os.ParcelFileDescriptor;
-
-/**
- * @author Steve Soltys
- */
-public interface BackupComponent {
-
-    String currentDestinationString();
-
-    String dataManagementLabel();
-
-    int initializeDevice();
-
-    int clearBackupData(PackageInfo packageInfo);
-
-    int finishBackup();
-
-    int performIncrementalBackup(PackageInfo targetPackage, ParcelFileDescriptor data, int flags);
-
-    int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor);
-
-    int checkFullBackupSize(long size);
-
-    int sendBackupData(int numBytes);
-
-    void cancelFullBackup();
-
-    long getBackupQuota(String packageName, boolean fullBackup);
-
-    long requestBackupTime();
-
-    long requestFullBackupTime();
-
-    void backupFinished();
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java
deleted file mode 100644
index 08f866a0..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.stevesoltys.backup.transport.component;
-
-import android.app.backup.RestoreDescription;
-import android.app.backup.RestoreSet;
-import android.content.pm.PackageInfo;
-import android.net.Uri;
-import android.os.ParcelFileDescriptor;
-
-/**
- * @author Steve Soltys
- */
-public interface RestoreComponent {
-
-    void prepareRestore(String password, Uri fileUri);
-
-    int startRestore(long token, PackageInfo[] packages);
-
-    RestoreDescription nextRestorePackage();
-
-    int getRestoreData(ParcelFileDescriptor outputFileDescriptor);
-
-    int getNextFullRestoreDataChunk(ParcelFileDescriptor socket);
-
-    int abortFullRestore();
-
-    long getCurrentRestoreSet();
-
-    void finishRestore();
-
-    RestoreSet[] getAvailableRestoreSets();
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java
deleted file mode 100644
index a951d50a..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java
+++ /dev/null
@@ -1,367 +0,0 @@
-package com.stevesoltys.backup.transport.component.provider;
-
-import android.app.backup.BackupDataInput;
-import android.content.Context;
-import android.content.pm.PackageInfo;
-import android.net.Uri;
-import android.os.ParcelFileDescriptor;
-import android.util.Base64;
-import android.util.Log;
-
-import com.stevesoltys.backup.security.CipherUtil;
-import com.stevesoltys.backup.security.KeyGenerator;
-import com.stevesoltys.backup.transport.component.BackupComponent;
-
-import org.apache.commons.io.IOUtils;
-
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.text.SimpleDateFormat;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.Locale;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-
-import javax.crypto.Cipher;
-import javax.crypto.SecretKey;
-
-import libcore.io.IoUtils;
-
-import static android.app.backup.BackupTransport.FLAG_INCREMENTAL;
-import static android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL;
-import static android.app.backup.BackupTransport.TRANSPORT_ERROR;
-import static android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED;
-import static android.app.backup.BackupTransport.TRANSPORT_OK;
-import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED;
-import static android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED;
-import static android.provider.DocumentsContract.buildDocumentUriUsingTree;
-import static android.provider.DocumentsContract.createDocument;
-import static android.provider.DocumentsContract.getTreeDocumentId;
-import static com.stevesoltys.backup.activity.MainActivityController.DOCUMENT_MIME_TYPE;
-import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupFolderUri;
-import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
-import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_BACKUP_QUOTA;
-import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY;
-import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY;
-import static java.util.Objects.requireNonNull;
-
-/**
- * @author Steve Soltys
- */
-public class ContentProviderBackupComponent implements BackupComponent {
-
-    private static final String TAG = ContentProviderBackupComponent.class.getSimpleName();
-
-    private static final String DOCUMENT_SUFFIX = "yyyy-MM-dd_HH_mm_ss";
-
-    private static final String DESTINATION_DESCRIPTION = "Backing up to zip file";
-
-    private static final String TRANSPORT_DATA_MANAGEMENT_LABEL = "";
-
-    private static final int INITIAL_BUFFER_SIZE = 512;
-
-    private final Context context;
-
-    private ContentProviderBackupState backupState;
-
-    public ContentProviderBackupComponent(Context context) {
-        this.context = context;
-    }
-
-    @Override
-    public void cancelFullBackup() {
-        clearBackupState(false);
-    }
-
-    @Override
-    public int checkFullBackupSize(long size) {
-        int result = TRANSPORT_OK;
-
-        if (size <= 0) {
-            result = TRANSPORT_PACKAGE_REJECTED;
-
-        } else if (size > DEFAULT_BACKUP_QUOTA) {
-            result = TRANSPORT_QUOTA_EXCEEDED;
-        }
-
-        return result;
-    }
-
-    @Override
-    public int clearBackupData(PackageInfo packageInfo) {
-        return TRANSPORT_OK;
-    }
-
-    @Override
-    public String currentDestinationString() {
-        return DESTINATION_DESCRIPTION;
-    }
-
-    @Override
-    public String dataManagementLabel() {
-        return TRANSPORT_DATA_MANAGEMENT_LABEL;
-    }
-
-    @Override
-    public int finishBackup() {
-        return clearBackupState(false);
-    }
-
-    @Override
-    public long getBackupQuota(String packageName, boolean fullBackup) {
-        return DEFAULT_BACKUP_QUOTA;
-    }
-
-    @Override
-    public int initializeDevice() {
-        return TRANSPORT_OK;
-    }
-
-    @Override
-    public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) {
-
-        if (backupState != null && backupState.getInputFileDescriptor() != null) {
-            Log.e(TAG, "Attempt to initiate full backup while one is in progress");
-            return TRANSPORT_ERROR;
-        }
-
-        try {
-            initializeBackupState();
-            backupState.setPackageName(targetPackage.packageName);
-
-            backupState.setInputFileDescriptor(fileDescriptor);
-            backupState.setInputStream(new FileInputStream(fileDescriptor.getFileDescriptor()));
-            backupState.setBytesTransferred(0);
-
-            Cipher cipher = CipherUtil.startEncrypt(backupState.getSecretKey(), backupState.getSalt());
-            backupState.setCipher(cipher);
-
-            ZipEntry zipEntry = new ZipEntry(DEFAULT_FULL_BACKUP_DIRECTORY + backupState.getPackageName());
-            backupState.getOutputStream().putNextEntry(zipEntry);
-
-        } catch (Exception ex) {
-            Log.e(TAG, "Error creating backup file for " + targetPackage.packageName + ": ", ex);
-            clearBackupState(true);
-            return TRANSPORT_ERROR;
-        }
-
-        return TRANSPORT_OK;
-    }
-
-    @Override
-    public int performIncrementalBackup(PackageInfo packageInfo, ParcelFileDescriptor data, int flags) {
-        boolean isIncremental = (flags & FLAG_INCREMENTAL) != 0;
-        if (isIncremental) {
-            Log.w(TAG, "Can not handle incremental backup. Requesting non-incremental for " + packageInfo.packageName);
-            return TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED;
-        }
-
-        boolean isNonIncremental = (flags & FLAG_NON_INCREMENTAL) != 0;
-        if (isNonIncremental) {
-            Log.i(TAG, "Performing non-incremental backup for " + packageInfo.packageName);
-        } else {
-            Log.i(TAG, "Performing backup for " + packageInfo.packageName);
-        }
-
-        BackupDataInput backupDataInput = new BackupDataInput(data.getFileDescriptor());
-
-        try {
-            initializeBackupState();
-            backupState.setPackageName(packageInfo.packageName);
-
-            return transferIncrementalBackupData(backupDataInput);
-
-        } catch (Exception ex) {
-            Log.e(TAG, "Error reading backup input: ", ex);
-            return TRANSPORT_ERROR;
-        }
-    }
-
-    @Override
-    public long requestBackupTime() {
-        return 0;
-    }
-
-    @Override
-    public long requestFullBackupTime() {
-        return 0;
-    }
-
-    @Override
-    public int sendBackupData(int numBytes) {
-
-        if (backupState == null) {
-            Log.e(TAG, "Attempted sendBackupData() before performFullBackup()");
-            return TRANSPORT_ERROR;
-        }
-
-        long bytesTransferred = backupState.getBytesTransferred() + numBytes;
-
-        if (bytesTransferred > DEFAULT_BACKUP_QUOTA) {
-            return TRANSPORT_QUOTA_EXCEEDED;
-        }
-
-        InputStream inputStream = backupState.getInputStream();
-        ZipOutputStream outputStream = backupState.getOutputStream();
-
-        try {
-            byte[] payload = IOUtils.readFully(inputStream, numBytes);
-
-            if (backupState.getCipher() != null) {
-                payload = backupState.getCipher().update(payload);
-            }
-
-            outputStream.write(payload, 0, numBytes);
-            backupState.setBytesTransferred(bytesTransferred);
-
-        } catch (Exception ex) {
-            Log.e(TAG, "Error handling backup data for " + backupState.getPackageName() + ": ", ex);
-            return TRANSPORT_ERROR;
-        }
-        return TRANSPORT_OK;
-    }
-
-    private int transferIncrementalBackupData(BackupDataInput backupDataInput) throws IOException {
-        ZipOutputStream outputStream = backupState.getOutputStream();
-
-        int bufferSize = INITIAL_BUFFER_SIZE;
-        byte[] buffer = new byte[bufferSize];
-
-        while (backupDataInput.readNextHeader()) {
-            String chunkFileName = Base64.encodeToString(backupDataInput.getKey().getBytes(), Base64.DEFAULT);
-            int dataSize = backupDataInput.getDataSize();
-
-            if (dataSize >= 0) {
-                ZipEntry zipEntry = new ZipEntry(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY +
-                        backupState.getPackageName() + "/" + chunkFileName);
-                outputStream.putNextEntry(zipEntry);
-
-                if (dataSize > bufferSize) {
-                    bufferSize = dataSize;
-                    buffer = new byte[bufferSize];
-                }
-
-                backupDataInput.readEntityData(buffer, 0, dataSize);
-
-                try {
-                    if (backupState.getSecretKey() != null) {
-                        byte[] payload = Arrays.copyOfRange(buffer, 0, dataSize);
-                        SecretKey secretKey = backupState.getSecretKey();
-                        byte[] salt = backupState.getSalt();
-
-                        outputStream.write(CipherUtil.encrypt(payload, secretKey, salt));
-
-                    } else {
-                        outputStream.write(buffer, 0, dataSize);
-                    }
-
-                } catch (Exception ex) {
-                    Log.e(TAG, "Error performing incremental backup for " + backupState.getPackageName() + ": ", ex);
-                    clearBackupState(true);
-                    return TRANSPORT_ERROR;
-                }
-            }
-        }
-
-        return TRANSPORT_OK;
-    }
-
-    @Override
-    public void backupFinished() {
-        clearBackupState(true);
-    }
-
-    private void initializeBackupState() throws Exception {
-        if (backupState == null) {
-            backupState = new ContentProviderBackupState();
-        }
-
-        if (backupState.getOutputStream() == null) {
-            initializeOutputStream();
-
-            ZipEntry saltZipEntry = new ZipEntry(ContentProviderBackupConstants.SALT_FILE_PATH);
-            backupState.getOutputStream().putNextEntry(saltZipEntry);
-            backupState.getOutputStream().write(backupState.getSalt());
-            backupState.getOutputStream().closeEntry();
-
-            String password = requireNonNull(getBackupPassword(context));
-            backupState.setSecretKey(KeyGenerator.generate(password, backupState.getSalt()));
-        }
-    }
-
-    private void initializeOutputStream() throws IOException {
-        Uri folderUri = getBackupFolderUri(context);
-        // TODO notify about failure with notification
-        Uri fileUri = createBackupFile(folderUri);
-
-        ParcelFileDescriptor outputFileDescriptor = context.getContentResolver().openFileDescriptor(fileUri, "w");
-        if (outputFileDescriptor == null) throw new IOException();
-        backupState.setOutputFileDescriptor(outputFileDescriptor);
-
-        FileOutputStream fileOutputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor());
-        ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream);
-        backupState.setOutputStream(zipOutputStream);
-    }
-
-    private Uri createBackupFile(Uri folderUri) throws IOException {
-        Uri documentUri = buildDocumentUriUsingTree(folderUri, getTreeDocumentId(folderUri));
-        try {
-            Uri fileUri = createDocument(context.getContentResolver(), documentUri, DOCUMENT_MIME_TYPE, getBackupFileName());
-            if (fileUri == null) throw new IOException();
-            return fileUri;
-
-        } catch (SecurityException e) {
-            // happens when folder was deleted and thus Uri permission don't exist anymore
-            throw new IOException(e);
-        }
-    }
-
-    private String getBackupFileName() {
-        SimpleDateFormat dateFormat = new SimpleDateFormat(DOCUMENT_SUFFIX, Locale.US);
-        String date = dateFormat.format(new Date());
-        return "backup-" + date;
-    }
-
-    private int clearBackupState(boolean closeFile) {
-
-        if (backupState == null) {
-            return TRANSPORT_OK;
-        }
-
-        try {
-            IoUtils.closeQuietly(backupState.getInputFileDescriptor());
-            backupState.setInputFileDescriptor(null);
-
-            ZipOutputStream outputStream = backupState.getOutputStream();
-
-            if (outputStream != null) {
-
-                if (backupState.getCipher() != null) {
-                    outputStream.write(backupState.getCipher().doFinal());
-                    backupState.setCipher(null);
-                }
-
-                outputStream.closeEntry();
-            }
-            if (closeFile) {
-                Log.d(TAG, "Closing backup file...");
-                if (outputStream != null) {
-                    outputStream.finish();
-                    outputStream.close();
-                }
-
-                IoUtils.closeQuietly(backupState.getOutputFileDescriptor());
-                backupState = null;
-            }
-
-        } catch (Exception ex) {
-            Log.e(TAG, "Error cancelling full backup: ", ex);
-            return TRANSPORT_ERROR;
-        }
-
-        return TRANSPORT_OK;
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java
deleted file mode 100644
index 4020f9d9..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.stevesoltys.backup.transport.component.provider;
-
-/**
- * @author Steve Soltys
- */
-public interface ContentProviderBackupConstants {
-
-    String SALT_FILE_PATH = "salt";
-
-    String DEFAULT_FULL_BACKUP_DIRECTORY = "full/";
-
-    String DEFAULT_INCREMENTAL_BACKUP_DIRECTORY = "incr/";
-
-    long DEFAULT_BACKUP_QUOTA = Long.MAX_VALUE;
-
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java
deleted file mode 100644
index 0adc8d17..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java
+++ /dev/null
@@ -1,109 +0,0 @@
-package com.stevesoltys.backup.transport.component.provider;
-
-import android.os.ParcelFileDescriptor;
-
-import java.io.InputStream;
-import java.security.SecureRandom;
-import java.util.zip.ZipOutputStream;
-
-import javax.crypto.Cipher;
-import javax.crypto.SecretKey;
-
-/**
- * @author Steve Soltys
- */
-class ContentProviderBackupState {
-
-    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
-
-    private ParcelFileDescriptor inputFileDescriptor;
-
-    private ParcelFileDescriptor outputFileDescriptor;
-
-    private InputStream inputStream;
-
-    private ZipOutputStream outputStream;
-
-    private Cipher cipher;
-
-    private long bytesTransferred;
-
-    private String packageName;
-
-    private byte[] salt;
-
-    private SecretKey secretKey;
-
-    ContentProviderBackupState() {
-        salt = new byte[16];
-        SECURE_RANDOM.nextBytes(salt);
-    }
-
-    long getBytesTransferred() {
-        return bytesTransferred;
-    }
-
-    void setBytesTransferred(long bytesTransferred) {
-        this.bytesTransferred = bytesTransferred;
-    }
-
-    Cipher getCipher() {
-        return cipher;
-    }
-
-    void setCipher(Cipher cipher) {
-        this.cipher = cipher;
-    }
-
-    ParcelFileDescriptor getInputFileDescriptor() {
-        return inputFileDescriptor;
-    }
-
-    void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) {
-        this.inputFileDescriptor = inputFileDescriptor;
-    }
-
-    InputStream getInputStream() {
-        return inputStream;
-    }
-
-    void setInputStream(InputStream inputStream) {
-        this.inputStream = inputStream;
-    }
-
-    ParcelFileDescriptor getOutputFileDescriptor() {
-        return outputFileDescriptor;
-    }
-
-    void setOutputFileDescriptor(ParcelFileDescriptor outputFileDescriptor) {
-        this.outputFileDescriptor = outputFileDescriptor;
-    }
-
-    ZipOutputStream getOutputStream() {
-        return outputStream;
-    }
-
-    void setOutputStream(ZipOutputStream outputStream) {
-        this.outputStream = outputStream;
-    }
-
-    String getPackageName() {
-        return packageName;
-    }
-
-    void setPackageName(String packageName) {
-        this.packageName = packageName;
-    }
-
-    byte[] getSalt() {
-        return salt;
-    }
-
-    SecretKey getSecretKey() {
-        return secretKey;
-    }
-
-    void setSecretKey(SecretKey secretKey) {
-        this.secretKey = secretKey;
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java
deleted file mode 100644
index f983bed9..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java
+++ /dev/null
@@ -1,360 +0,0 @@
-package com.stevesoltys.backup.transport.component.provider;
-
-import android.annotation.Nullable;
-import android.app.backup.BackupDataOutput;
-import android.app.backup.RestoreDescription;
-import android.app.backup.RestoreSet;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.pm.PackageInfo;
-import android.net.Uri;
-import android.os.ParcelFileDescriptor;
-import android.util.Base64;
-import android.util.Log;
-
-import com.android.internal.util.Preconditions;
-import com.stevesoltys.backup.security.CipherUtil;
-import com.stevesoltys.backup.security.KeyGenerator;
-import com.stevesoltys.backup.transport.component.RestoreComponent;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Arrays;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Optional;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
-import javax.crypto.SecretKey;
-
-import libcore.io.IoUtils;
-import libcore.io.Streams;
-
-import static android.app.backup.BackupTransport.NO_MORE_DATA;
-import static android.app.backup.BackupTransport.TRANSPORT_ERROR;
-import static android.app.backup.BackupTransport.TRANSPORT_OK;
-import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED;
-import static android.app.backup.RestoreDescription.TYPE_FULL_STREAM;
-import static android.app.backup.RestoreDescription.TYPE_KEY_VALUE;
-import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY;
-import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY;
-import static java.util.Objects.requireNonNull;
-
-/**
- * @author Steve Soltys
- */
-public class ContentProviderRestoreComponent implements RestoreComponent {
-
-    private static final String TAG = ContentProviderRestoreComponent.class.getName();
-
-    private static final int DEFAULT_RESTORE_SET = 1;
-
-    private static final int DEFAULT_BUFFER_SIZE = 2048;
-
-    @Nullable
-    private String password;
-    @Nullable
-    private Uri fileUri;
-
-    private ContentProviderRestoreState restoreState;
-
-    private final Context context;
-
-    public ContentProviderRestoreComponent(Context context) {
-        this.context = context;
-    }
-
-    @Override
-    public void prepareRestore(String password, Uri fileUri) {
-        this.password = password;
-        this.fileUri = fileUri;
-    }
-
-    @Override
-    public int startRestore(long token, PackageInfo[] packages) {
-        restoreState = new ContentProviderRestoreState();
-        restoreState.setPackages(packages);
-        restoreState.setPackageIndex(-1);
-
-        String password = requireNonNull(this.password);
-
-        if (!password.isEmpty()) {
-            try {
-                ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
-                ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
-                seekToEntry(inputStream, ContentProviderBackupConstants.SALT_FILE_PATH);
-
-                restoreState.setSalt(Streams.readFullyNoClose(inputStream));
-                restoreState.setSecretKey(KeyGenerator.generate(password, restoreState.getSalt()));
-
-                IoUtils.closeQuietly(inputFileDescriptor);
-                IoUtils.closeQuietly(inputStream);
-
-            } catch (Exception ex) {
-                Log.e(TAG, "Salt not found", ex);
-            }
-        }
-
-        try {
-            List<ZipEntry> zipEntries = new LinkedList<>();
-
-            ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
-            ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
-
-            ZipEntry zipEntry;
-            while ((zipEntry = inputStream.getNextEntry()) != null) {
-                zipEntries.add(zipEntry);
-                inputStream.closeEntry();
-            }
-
-            IoUtils.closeQuietly(inputFileDescriptor);
-            IoUtils.closeQuietly(inputStream);
-
-            restoreState.setZipEntries(zipEntries);
-
-        } catch (Exception ex) {
-            Log.e(TAG, "Error while caching zip entries", ex);
-        }
-
-        return TRANSPORT_OK;
-    }
-
-    @Override
-    public RestoreDescription nextRestorePackage() {
-        Preconditions.checkNotNull(restoreState, "startRestore() not called");
-
-        int packageIndex = restoreState.getPackageIndex();
-        PackageInfo[] packages = restoreState.getPackages();
-
-        while (++packageIndex < packages.length) {
-            restoreState.setPackageIndex(packageIndex);
-            String name = packages[packageIndex].packageName;
-
-            if (containsPackageFile(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + name)) {
-                restoreState.setRestoreType(TYPE_KEY_VALUE);
-                return new RestoreDescription(name, restoreState.getRestoreType());
-
-            } else if (containsPackageFile(DEFAULT_FULL_BACKUP_DIRECTORY + name)) {
-                restoreState.setRestoreType(TYPE_FULL_STREAM);
-                return new RestoreDescription(name, restoreState.getRestoreType());
-            }
-        }
-        return RestoreDescription.NO_MORE_PACKAGES;
-    }
-
-    private boolean containsPackageFile(String fileName) {
-        return restoreState.getZipEntries().stream()
-                .anyMatch(zipEntry -> zipEntry.getName().startsWith(fileName));
-    }
-
-    @Override
-    public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) {
-        Preconditions.checkState(restoreState != null, "startRestore() not called");
-        Preconditions.checkState(restoreState.getPackageIndex() >= 0, "nextRestorePackage() not called");
-        Preconditions.checkState(restoreState.getRestoreType() == TYPE_KEY_VALUE,
-                "getRestoreData() for non-key/value dataset");
-
-        PackageInfo packageInfo = restoreState.getPackages()[restoreState.getPackageIndex()];
-
-        try {
-            return transferIncrementalRestoreData(packageInfo.packageName, outputFileDescriptor);
-
-        } catch (Exception ex) {
-            Log.e(TAG, "Unable to read backup records: ", ex);
-            return TRANSPORT_ERROR;
-        }
-    }
-
-    private int transferIncrementalRestoreData(String packageName, ParcelFileDescriptor outputFileDescriptor)
-            throws Exception {
-
-        ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor();
-        ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
-        BackupDataOutput backupDataOutput = new BackupDataOutput(outputFileDescriptor.getFileDescriptor());
-
-        Optional<ZipEntry> zipEntryOptional = seekToEntry(inputStream,
-                DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName);
-
-        while (zipEntryOptional.isPresent()) {
-            String fileName = new File(zipEntryOptional.get().getName()).getName();
-            String blobKey = new String(Base64.decode(fileName, Base64.DEFAULT));
-
-            byte[] backupData = readBackupData(inputStream);
-            backupDataOutput.writeEntityHeader(blobKey, backupData.length);
-            backupDataOutput.writeEntityData(backupData, backupData.length);
-            inputStream.closeEntry();
-
-            zipEntryOptional = seekToEntry(inputStream, DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName);
-        }
-
-        IoUtils.closeQuietly(inputFileDescriptor);
-        IoUtils.closeQuietly(outputFileDescriptor);
-        return TRANSPORT_OK;
-    }
-
-    private byte[] readBackupData(ZipInputStream inputStream) throws Exception {
-        byte[] backupData = Streams.readFullyNoClose(inputStream);
-        SecretKey secretKey = restoreState.getSecretKey();
-        byte[] initializationVector = restoreState.getSalt();
-
-        if (secretKey != null) {
-            backupData = CipherUtil.decrypt(backupData, secretKey, initializationVector);
-        }
-
-        return backupData;
-    }
-
-    @Override
-    public int getNextFullRestoreDataChunk(ParcelFileDescriptor outputFileDescriptor) {
-        Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM,
-                "Asked for full restore data for non-stream package");
-
-        ParcelFileDescriptor inputFileDescriptor = restoreState.getInputFileDescriptor();
-
-        if (inputFileDescriptor == null) {
-            String name = restoreState.getPackages()[restoreState.getPackageIndex()].packageName;
-
-            try {
-                inputFileDescriptor = buildInputFileDescriptor();
-                restoreState.setInputFileDescriptor(inputFileDescriptor);
-
-                ZipInputStream inputStream = buildInputStream(inputFileDescriptor);
-                restoreState.setInputStream(inputStream);
-
-                if (!seekToEntry(inputStream, DEFAULT_FULL_BACKUP_DIRECTORY + name).isPresent()) {
-                    IoUtils.closeQuietly(inputFileDescriptor);
-                    IoUtils.closeQuietly(outputFileDescriptor);
-                    return TRANSPORT_PACKAGE_REJECTED;
-                }
-
-            } catch (IOException ex) {
-                Log.e(TAG, "Unable to read archive for " + name, ex);
-
-                IoUtils.closeQuietly(inputFileDescriptor);
-                IoUtils.closeQuietly(outputFileDescriptor);
-                return TRANSPORT_PACKAGE_REJECTED;
-            }
-        }
-
-        return transferFullRestoreData(outputFileDescriptor);
-    }
-
-    private int transferFullRestoreData(ParcelFileDescriptor outputFileDescriptor) {
-        ZipInputStream inputStream = restoreState.getInputStream();
-        OutputStream outputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor());
-
-        byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
-        int bytesRead = NO_MORE_DATA;
-
-        try {
-            bytesRead = inputStream.read(buffer);
-
-            if (bytesRead <= 0) {
-                bytesRead = NO_MORE_DATA;
-
-                if (restoreState.getCipher() != null) {
-                    buffer = restoreState.getCipher().doFinal();
-                    bytesRead = buffer.length;
-
-                    outputStream.write(buffer, 0, bytesRead);
-                    restoreState.setCipher(null);
-                }
-
-            } else {
-                if (restoreState.getSecretKey() != null) {
-                    SecretKey secretKey = restoreState.getSecretKey();
-                    byte[] salt = restoreState.getSalt();
-
-                    if (restoreState.getCipher() == null) {
-                        restoreState.setCipher(CipherUtil.startDecrypt(secretKey, salt));
-                    }
-
-                    buffer = restoreState.getCipher().update(Arrays.copyOfRange(buffer, 0, bytesRead));
-                    bytesRead = buffer.length;
-                }
-
-                outputStream.write(buffer, 0, bytesRead);
-            }
-
-        } catch (Exception e) {
-            Log.e(TAG, "Exception while streaming restore data: ", e);
-            return TRANSPORT_ERROR;
-
-        } finally {
-            if (bytesRead == NO_MORE_DATA) {
-
-                if (restoreState.getInputFileDescriptor() != null) {
-                    IoUtils.closeQuietly(restoreState.getInputFileDescriptor());
-                }
-
-                restoreState.setInputFileDescriptor(null);
-                restoreState.setInputStream(null);
-            }
-
-            IoUtils.closeQuietly(outputFileDescriptor);
-        }
-
-        return bytesRead;
-    }
-
-    @Override
-    public int abortFullRestore() {
-        resetFullRestoreState();
-        return TRANSPORT_OK;
-    }
-
-    @Override
-    public long getCurrentRestoreSet() {
-        return DEFAULT_RESTORE_SET;
-    }
-
-    @Override
-    public void finishRestore() {
-        if (restoreState.getRestoreType() == TYPE_FULL_STREAM) {
-            resetFullRestoreState();
-        }
-
-        restoreState = null;
-    }
-
-    @Override
-    public RestoreSet[] getAvailableRestoreSets() {
-        return new RestoreSet[]{new RestoreSet("Local disk image", "flash", DEFAULT_RESTORE_SET)};
-    }
-
-    private void resetFullRestoreState() {
-        Preconditions.checkNotNull(restoreState);
-        Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM);
-
-        IoUtils.closeQuietly(restoreState.getInputFileDescriptor());
-        restoreState = null;
-    }
-
-    private ParcelFileDescriptor buildInputFileDescriptor() throws FileNotFoundException {
-        ContentResolver contentResolver = context.getContentResolver();
-        return contentResolver.openFileDescriptor(requireNonNull(fileUri), "r");
-    }
-
-    private ZipInputStream buildInputStream(ParcelFileDescriptor inputFileDescriptor) {
-        FileInputStream fileInputStream = new FileInputStream(inputFileDescriptor.getFileDescriptor());
-        return new ZipInputStream(fileInputStream);
-    }
-
-    private Optional<ZipEntry> seekToEntry(ZipInputStream inputStream, String entryPath) throws IOException {
-        ZipEntry zipEntry;
-        while ((zipEntry = inputStream.getNextEntry()) != null) {
-
-            if (zipEntry.getName().startsWith(entryPath)) {
-                return Optional.of(zipEntry);
-            }
-            inputStream.closeEntry();
-        }
-
-        return Optional.empty();
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java
deleted file mode 100644
index a7b6e3c6..00000000
--- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java
+++ /dev/null
@@ -1,106 +0,0 @@
-package com.stevesoltys.backup.transport.component.provider;
-
-import android.content.pm.PackageInfo;
-import android.os.ParcelFileDescriptor;
-
-import javax.crypto.Cipher;
-import javax.crypto.SecretKey;
-import java.util.List;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
-/**
- * @author Steve Soltys
- */
-class ContentProviderRestoreState {
-
-    private ParcelFileDescriptor inputFileDescriptor;
-
-    private PackageInfo[] packages;
-
-    private int packageIndex;
-
-    private int restoreType;
-
-    private ZipInputStream inputStream;
-
-    private Cipher cipher;
-
-    private byte[] salt;
-
-    private SecretKey secretKey;
-
-    private List<ZipEntry> zipEntries;
-
-    Cipher getCipher() {
-        return cipher;
-    }
-
-    ParcelFileDescriptor getInputFileDescriptor() {
-        return inputFileDescriptor;
-    }
-
-    void setCipher(Cipher cipher) {
-        this.cipher = cipher;
-    }
-
-    void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) {
-        this.inputFileDescriptor = inputFileDescriptor;
-    }
-
-    ZipInputStream getInputStream() {
-        return inputStream;
-    }
-
-    void setInputStream(ZipInputStream inputStream) {
-        this.inputStream = inputStream;
-    }
-
-    int getPackageIndex() {
-        return packageIndex;
-    }
-
-    void setPackageIndex(int packageIndex) {
-        this.packageIndex = packageIndex;
-    }
-
-    PackageInfo[] getPackages() {
-        return packages;
-    }
-
-    void setPackages(PackageInfo[] packages) {
-        this.packages = packages;
-    }
-
-    int getRestoreType() {
-        return restoreType;
-    }
-
-    void setRestoreType(int restoreType) {
-        this.restoreType = restoreType;
-    }
-
-    byte[] getSalt() {
-        return salt;
-    }
-
-    void setSalt(byte[] salt) {
-        this.salt = salt;
-    }
-
-    public SecretKey getSecretKey() {
-        return secretKey;
-    }
-
-    public void setSecretKey(SecretKey secretKey) {
-        this.secretKey = secretKey;
-    }
-
-    public List<ZipEntry> getZipEntries() {
-        return zipEntries;
-    }
-
-    public void setZipEntries(List<ZipEntry> zipEntries) {
-        this.zipEntries = zipEntries;
-    }
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt
new file mode 100644
index 00000000..66588567
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt
@@ -0,0 +1,172 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.app.backup.BackupTransport.*
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import com.stevesoltys.backup.crypto.Crypto
+import com.stevesoltys.backup.header.HeaderReader
+import com.stevesoltys.backup.header.UnsupportedVersionException
+import libcore.io.IoUtils.closeQuietly
+import java.io.EOFException
+import java.io.IOException
+import java.io.InputStream
+
+private class FullRestoreState(
+        internal val token: Long,
+        internal val packageInfo: PackageInfo) {
+    internal var inputStream: InputStream? = null
+}
+
+private val TAG = FullRestore::class.java.simpleName
+
+internal class FullRestore(
+        private val plugin: FullRestorePlugin,
+        private val outputFactory: OutputFactory,
+        private val headerReader: HeaderReader,
+        private val crypto: Crypto) {
+
+    private var state: FullRestoreState? = null
+
+    fun hasState() = state != null
+
+    /**
+     * Return true if there is data stored for the given package.
+     */
+    @Throws(IOException::class)
+    fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
+        return plugin.hasDataForPackage(token, packageInfo)
+    }
+
+    /**
+     * This prepares to restore the given package from the given restore token.
+     *
+     * It is possible that the system decides to not restore the package.
+     * Then a new state will be initialized right away without calling other methods.
+     */
+    fun initializeState(token: Long, packageInfo: PackageInfo) {
+        state = FullRestoreState(token, packageInfo)
+    }
+
+    /**
+     * Ask the transport to provide data for the "current" package being restored.
+     *
+     * The transport writes some data to the socket supplied to this call,
+     * and returns the number of bytes written.
+     * The system will then read that many bytes
+     * and stream them to the application's agent for restore,
+     * then will call this method again to receive the next chunk of the archive.
+     * This sequence will be repeated until the transport returns zero
+     * indicating that all of the package's data has been delivered
+     * (or returns a negative value indicating a hard error condition at the transport level).
+     *
+     * The transport should always close this socket when returning from this method.
+     * Do not cache this socket across multiple calls or you may leak file descriptors.
+     *
+     * @param socket The file descriptor for delivering the streamed archive.
+     * The transport must close this socket in all cases when returning from this method.
+     * @return [NO_MORE_DATA] when no more data for the current package is available.
+     * A positive value indicates the presence of that many bytes to be delivered to the app.
+     * A value of zero indicates that no data was deliverable at this time,
+     * but the restore is still running and the caller should retry.
+     * [TRANSPORT_PACKAGE_REJECTED] means that the package's restore operation should be aborted,
+     * but that the transport itself is still in a good state
+     * and so a multiple-package restore sequence can still be continued.
+     * Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition
+     * that aborts all further restore operations on the current dataset.
+     */
+    fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
+        Log.i(TAG, "Get next full restore data chunk.")
+        val state = this.state ?: throw IllegalStateException()
+        val packageName = state.packageInfo.packageName
+
+        if (state.inputStream == null) {
+            Log.i(TAG, "First Chunk, initializing package input stream.")
+            try {
+                val inputStream = plugin.getInputStreamForPackage(state.token, state.packageInfo)
+                val version = headerReader.readVersion(inputStream)
+                crypto.decryptHeader(inputStream, version, packageName)
+                state.inputStream = inputStream
+            } catch (e: IOException) {
+                Log.w(TAG, "Error getting input stream for $packageName", e)
+                return TRANSPORT_PACKAGE_REJECTED
+            } catch (e: SecurityException) {
+                Log.e(TAG, "Security Exception while getting input stream for $packageName", e)
+                return TRANSPORT_ERROR
+            } catch (e: UnsupportedVersionException) {
+                Log.e(TAG, "Backup data for $packageName uses unsupported version ${e.version}.", e)
+                return TRANSPORT_PACKAGE_REJECTED
+            }
+        }
+
+        return readInputStream(socket)
+    }
+
+    private fun readInputStream(socket: ParcelFileDescriptor): Int = socket.use { fileDescriptor ->
+        val state = this.state ?: throw IllegalStateException()
+        val packageName = state.packageInfo.packageName
+        val inputStream = state.inputStream ?: throw IllegalStateException()
+        val outputStream = outputFactory.getOutputStream(fileDescriptor)
+
+        try {
+            // read segment from input stream and decrypt it
+            val decrypted = try {
+                crypto.decryptSegment(inputStream)
+            } catch (e: EOFException) {
+                Log.i(TAG, "   EOF")
+                // close input stream here as we won't need it anymore
+                closeQuietly(inputStream)
+                return NO_MORE_DATA
+            }
+
+            // write decrypted segment to output stream (without header)
+            outputStream.write(decrypted)
+            // return number of written bytes
+            return decrypted.size
+        } catch (e: IOException) {
+            Log.w(TAG, "Error processing stream for package $packageName.", e)
+            closeQuietly(inputStream)
+            return TRANSPORT_PACKAGE_REJECTED
+        } finally {
+            closeQuietly(outputStream)
+        }
+    }
+
+    /**
+     * If the OS encounters an error while processing full data for restore,
+     * it will invoke this method
+     * to tell the transport that it should abandon the data download for the current package.
+     *
+     * @return [TRANSPORT_OK] if the transport shut down the current stream cleanly,
+     * or [TRANSPORT_ERROR] to indicate a serious transport-level failure.
+     * If the transport reports an error here,
+     * the entire restore operation will immediately be finished
+     * with no further attempts to restore app data.
+     */
+    fun abortFullRestore(): Int {
+        val state = this.state ?: throw IllegalStateException()
+        Log.i(TAG, "Abort full restore of ${state.packageInfo.packageName}!")
+
+        resetState()
+        return TRANSPORT_OK
+    }
+
+    /**
+     * End a restore session (aborting any in-process data transfer as necessary),
+     * freeing any resources and connections used during the restore process.
+     */
+    fun finishRestore() {
+        val state = this.state ?: throw IllegalStateException()
+        Log.i(TAG, "Finish restore of ${state.packageInfo.packageName}!")
+
+        resetState()
+    }
+
+    private fun resetState() {
+        Log.i(TAG, "Resetting state.")
+
+        closeQuietly(state?.inputStream)
+        state = null
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt
new file mode 100644
index 00000000..9281d76c
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt
@@ -0,0 +1,18 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.content.pm.PackageInfo
+import java.io.IOException
+import java.io.InputStream
+
+interface FullRestorePlugin {
+
+    /**
+     * Return true if there is data stored for the given package.
+     */
+    @Throws(IOException::class)
+    fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
+
+    @Throws(IOException::class)
+    fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt
new file mode 100644
index 00000000..67f6b1a5
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt
@@ -0,0 +1,140 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.app.backup.BackupDataOutput
+import android.app.backup.BackupTransport.TRANSPORT_ERROR
+import android.app.backup.BackupTransport.TRANSPORT_OK
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import com.stevesoltys.backup.crypto.Crypto
+import com.stevesoltys.backup.header.HeaderReader
+import com.stevesoltys.backup.header.UnsupportedVersionException
+import libcore.io.IoUtils.closeQuietly
+import java.io.IOException
+import java.util.*
+import java.util.Base64.getUrlDecoder
+
+private class KVRestoreState(
+        internal val token: Long,
+        internal val packageInfo: PackageInfo)
+
+private val TAG = KVRestore::class.java.simpleName
+
+internal class KVRestore(
+        private val plugin: KVRestorePlugin,
+        private val outputFactory: OutputFactory,
+        private val headerReader: HeaderReader,
+        private val crypto: Crypto) {
+
+    private var state: KVRestoreState? = null
+
+    /**
+     * Return true if there are records stored for the given package.
+     */
+    @Throws(IOException::class)
+    fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
+        return plugin.hasDataForPackage(token, packageInfo)
+    }
+
+    /**
+     * This prepares to restore the given package from the given restore token.
+     *
+     * It is possible that the system decides to not restore the package.
+     * Then a new state will be initialized right away without calling other methods.
+     */
+    fun initializeState(token: Long, packageInfo: PackageInfo) {
+        state = KVRestoreState(token, packageInfo)
+    }
+
+    /**
+     * Get the data for the current package.
+     *
+     * @param data An open, writable file into which the key/value backup data should be stored.
+     * @return One of [TRANSPORT_OK]
+     * or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
+     */
+    fun getRestoreData(data: ParcelFileDescriptor): Int {
+        val state = this.state ?: throw IllegalStateException()
+
+        // The restore set is the concatenation of the individual record blobs,
+        // each of which is a file in the package's directory.
+        // We return the data in lexical order sorted by key,
+        // so that apps which use synthetic keys like BLOB_1, BLOB_2, etc
+        // will see the date in the most obvious order.
+        val sortedKeys = getSortedKeys(state.token, state.packageInfo)
+        if (sortedKeys == null) {
+            // nextRestorePackage() ensures the dir exists, so this is an error
+            Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}")
+            return TRANSPORT_ERROR
+        }
+
+        // We expect at least some data if the directory exists in the first place
+        Log.v(TAG, "  getRestoreData() found ${sortedKeys.size} key files")
+
+        return try {
+            val dataOutput = outputFactory.getBackupDataOutput(data)
+            for (keyEntry in sortedKeys) {
+                readAndWriteValue(state, keyEntry, dataOutput)
+            }
+            TRANSPORT_OK
+        } catch (e: IOException) {
+            Log.e(TAG, "Unable to read backup records", e)
+            TRANSPORT_ERROR
+        } catch (e: SecurityException) {
+            Log.e(TAG, "Security exception while reading backup records", e)
+            TRANSPORT_ERROR
+        } catch (e: UnsupportedVersionException) {
+            Log.e(TAG, "Unsupported version in backup: ${e.version}", e)
+            TRANSPORT_ERROR
+        } finally {
+            this.state = null
+            closeQuietly(data)
+        }
+    }
+
+    /**
+     * Return a list of the records (represented by key files) in the given directory,
+     * sorted lexically by the Base64-decoded key file name, not by the on-disk filename.
+     */
+    private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
+        val records: List<String> = try {
+            plugin.listRecords(token, packageInfo)
+        } catch (e: IOException) {
+            return null
+        }
+        if (records.isEmpty()) return null
+
+        // Decode the key filenames into keys then sort lexically by key
+        val contents = ArrayList<DecodedKey>()
+        for (recordKey in records) contents.add(DecodedKey(recordKey))
+        contents.sort()
+        return contents
+    }
+
+    /**
+     * Read the encrypted value for the given key and write it to the given [BackupDataOutput].
+     */
+    @Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class)
+    private fun readAndWriteValue(state: KVRestoreState, dKey: DecodedKey, out: BackupDataOutput) {
+        val inputStream = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
+        try {
+            val version = headerReader.readVersion(inputStream)
+            crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key)
+            val value = crypto.decryptSegment(inputStream)
+            val size = value.size
+            Log.v(TAG, "    ... key=${dKey.key} size=$size")
+
+            out.writeEntityHeader(dKey.key, size)
+            out.writeEntityData(value, size)
+        } finally {
+            closeQuietly(inputStream)
+        }
+    }
+
+    private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> {
+        internal val key = String(getUrlDecoder().decode(base64Key))
+
+        override fun compareTo(other: DecodedKey) = key.compareTo(other.key)
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt
new file mode 100644
index 00000000..e425752c
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt
@@ -0,0 +1,30 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.content.pm.PackageInfo
+import java.io.IOException
+import java.io.InputStream
+
+interface KVRestorePlugin {
+
+    /**
+     * Return true if there is data stored for the given package.
+     */
+    @Throws(IOException::class)
+    fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
+
+    /**
+     * Return all record keys for the given token and package.
+     *
+     * For file-based plugins, this is usually a list of file names in the package directory.
+     */
+    @Throws(IOException::class)
+    fun listRecords(token: Long, packageInfo: PackageInfo): List<String>
+
+    /**
+     * Return an [InputStream] for the given token, package and key
+     * which will provide the record's encrypted value.
+     */
+    @Throws(IOException::class)
+    fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt
new file mode 100644
index 00000000..16162bb6
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt
@@ -0,0 +1,21 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.app.backup.BackupDataOutput
+import android.os.ParcelFileDescriptor
+import java.io.FileOutputStream
+import java.io.OutputStream
+
+/**
+ * This class exists for easier testing, so we can mock it and return custom data outputs.
+ */
+class OutputFactory {
+
+    fun getBackupDataOutput(outputFileDescriptor: ParcelFileDescriptor): BackupDataOutput {
+        return BackupDataOutput(outputFileDescriptor.fileDescriptor)
+    }
+    
+    fun getOutputStream(outputFileDescriptor: ParcelFileDescriptor): OutputStream {
+        return FileOutputStream(outputFileDescriptor.fileDescriptor)
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt
new file mode 100644
index 00000000..d6dc397a
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt
@@ -0,0 +1,155 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.app.backup.BackupTransport.TRANSPORT_ERROR
+import android.app.backup.BackupTransport.TRANSPORT_OK
+import android.app.backup.RestoreDescription
+import android.app.backup.RestoreDescription.*
+import android.app.backup.RestoreSet
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import java.io.IOException
+
+private class RestoreCoordinatorState(
+        internal val token: Long,
+        internal val packages: Iterator<PackageInfo>)
+
+private val TAG = RestoreCoordinator::class.java.simpleName
+
+internal class RestoreCoordinator(
+        private val plugin: RestorePlugin,
+        private val kv: KVRestore,
+        private val full: FullRestore) {
+
+    private var state: RestoreCoordinatorState? = null
+
+    fun getAvailableRestoreSets(): Array<RestoreSet>? {
+        return plugin.getAvailableRestoreSets()
+                .apply { Log.i(TAG, "Got available restore sets: $this") }
+    }
+
+    fun getCurrentRestoreSet(): Long {
+        return plugin.getCurrentRestoreSet()
+                .apply { Log.i(TAG, "Got current restore set token: $this") }
+    }
+
+    /**
+     * Start restoring application data from backup.
+     * After calling this function,
+     * there will be alternate calls to [nextRestorePackage] and [getRestoreData]
+     * to walk through the actual application data.
+     *
+     * @param token A backup token as returned by [getAvailableRestoreSets] or [getCurrentRestoreSet].
+     * @param packages List of applications to restore (if data is available).
+     * Application data will be restored in the order given.
+     * @return One of [TRANSPORT_OK] (OK so far, call [nextRestorePackage])
+     * or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
+     */
+    fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
+        if (state != null) throw IllegalStateException()
+        Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}")
+        state = RestoreCoordinatorState(token, packages.iterator())
+        return TRANSPORT_OK
+    }
+
+    /**
+     * Get the package name of the next package with data in the backup store,
+     * plus a description of the structure of the restored archive:
+     * either [TYPE_KEY_VALUE] for an original-API key/value dataset,
+     * or [TYPE_FULL_STREAM] for a tarball-type archive stream.
+     *
+     * If the package name in the returned [RestoreDescription] object is [NO_MORE_PACKAGES],
+     * it indicates that no further data is available in the current restore session,
+     * i.e. all packages described in [startRestore] have been processed.
+     *
+     * If this method returns null, it means that a transport-level error has
+     * occurred and the entire restore operation should be abandoned.
+     *
+     * The OS may call [nextRestorePackage] multiple times
+     * before calling either [getRestoreData] or [getNextFullRestoreDataChunk].
+     * It does this when it has determined
+     * that it needs to skip restore of one or more packages.
+     * The transport should not actually transfer any restore data
+     * for the given package in response to [nextRestorePackage],
+     * but rather wait for an explicit request before doing so.
+     *
+     * @return A [RestoreDescription] object containing the name of one of the packages
+     * supplied to [startRestore] plus an indicator of the data type of that restore data;
+     * or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session;
+     * or null to indicate a transport-level error.
+     */
+    fun nextRestorePackage(): RestoreDescription? {
+        Log.i(TAG, "Next restore package!")
+        val state = this.state ?: throw IllegalStateException()
+
+        if (!state.packages.hasNext()) return NO_MORE_PACKAGES
+        val packageInfo = state.packages.next()
+        val packageName = packageInfo.packageName
+
+        val type = try {
+            when {
+                // check key/value data first and if available, don't even check for full data
+                kv.hasDataForPackage(state.token, packageInfo) -> {
+                    Log.i(TAG, "Found K/V data for $packageName.")
+                    kv.initializeState(state.token, packageInfo)
+                    TYPE_KEY_VALUE
+                }
+                full.hasDataForPackage(state.token, packageInfo) -> {
+                    Log.i(TAG, "Found full backup data for $packageName.")
+                    full.initializeState(state.token, packageInfo)
+                    TYPE_FULL_STREAM
+                }
+                else -> {
+                    Log.i(TAG, "No data found for $packageName. Skipping.")
+                    return nextRestorePackage()
+                }
+            }
+        } catch (e: IOException) {
+            Log.e(TAG, "Error finding restore data for $packageName.", e)
+            return null
+        }
+        return RestoreDescription(packageName, type)
+    }
+
+    /**
+     * Get the data for the application returned by [nextRestorePackage],
+     * if that method reported [TYPE_KEY_VALUE] as its delivery type.
+     * If the package has only TYPE_FULL_STREAM data, then this method will return an error.
+     *
+     * @param data An open, writable file into which the key/value backup data should be stored.
+     * @return the same error codes as [startRestore].
+     */
+    fun getRestoreData(data: ParcelFileDescriptor): Int {
+        return kv.getRestoreData(data)
+    }
+
+    /**
+     * Ask the transport to provide data for the "current" package being restored.
+     *
+     * After this method returns zero, the system will then call [nextRestorePackage]
+     * to begin the restore process for the next application, and the sequence begins again.
+     */
+    fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int {
+        return full.getNextFullRestoreDataChunk(outputFileDescriptor)
+    }
+
+    /**
+     * If the OS encounters an error while processing full data for restore, it will abort.
+     *
+     * The OS will then either call [nextRestorePackage] again to move on
+     * to restoring the next package in the set being iterated over,
+     * or will call [finishRestore] to shut down the restore operation.
+     */
+    fun abortFullRestore(): Int {
+        return full.abortFullRestore()
+    }
+
+    /**
+     * End a restore session (aborting any in-process data transfer as necessary),
+     * freeing any resources and connections used during the restore process.
+     */
+    fun finishRestore() {
+        if (full.hasState()) full.finishRestore()
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt
new file mode 100644
index 00000000..17f4f0ad
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt
@@ -0,0 +1,28 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.app.backup.RestoreSet
+
+interface RestorePlugin {
+
+    val kvRestorePlugin: KVRestorePlugin
+
+    val fullRestorePlugin: FullRestorePlugin
+
+    /**
+     * Get the set of all backups currently available for restore.
+     *
+     * @return Descriptions of the set of restore images available for this device,
+     * or null if an error occurred (the attempt should be rescheduled).
+     **/
+    fun getAvailableRestoreSets(): Array<RestoreSet>?
+
+    /**
+     * Get the identifying token of the backup set currently being stored from this device.
+     * This is used in the case of applications wishing to restore their last-known-good data.
+     *
+     * @return A token that can be used for restore,
+     * or 0 if there is no backup set available corresponding to the current device state.
+     */
+    fun getCurrentRestoreSet(): Long
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt
new file mode 100644
index 00000000..83825a06
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt
@@ -0,0 +1,25 @@
+package com.stevesoltys.backup.transport.restore.plugins
+
+import android.content.pm.PackageInfo
+import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
+import com.stevesoltys.backup.transport.restore.FullRestorePlugin
+import java.io.IOException
+import java.io.InputStream
+
+class DocumentsProviderFullRestorePlugin(
+        private val documentsStorage: DocumentsStorage) : FullRestorePlugin {
+
+    @Throws(IOException::class)
+    override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
+        val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
+        return backupDir.findFile(packageInfo.packageName) != null
+    }
+
+    @Throws(IOException::class)
+    override fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream {
+        val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
+        val packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException()
+        return documentsStorage.getInputStream(packageFile)
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt
new file mode 100644
index 00000000..d24b1bd1
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt
@@ -0,0 +1,42 @@
+package com.stevesoltys.backup.transport.restore.plugins
+
+import android.content.pm.PackageInfo
+import androidx.documentfile.provider.DocumentFile
+import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
+import com.stevesoltys.backup.transport.backup.plugins.assertRightFile
+import com.stevesoltys.backup.transport.restore.KVRestorePlugin
+import java.io.IOException
+import java.io.InputStream
+
+class DocumentsProviderKVRestorePlugin(private val storage: DocumentsStorage) : KVRestorePlugin {
+
+    private var packageDir: DocumentFile? = null
+
+    override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
+        return try {
+            val backupDir = storage.getKVBackupDir(token) ?: return false
+            // remember package file for subsequent operations
+            packageDir = backupDir.findFile(packageInfo.packageName)
+            packageDir != null
+        } catch (e: IOException) {
+            false
+        }
+    }
+
+    override fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
+        val packageDir = this.packageDir ?: throw AssertionError()
+        packageDir.assertRightFile(packageInfo)
+        return packageDir.listFiles()
+                .filter { file -> file.name != null }
+                .map { file -> file.name!! }
+    }
+
+    @Throws(IOException::class)
+    override fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream {
+        val packageDir = this.packageDir ?: throw AssertionError()
+        packageDir.assertRightFile(packageInfo)
+        val keyFile = packageDir.findFile(key) ?: throw IOException()
+        return storage.getInputStream(keyFile)
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt
new file mode 100644
index 00000000..deb9e327
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt
@@ -0,0 +1,29 @@
+package com.stevesoltys.backup.transport.restore.plugins
+
+import android.app.backup.RestoreSet
+import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
+import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
+import com.stevesoltys.backup.transport.restore.FullRestorePlugin
+import com.stevesoltys.backup.transport.restore.KVRestorePlugin
+import com.stevesoltys.backup.transport.restore.RestorePlugin
+
+class DocumentsProviderRestorePlugin(
+        private val documentsStorage: DocumentsStorage) : RestorePlugin {
+
+    override val kvRestorePlugin: KVRestorePlugin by lazy {
+        DocumentsProviderKVRestorePlugin(documentsStorage)
+    }
+
+    override val fullRestorePlugin: FullRestorePlugin by lazy {
+        DocumentsProviderFullRestorePlugin(documentsStorage)
+    }
+
+    override fun getAvailableRestoreSets(): Array<RestoreSet>? {
+        return arrayOf(RestoreSet("default", "device", DEFAULT_RESTORE_SET_TOKEN))
+    }
+
+    override fun getCurrentRestoreSet(): Long {
+        return DEFAULT_RESTORE_SET_TOKEN
+    }
+
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2c8e82e3..e06e4f21 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -26,6 +26,7 @@
     <!-- Settings -->
     <string name="settings_backup">Backup my data</string>
     <string name="settings_backup_location">Backup location</string>
+    <string name="settings_backup_location_picker">Choose backup location</string>
     <string name="settings_backup_location_title">Backup Location</string>
     <string name="settings_backup_location_info">Choose where to store your backups. More options might get added in the future.</string>
     <string name="settings_backup_external_storage">External Storage</string>
diff --git a/app/src/test/java/com/stevesoltys/backup/TestUtils.kt b/app/src/test/java/com/stevesoltys/backup/TestUtils.kt
new file mode 100644
index 00000000..6644f3b1
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/TestUtils.kt
@@ -0,0 +1,38 @@
+package com.stevesoltys.backup
+
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Assertions.fail
+import kotlin.random.Random
+
+fun assertContains(stack: String?, needle: String) {
+    assertTrue(stack?.contains(needle) ?: fail())
+}
+
+fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply {
+    Random.nextBytes(this)
+}
+
+private val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.'
+
+fun getRandomString(size: Int = Random.nextInt(1, 255)): String {
+    return (1..size)
+            .map { Random.nextInt(0, charPool.size) }
+            .map(charPool::get)
+            .joinToString("")
+}
+
+fun ByteArray.toHexString(): String {
+    var str = ""
+    for (b in this) {
+        str += String.format("%02X ", b)
+    }
+    return str
+}
+
+fun ByteArray.toIntString(): String {
+    var str = ""
+    for (b in this) {
+        str += String.format("%02d ", b)
+    }
+    return str
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt
new file mode 100644
index 00000000..e606bb67
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt
@@ -0,0 +1,53 @@
+package com.stevesoltys.backup.crypto
+
+import com.stevesoltys.backup.header.HeaderReaderImpl
+import com.stevesoltys.backup.header.HeaderWriterImpl
+import com.stevesoltys.backup.header.IV_SIZE
+import com.stevesoltys.backup.header.MAX_SEGMENT_LENGTH
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.assertArrayEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import javax.crypto.Cipher
+import kotlin.random.Random
+
+@TestInstance(PER_METHOD)
+class CryptoImplTest {
+
+    private val cipherFactory = mockk<CipherFactory>()
+    private val headerWriter = HeaderWriterImpl()
+    private val headerReader = HeaderReaderImpl()
+
+    private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
+
+    private val cipher = mockk<Cipher>()
+
+    private val iv = ByteArray(IV_SIZE).apply { Random.nextBytes(this) }
+    private val cleartext = ByteArray(Random.nextInt(Short.MAX_VALUE.toInt()))
+            .apply { Random.nextBytes(this) }
+    private val ciphertext = ByteArray(Random.nextInt(Short.MAX_VALUE.toInt()))
+            .apply { Random.nextBytes(this) }
+    private val outputStream = ByteArrayOutputStream()
+
+    @Test
+    fun `encrypted cleartext gets decrypted as expected`() {
+        every { cipherFactory.createEncryptionCipher() } returns cipher
+        every { cipher.getOutputSize(cleartext.size) } returns MAX_SEGMENT_LENGTH
+        every { cipher.doFinal(cleartext) } returns ciphertext
+        every { cipher.iv } returns iv
+
+        crypto.encryptSegment(outputStream, cleartext)
+
+        val inputStream = ByteArrayInputStream(outputStream.toByteArray())
+
+        every { cipherFactory.createDecryptionCipher(iv) } returns cipher
+        every { cipher.doFinal(ciphertext) } returns cleartext
+
+        assertArrayEquals(cleartext, crypto.decryptSegment(inputStream))
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt
new file mode 100644
index 00000000..a48d31e8
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt
@@ -0,0 +1,44 @@
+package com.stevesoltys.backup.crypto
+
+import com.stevesoltys.backup.header.HeaderReaderImpl
+import com.stevesoltys.backup.header.HeaderWriterImpl
+import org.junit.jupiter.api.Assertions.assertArrayEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+
+@TestInstance(PER_METHOD)
+class CryptoIntegrationTest {
+
+    private val keyManager = KeyManagerTestImpl()
+    private val cipherFactory = CipherFactoryImpl(keyManager)
+    private val headerWriter = HeaderWriterImpl()
+    private val headerReader = HeaderReaderImpl()
+
+    private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
+
+    private val cleartext = byteArrayOf(0x01, 0x02, 0x03)
+
+    private val outputStream = ByteArrayOutputStream()
+
+    @Test
+    fun `the plain crypto works`() {
+        val eCipher = cipherFactory.createEncryptionCipher()
+        val encrypted = eCipher.doFinal(cleartext)
+
+        val dCipher = cipherFactory.createDecryptionCipher(eCipher.iv)
+        val decrypted = dCipher.doFinal(encrypted)
+
+        assertArrayEquals(cleartext, decrypted)
+    }
+
+    @Test
+    fun `encrypted cleartext gets decrypted as expected`() {
+        crypto.encryptSegment(outputStream, cleartext)
+        val inputStream = ByteArrayInputStream(outputStream.toByteArray())
+        assertArrayEquals(cleartext, crypto.decryptSegment(inputStream))
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt
new file mode 100644
index 00000000..e1d129dd
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt
@@ -0,0 +1,192 @@
+package com.stevesoltys.backup.crypto
+
+import com.stevesoltys.backup.assertContains
+import com.stevesoltys.backup.getRandomByteArray
+import com.stevesoltys.backup.getRandomString
+import com.stevesoltys.backup.header.*
+import io.mockk.*
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
+import java.io.*
+import javax.crypto.Cipher
+import kotlin.random.Random
+
+@TestInstance(PER_METHOD)
+class CryptoTest {
+
+    private val cipherFactory = mockk<CipherFactory>()
+    private val headerWriter = mockk<HeaderWriter>()
+    private val headerReader = mockk<HeaderReader>()
+
+    private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
+
+    private val cipher = mockk<Cipher>()
+
+    private val iv = getRandomByteArray(IV_SIZE)
+    private val cleartext = getRandomByteArray(Random.nextInt(MAX_SEGMENT_LENGTH))
+    private val ciphertext = getRandomByteArray(Random.nextInt(MAX_SEGMENT_LENGTH))
+    private val versionHeader = VersionHeader(VERSION, getRandomString(MAX_PACKAGE_LENGTH_SIZE), getRandomString(MAX_KEY_LENGTH_SIZE))
+    private val versionCiphertext = getRandomByteArray(MAX_VERSION_HEADER_SIZE)
+    private val versionSegmentHeader = SegmentHeader(versionCiphertext.size.toShort(), iv)
+    private val outputStream = ByteArrayOutputStream()
+    private val segmentHeader = SegmentHeader(ciphertext.size.toShort(), iv)
+    // the headerReader will not actually read the header, so only insert cipher text
+    private val inputStream = ByteArrayInputStream(ciphertext)
+    private val versionInputStream = ByteArrayInputStream(versionCiphertext)
+
+    // encrypting
+
+    @Test
+    fun `encrypt header works as expected`() {
+        val segmentHeader = CapturingSlot<SegmentHeader>()
+        every { headerWriter.getEncodedVersionHeader(versionHeader) } returns ciphertext
+        encryptSegmentHeader(ciphertext, segmentHeader)
+
+        crypto.encryptHeader(outputStream, versionHeader)
+        assertArrayEquals(iv, segmentHeader.captured.nonce)
+        assertEquals(ciphertext.size, segmentHeader.captured.segmentLength.toInt())
+    }
+
+    @Test
+    fun `encrypting segment works as expected`() {
+        val segmentHeader = CapturingSlot<SegmentHeader>()
+        encryptSegmentHeader(cleartext, segmentHeader)
+
+        crypto.encryptSegment(outputStream, cleartext)
+
+        assertArrayEquals(ciphertext, outputStream.toByteArray())
+        assertArrayEquals(iv, segmentHeader.captured.nonce)
+        assertEquals(ciphertext.size, segmentHeader.captured.segmentLength.toInt())
+    }
+
+    private fun encryptSegmentHeader(toEncrypt: ByteArray, segmentHeader: CapturingSlot<SegmentHeader>) {
+        every { cipherFactory.createEncryptionCipher() } returns cipher
+        every { cipher.getOutputSize(toEncrypt.size) } returns toEncrypt.size
+        every { cipher.iv } returns iv
+        every { headerWriter.writeSegmentHeader(outputStream, capture(segmentHeader)) } just Runs
+        every { cipher.doFinal(toEncrypt) } returns ciphertext
+    }
+
+    // decrypting
+
+    @Test
+    fun `decrypting header works as expected`() {
+        every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
+        every { cipherFactory.createDecryptionCipher(iv) } returns cipher
+        every { cipher.doFinal(versionCiphertext) } returns cleartext
+        every { headerReader.getVersionHeader(cleartext) } returns versionHeader
+
+        assertEquals(
+                versionHeader,
+                crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, versionHeader.key)
+        )
+    }
+
+    @Test
+    fun `decrypting header throws if too large`() {
+        val size = MAX_VERSION_HEADER_SIZE + 1
+        val versionCiphertext = getRandomByteArray(size)
+        val versionInputStream = ByteArrayInputStream(versionCiphertext)
+        val versionSegmentHeader = SegmentHeader(size.toShort(), iv)
+
+        every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
+
+        val e = assertThrows(SecurityException::class.java) {
+            crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, versionHeader.key)
+        }
+        assertContains(e.message, size.toString())
+    }
+
+    @Test
+    fun `decrypting header throws because of different version`() {
+        every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
+        every { cipherFactory.createDecryptionCipher(iv) } returns cipher
+        every { cipher.doFinal(versionCiphertext) } returns cleartext
+        every { headerReader.getVersionHeader(cleartext) } returns versionHeader
+
+        val version = (VERSION + 1).toByte()
+        val e = assertThrows(SecurityException::class.java) {
+            crypto.decryptHeader(versionInputStream, version, versionHeader.packageName, versionHeader.key)
+        }
+        assertContains(e.message, version.toString())
+    }
+
+    @Test
+    fun `decrypting header throws because of different package name`() {
+        every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
+        every { cipherFactory.createDecryptionCipher(iv) } returns cipher
+        every { cipher.doFinal(versionCiphertext) } returns cleartext
+        every { headerReader.getVersionHeader(cleartext) } returns versionHeader
+
+        val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE)
+        val e = assertThrows(SecurityException::class.java) {
+            crypto.decryptHeader(versionInputStream, versionHeader.version, packageName, versionHeader.key)
+        }
+        assertContains(e.message, packageName)
+    }
+
+    @Test
+    fun `decrypting header throws because of different key`() {
+        every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader
+        every { cipherFactory.createDecryptionCipher(iv) } returns cipher
+        every { cipher.doFinal(versionCiphertext) } returns cleartext
+        every { headerReader.getVersionHeader(cleartext) } returns versionHeader
+
+        val e = assertThrows(SecurityException::class.java) {
+            crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, null)
+        }
+        assertContains(e.message, "null")
+        assertContains(e.message, versionHeader.key ?: fail())
+    }
+
+    @Test
+    fun `decrypting data segment header works as expected`() {
+        every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader
+        every { cipherFactory.createDecryptionCipher(iv) } returns cipher
+        every { cipher.doFinal(ciphertext) } returns cleartext
+
+        assertArrayEquals(cleartext, crypto.decryptSegment(inputStream))
+    }
+
+    @Test
+    fun `decrypting data segment throws if reading 0 bytes`() {
+        val inputStream = mockk<InputStream>()
+        val buffer = ByteArray(segmentHeader.segmentLength.toInt())
+
+        every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader
+        every { inputStream.read(buffer) } returns 0
+
+        assertThrows(IOException::class.java) {
+            crypto.decryptSegment(inputStream)
+        }
+    }
+
+    @Test
+    fun `decrypting data segment throws if reaching end of stream`() {
+        val inputStream = mockk<InputStream>()
+        val buffer = ByteArray(segmentHeader.segmentLength.toInt())
+
+        every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader
+        every { inputStream.read(buffer) } returns -1
+
+        assertThrows(EOFException::class.java) {
+            crypto.decryptSegment(inputStream)
+        }
+    }
+
+    @Test
+    fun `decrypting data segment throws if reading less than expected`() {
+        val inputStream = mockk<InputStream>()
+        val buffer = ByteArray(segmentHeader.segmentLength.toInt())
+
+        every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader
+        every { inputStream.read(buffer) } returns buffer.size - 1
+
+        assertThrows(IOException::class.java) {
+            crypto.decryptSegment(inputStream)
+        }
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt b/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt
new file mode 100644
index 00000000..e9473c89
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt
@@ -0,0 +1,26 @@
+package com.stevesoltys.backup.crypto
+
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+
+class KeyManagerTestImpl : KeyManager {
+
+    private val key by lazy {
+        val keyGenerator = KeyGenerator.getInstance("AES")
+        keyGenerator.init(KEY_SIZE)
+        keyGenerator.generateKey()
+    }
+
+    override fun storeBackupKey(seed: ByteArray) {
+        TODO("not implemented")
+    }
+
+    override fun hasBackupKey(): Boolean {
+        return true
+    }
+
+    override fun getBackupKey(): SecretKey {
+        return key
+    }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt b/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt
new file mode 100644
index 00000000..94b85b6f
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt
@@ -0,0 +1,274 @@
+package com.stevesoltys.backup.header
+
+import com.stevesoltys.backup.assertContains
+import com.stevesoltys.backup.getRandomString
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.nio.ByteBuffer
+import kotlin.random.Random
+
+@TestInstance(PER_CLASS)
+internal class HeaderReaderTest {
+
+    private val reader = HeaderReaderImpl()
+
+    // Version Tests
+
+    @Test
+    fun `valid version is read`() {
+        val input = byteArrayOf(VERSION)
+        val inputStream = ByteArrayInputStream(input)
+
+        assertEquals(VERSION, reader.readVersion(inputStream))
+    }
+
+    @Test
+    fun `too short version stream throws exception`() {
+        val input = ByteArray(0)
+        val inputStream = ByteArrayInputStream(input)
+        assertThrows(IOException::class.javaObjectType) {
+            reader.readVersion(inputStream)
+        }
+    }
+
+    @Test
+    fun `unsupported version throws exception`() {
+        val input = byteArrayOf((VERSION + 1).toByte())
+        val inputStream = ByteArrayInputStream(input)
+        assertThrows(UnsupportedVersionException::class.javaObjectType) {
+            reader.readVersion(inputStream)
+        }
+    }
+
+    @Test
+    fun `negative version throws exception`() {
+        val input = byteArrayOf((-1).toByte())
+        val inputStream = ByteArrayInputStream(input)
+        assertThrows(IOException::class.javaObjectType) {
+            reader.readVersion(inputStream)
+        }
+    }
+
+    @Test
+    fun `max version byte throws exception`() {
+        val input = byteArrayOf(Byte.MAX_VALUE)
+        val inputStream = ByteArrayInputStream(input)
+        assertThrows(UnsupportedVersionException::class.javaObjectType) {
+            reader.readVersion(inputStream)
+        }
+    }
+
+    // VersionHeader Tests
+
+    @Test
+    fun `valid VersionHeader is read`() {
+        val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x01, 0x62)
+
+        val versionHeader = VersionHeader(VERSION, "a", "b")
+        assertEquals(versionHeader, reader.getVersionHeader(input))
+    }
+
+    @Test
+    fun `zero package length in VersionHeader throws`() {
+        val input = byteArrayOf(VERSION, 0x00, 0x00, 0x00, 0x01, 0x62)
+
+        assertThrows(SecurityException::class.javaObjectType) {
+            reader.getVersionHeader(input)
+        }
+    }
+
+    @Test
+    fun `negative package length in VersionHeader throws`() {
+        val input = byteArrayOf(0x00, 0xFF, 0xFF, 0x00, 0x01, 0x62)
+
+        assertThrows(SecurityException::class.javaObjectType) {
+            reader.getVersionHeader(input)
+        }
+    }
+
+    @Test
+    fun `too large package length in VersionHeader throws`() {
+        val size = MAX_PACKAGE_LENGTH_SIZE + 1
+        val input = ByteBuffer.allocate(3 + size)
+                .put(VERSION)
+                .putShort(size.toShort())
+                .put(ByteArray(size))
+                .array()
+        val e = assertThrows(SecurityException::class.javaObjectType) {
+            reader.getVersionHeader(input)
+        }
+        assertContains(e.message, size.toString())
+    }
+
+    @Test
+    fun `insufficient bytes for package in VersionHeader throws`() {
+        val input = byteArrayOf(VERSION, 0x00, 0x50)
+
+        assertThrows(SecurityException::class.javaObjectType) {
+            reader.getVersionHeader(input)
+        }
+    }
+
+    @Test
+    fun `zero key length in VersionHeader gets accepted`() {
+        val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x00)
+
+        val versionHeader = VersionHeader(VERSION, "a", null)
+        assertEquals(versionHeader, reader.getVersionHeader(input))
+    }
+
+    @Test
+    fun `negative key length in VersionHeader throws`() {
+        val input = byteArrayOf(0x00, 0x00, 0x01, 0x61, 0xFF, 0xFF)
+
+        assertThrows(SecurityException::class.javaObjectType) {
+            reader.getVersionHeader(input)
+        }
+    }
+
+    @Test
+    fun `too large key length in VersionHeader throws`() {
+        val size = MAX_KEY_LENGTH_SIZE + 1
+        val input = ByteBuffer.allocate(4 + size)
+                .put(VERSION)
+                .putShort(1.toShort())
+                .put("a".toByteArray(Utf8))
+                .putShort(size.toShort())
+                .array()
+        val e = assertThrows(SecurityException::class.javaObjectType) {
+            reader.getVersionHeader(input)
+        }
+        assertContains(e.message, size.toString())
+    }
+
+    @Test
+    fun `insufficient bytes for key in VersionHeader throws`() {
+        val input = byteArrayOf(0x00, 0x00, 0x01, 0x61, 0x00, 0x50)
+
+        assertThrows(SecurityException::class.javaObjectType) {
+            reader.getVersionHeader(input)
+        }
+    }
+
+    @Test
+    fun `extra bytes in VersionHeader throws`() {
+        val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x01, 0x62, 0x00)
+
+        assertThrows(SecurityException::class.javaObjectType) {
+            reader.getVersionHeader(input)
+        }
+    }
+
+    @Test
+    fun `max sized VersionHeader gets accepted`() {
+        val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE)
+        val key = getRandomString(MAX_KEY_LENGTH_SIZE)
+        val input = ByteBuffer.allocate(MAX_VERSION_HEADER_SIZE)
+                .put(VERSION)
+                .putShort(MAX_PACKAGE_LENGTH_SIZE.toShort())
+                .put(packageName.toByteArray(Utf8))
+                .putShort(MAX_KEY_LENGTH_SIZE.toShort())
+                .put(key.toByteArray(Utf8))
+                .array()
+        assertEquals(MAX_VERSION_HEADER_SIZE, input.size)
+        val h = reader.getVersionHeader(input)
+        assertEquals(VERSION, h.version)
+        assertEquals(packageName, h.packageName)
+        assertEquals(key, h.key)
+    }
+
+    // SegmentHeader Tests
+
+    @Test
+    fun `too short SegmentHeader throws exception`() {
+        val input = byteArrayOf(0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
+        val inputStream = ByteArrayInputStream(input)
+        assertThrows(IOException::class.javaObjectType) {
+            reader.readSegmentHeader(inputStream)
+        }
+    }
+
+    @Test
+    fun `segment length of zero is rejected`() {
+        val input = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
+        val inputStream = ByteArrayInputStream(input)
+        assertThrows(IOException::class.javaObjectType) {
+            reader.readSegmentHeader(inputStream)
+        }
+    }
+
+    @Test
+    fun `negative segment length is rejected`() {
+        val input = byteArrayOf(0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
+        val inputStream = ByteArrayInputStream(input)
+        assertThrows(IOException::class.javaObjectType) {
+            reader.readSegmentHeader(inputStream)
+        }
+    }
+
+    @Test
+    fun `minimum negative segment length is rejected`() {
+        val input = byteArrayOf(0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
+        val inputStream = ByteArrayInputStream(input)
+        assertThrows(IOException::class.javaObjectType) {
+            reader.readSegmentHeader(inputStream)
+        }
+    }
+
+    @Test
+    fun `max segment length is accepted`() {
+        val input = byteArrayOf(0x7F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
+        val inputStream = ByteArrayInputStream(input)
+        assertEquals(MAX_SEGMENT_LENGTH, reader.readSegmentHeader(inputStream).segmentLength.toInt())
+    }
+
+    @Test
+    fun `min segment length of 1 is accepted`() {
+        val input = byteArrayOf(0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
+        val inputStream = ByteArrayInputStream(input)
+        assertEquals(1, reader.readSegmentHeader(inputStream).segmentLength.toInt())
+    }
+
+    @Test
+    fun `segment length is always read correctly`() {
+        val segmentLength = getRandomValidSegmentLength()
+        val input = ByteBuffer.allocate(SEGMENT_HEADER_SIZE)
+                .putShort(segmentLength)
+                .put(ByteArray(IV_SIZE))
+                .array()
+        val inputStream = ByteArrayInputStream(input)
+        assertEquals(segmentLength, reader.readSegmentHeader(inputStream).segmentLength)
+    }
+
+    @Test
+    fun `nonce is read in big endian`() {
+        val nonce = byteArrayOf(0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01)
+        val input = byteArrayOf(0x00, 0x01, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01)
+        val inputStream = ByteArrayInputStream(input)
+        assertArrayEquals(nonce, reader.readSegmentHeader(inputStream).nonce)
+    }
+
+    @Test
+    fun `nonce is always read correctly`() {
+        val nonce = ByteArray(IV_SIZE).apply { Random.nextBytes(this) }
+        val input = ByteBuffer.allocate(SEGMENT_HEADER_SIZE)
+                .putShort(1)
+                .put(nonce)
+                .array()
+        val inputStream = ByteArrayInputStream(input)
+        assertArrayEquals(nonce, reader.readSegmentHeader(inputStream).nonce)
+    }
+
+    private fun byteArrayOf(vararg elements: Int): ByteArray {
+        return elements.map { it.toByte() }.toByteArray()
+    }
+
+}
+
+internal fun getRandomValidSegmentLength(): Short {
+    return Random.nextInt(1, Short.MAX_VALUE.toInt()).toShort()
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt b/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt
new file mode 100644
index 00000000..a1df050b
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt
@@ -0,0 +1,102 @@
+package com.stevesoltys.backup.header
+
+import com.stevesoltys.backup.getRandomByteArray
+import com.stevesoltys.backup.getRandomString
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import kotlin.random.Random
+
+@TestInstance(PER_CLASS)
+internal class HeaderWriterReaderTest {
+
+    private val writer = HeaderWriterImpl()
+    private val reader = HeaderReaderImpl()
+
+    private val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE)
+    private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
+    private val versionHeader = VersionHeader(VERSION, packageName, key)
+    private val unsupportedVersionHeader = VersionHeader((VERSION + 1).toByte(), packageName)
+
+    private val segmentLength = getRandomValidSegmentLength()
+    private val nonce = getRandomByteArray(IV_SIZE)
+    private val segmentHeader = SegmentHeader(segmentLength, nonce)
+
+    @Test
+    fun `written version matches read input`() {
+        assertEquals(versionHeader.version, readWriteVersion(versionHeader))
+    }
+
+    @Test
+    fun `reading unsupported version throws exception`() {
+        assertThrows(UnsupportedVersionException::class.javaObjectType) {
+            readWriteVersion(unsupportedVersionHeader)
+        }
+    }
+
+    @Test
+    fun `VersionHeader output matches read input`() {
+        assertEquals(versionHeader, readWrite(versionHeader))
+    }
+
+    @Test
+    fun `VersionHeader with no key output matches read input`() {
+        val versionHeader = VersionHeader(VERSION, packageName, null)
+        assertEquals(versionHeader, readWrite(versionHeader))
+    }
+
+    @Test
+    fun `VersionHeader with empty package name throws`() {
+        val versionHeader = VersionHeader(VERSION, "")
+        assertThrows(SecurityException::class.java) {
+            readWrite(versionHeader)
+        }
+    }
+
+    @Test
+    fun `SegmentHeader constructor needs right IV size`() {
+        val nonceTooBig = ByteArray(IV_SIZE + 1).apply { Random.nextBytes(this) }
+        assertThrows(IllegalStateException::class.javaObjectType) {
+            SegmentHeader(segmentLength, nonceTooBig)
+        }
+        val nonceTooSmall = ByteArray(IV_SIZE - 1).apply { Random.nextBytes(this) }
+        assertThrows(IllegalStateException::class.javaObjectType) {
+            SegmentHeader(segmentLength, nonceTooSmall)
+        }
+    }
+
+    @Test
+    fun `SegmentHeader output matches read input`() {
+        assertEquals(segmentHeader, readWriteVersion(segmentHeader))
+    }
+
+    private fun readWriteVersion(header: VersionHeader): Byte {
+        val outputStream = ByteArrayOutputStream()
+        writer.writeVersion(outputStream, header)
+        val written = outputStream.toByteArray()
+        val inputStream = ByteArrayInputStream(written)
+        return reader.readVersion(inputStream)
+    }
+
+    private fun readWrite(header: VersionHeader): VersionHeader {
+        val written = writer.getEncodedVersionHeader(header)
+        return reader.getVersionHeader(written)
+    }
+
+    private fun readWriteVersion(header: SegmentHeader): SegmentHeader {
+        val outputStream = ByteArrayOutputStream()
+        writer.writeSegmentHeader(outputStream, header)
+        val written = outputStream.toByteArray()
+        val inputStream = ByteArrayInputStream(written)
+        return reader.readSegmentHeader(inputStream)
+    }
+
+    private fun assertEquals(expected: SegmentHeader, actual: SegmentHeader) {
+        assertEquals(expected.segmentLength, actual.segmentLength)
+        assertArrayEquals(expected.nonce, actual.nonce)
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
new file mode 100644
index 00000000..9622568c
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
@@ -0,0 +1,162 @@
+package com.stevesoltys.backup.transport
+
+import android.app.backup.BackupDataInput
+import android.app.backup.BackupDataOutput
+import android.app.backup.BackupTransport.NO_MORE_DATA
+import android.app.backup.BackupTransport.TRANSPORT_OK
+import android.app.backup.RestoreDescription
+import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
+import android.os.ParcelFileDescriptor
+import com.stevesoltys.backup.crypto.CipherFactoryImpl
+import com.stevesoltys.backup.crypto.CryptoImpl
+import com.stevesoltys.backup.crypto.KeyManagerTestImpl
+import com.stevesoltys.backup.transport.backup.*
+import com.stevesoltys.backup.header.HeaderReaderImpl
+import com.stevesoltys.backup.header.HeaderWriterImpl
+import com.stevesoltys.backup.header.Utf8
+import com.stevesoltys.backup.transport.restore.*
+import io.mockk.*
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.util.*
+import kotlin.random.Random
+
+internal class CoordinatorIntegrationTest : TransportTest() {
+
+    private val inputFactory = mockk<InputFactory>()
+    private val outputFactory = mockk<OutputFactory>()
+    private val keyManager = KeyManagerTestImpl()
+    private val cipherFactory = CipherFactoryImpl(keyManager)
+    private val headerWriter = HeaderWriterImpl()
+    private val headerReader = HeaderReaderImpl()
+    private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
+
+    private val backupPlugin = mockk<BackupPlugin>()
+    private val kvBackupPlugin = mockk<KVBackupPlugin>()
+    private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl)
+    private val fullBackupPlugin = mockk<FullBackupPlugin>()
+    private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
+    private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup)
+
+    private val restorePlugin = mockk<RestorePlugin>()
+    private val kvRestorePlugin = mockk<KVRestorePlugin>()
+    private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
+    private val fullRestorePlugin = mockk<FullRestorePlugin>()
+    private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
+    private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
+
+    private val backupDataInput = mockk<BackupDataInput>()
+    private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
+    private val token = DEFAULT_RESTORE_SET_TOKEN
+    private val appData = ByteArray(42).apply { Random.nextBytes(this) }
+    private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
+    private val key = "RestoreKey"
+    private val key64 = Base64.getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8))
+    private val key2 = "RestoreKey2"
+    private val key264 = Base64.getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8))
+
+    init {
+        every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin
+        every { backupPlugin.fullBackupPlugin } returns fullBackupPlugin
+    }
+
+    @Test
+    fun `test key-value backup and restore with 2 records`() {
+        val value = CapturingSlot<ByteArray>()
+        val value2 = CapturingSlot<ByteArray>()
+        val bOutputStream = ByteArrayOutputStream()
+        val bOutputStream2 = ByteArrayOutputStream()
+
+        // read one key/value record and write it to output stream
+        every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
+        every { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs
+        every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
+        every { backupDataInput.readNextHeader() } returns true andThen true andThen false
+        every { backupDataInput.key } returns key andThen key2
+        every { backupDataInput.dataSize } returns appData.size andThen appData2.size
+        every { backupDataInput.readEntityData(capture(value), 0, appData.size) } answers {
+            appData.copyInto(value.captured) // write the app data into the passed ByteArray
+            appData.size
+        }
+        every { 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
+        }
+        every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
+
+        // start and finish K/V backup
+        assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+
+        // start restore
+        assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
+
+        // find data for K/V backup
+        every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
+
+        val restoreDescription = restore.nextRestorePackage() ?: fail()
+        assertEquals(packageInfo.packageName, restoreDescription.packageName)
+        assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType)
+
+        // restore finds the backed up key and writes the decrypted value
+        val backupDataOutput = mockk<BackupDataOutput>()
+        val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
+        val rInputStream2 = ByteArrayInputStream(bOutputStream2.toByteArray())
+        every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264)
+        every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
+        every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key64) } returns rInputStream
+        every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137
+        every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size
+        every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key264) } returns rInputStream2
+        every { backupDataOutput.writeEntityHeader(key2, appData2.size) } returns 1137
+        every { backupDataOutput.writeEntityData(appData2, appData2.size) } returns appData2.size
+
+        assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
+    }
+
+    @Test
+    fun `test full backup and restore with two chunks`() {
+        // return streams from plugin and app data
+        val bOutputStream = ByteArrayOutputStream()
+        val bInputStream = ByteArrayInputStream(appData)
+        every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
+        every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
+        every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
+
+        // perform backup to output stream
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
+        assertEquals(TRANSPORT_OK, backup.sendBackupData(appData.size / 2))
+        assertEquals(TRANSPORT_OK, backup.sendBackupData(appData.size / 2))
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+
+        // start restore
+        assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
+
+        // find data only for full backup
+        every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns false
+        every { fullRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
+
+        val restoreDescription = restore.nextRestorePackage() ?: fail()
+        assertEquals(packageInfo.packageName, restoreDescription.packageName)
+        assertEquals(TYPE_FULL_STREAM, restoreDescription.dataType)
+
+        // reverse the backup streams into restore input
+        val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
+        val rOutputStream = ByteArrayOutputStream()
+        every { fullRestorePlugin.getInputStreamForPackage(token, packageInfo) } returns rInputStream
+        every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream
+
+        // restore data
+        assertEquals(appData.size / 2, restore.getNextFullRestoreDataChunk(fileDescriptor))
+        assertEquals(appData.size / 2, restore.getNextFullRestoreDataChunk(fileDescriptor))
+        assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor))
+        restore.finishRestore()
+
+        // assert that restored data matches original app data
+        assertArrayEquals(appData, rOutputStream.toByteArray())
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt
new file mode 100644
index 00000000..6c425339
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt
@@ -0,0 +1,30 @@
+package com.stevesoltys.backup.transport
+
+import android.content.pm.PackageInfo
+import android.util.Log
+import com.stevesoltys.backup.crypto.Crypto
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
+
+@TestInstance(PER_METHOD)
+abstract class TransportTest {
+
+    protected val crypto = mockk<Crypto>()
+
+    protected val packageInfo = PackageInfo().apply { packageName = "org.example" }
+
+    init {
+        mockkStatic(Log::class)
+        every { Log.v(any(), any()) } returns 0
+        every { Log.d(any(), any()) } returns 0
+        every { Log.i(any(), any()) } returns 0
+        every { Log.w(any(), ofType(String::class)) } returns 0
+        every { Log.w(any(), ofType(String::class), any()) } returns 0
+        every { Log.e(any(), any()) } returns 0
+        every { Log.e(any(), any(), any()) } returns 0
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
new file mode 100644
index 00000000..e5833277
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
@@ -0,0 +1,110 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.app.backup.BackupTransport.TRANSPORT_ERROR
+import android.app.backup.BackupTransport.TRANSPORT_OK
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+import java.io.IOException
+import kotlin.random.Random
+
+internal class BackupCoordinatorTest: BackupTest() {
+
+    private val plugin = mockk<BackupPlugin>()
+    private val kv = mockk<KVBackup>()
+    private val full = mockk<FullBackup>()
+
+    private val backup = BackupCoordinator(plugin, kv, full)
+
+    @Test
+    fun `device initialization succeeds and delegates to plugin`() {
+        every { plugin.initializeDevice() } just Runs
+        every { kv.hasState() } returns false
+        every { full.hasState() } returns false
+
+        assertEquals(TRANSPORT_OK, backup.initializeDevice())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+    }
+
+    @Test
+    fun `device initialization fails`() {
+        every { plugin.initializeDevice() } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
+
+        // finish will only be called when TRANSPORT_OK is returned, so it should throw
+        every { kv.hasState() } returns false
+        every { full.hasState() } returns false
+        assertThrows(IllegalStateException::class.java) {
+            backup.finishBackup()
+        }
+    }
+
+    @Test
+    fun `getBackupQuota() delegates to right plugin`() {
+        val isFullBackup = Random.nextBoolean()
+        val quota = Random.nextLong()
+
+        if (isFullBackup) {
+            every { full.getQuota() } returns quota
+        } else {
+            every { kv.getQuota() } returns quota
+        }
+        assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup))
+    }
+
+    @Test
+    fun `clearing KV backup data throws`() {
+        every { kv.clearBackupData(packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
+    }
+
+    @Test
+    fun `clearing full backup data throws`() {
+        every { kv.clearBackupData(packageInfo) } just Runs
+        every { full.clearBackupData(packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
+    }
+
+    @Test
+    fun `clearing backup data succeeds`() {
+        every { kv.clearBackupData(packageInfo) } just Runs
+        every { full.clearBackupData(packageInfo) } just Runs
+
+        assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo))
+
+        every { kv.hasState() } returns false
+        every { full.hasState() } returns false
+
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+    }
+
+    @Test
+    fun `finish backup delegates to KV plugin if it has state`() {
+        val result = Random.nextInt()
+
+        every { kv.hasState() } returns true
+        every { full.hasState() } returns false
+        every { kv.finishBackup() } returns result
+
+        assertEquals(result, backup.finishBackup())
+    }
+
+    @Test
+    fun `finish backup delegates to full plugin if it has state`() {
+        val result = Random.nextInt()
+
+        every { kv.hasState() } returns false
+        every { full.hasState() } returns true
+        every { full.finishBackup() } returns result
+
+        assertEquals(result, backup.finishBackup())
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt
new file mode 100644
index 00000000..df96b38e
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt
@@ -0,0 +1,20 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.os.ParcelFileDescriptor
+import com.stevesoltys.backup.transport.TransportTest
+import com.stevesoltys.backup.header.HeaderWriter
+import com.stevesoltys.backup.header.VersionHeader
+import io.mockk.mockk
+import java.io.OutputStream
+
+internal abstract class BackupTest : TransportTest() {
+
+    protected val inputFactory = mockk<InputFactory>()
+    protected val headerWriter = mockk<HeaderWriter>()
+    protected val data = mockk<ParcelFileDescriptor>()
+    protected val outputStream = mockk<OutputStream>()
+
+    protected val header = VersionHeader(packageName = packageInfo.packageName)
+    protected val quota = 42L
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt
new file mode 100644
index 00000000..b6f1972e
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt
@@ -0,0 +1,276 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.app.backup.BackupTransport.*
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.io.FileInputStream
+import java.io.IOException
+import kotlin.random.Random
+
+internal class FullBackupTest : BackupTest() {
+
+    private val plugin = mockk<FullBackupPlugin>()
+    private val backup = FullBackup(plugin, inputFactory, headerWriter, crypto)
+
+    private val bytes = ByteArray(23).apply { Random.nextBytes(this) }
+    private val closeBytes = ByteArray(42).apply { Random.nextBytes(this) }
+    private val inputStream = mockk<FileInputStream>()
+
+    @Test
+    fun `now is a good time for a backup`() {
+        assertEquals(0, backup.requestFullBackupTime())
+    }
+
+    @Test
+    fun `has no initial state`() {
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `checkFullBackupSize exceeds quota`() {
+        every { plugin.getQuota() } returns quota
+
+        assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.checkFullBackupSize(quota + 1))
+    }
+
+    @Test
+    fun `checkFullBackupSize for no data`() {
+        assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(0))
+    }
+
+    @Test
+    fun `checkFullBackupSize for negative data`() {
+        assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(-1))
+    }
+
+    @Test
+    fun `checkFullBackupSize accepts min data`() {
+        every { plugin.getQuota() } returns quota
+
+        assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(1))
+    }
+
+    @Test
+    fun `checkFullBackupSize accepts max data`() {
+        every { plugin.getQuota() } returns quota
+
+        assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(quota))
+    }
+
+    @Test
+    fun `performFullBackup throws exception when getting outputStream`() {
+        every { plugin.getOutputStream(packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performFullBackup(packageInfo, data))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `performFullBackup throws exception when writing header`() {
+        every { plugin.getOutputStream(packageInfo) } returns outputStream
+        every { inputFactory.getInputStream(data) } returns inputStream
+        every { headerWriter.writeVersion(outputStream, header) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performFullBackup(packageInfo, data))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `performFullBackup runs ok`() {
+        expectPerformFullBackup()
+        expectClearState()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `sendBackupData first call over quota`() {
+        expectPerformFullBackup()
+        val numBytes = (quota + 1).toInt()
+        expectSendData(numBytes)
+        expectClearState()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `sendBackupData second call over quota`() {
+        expectPerformFullBackup()
+        val numBytes1 = quota.toInt()
+        expectSendData(numBytes1)
+        val numBytes2 = 1
+        expectSendData(numBytes2)
+        expectClearState()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes2))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `sendBackupData throws exception when reading from InputStream`() {
+        expectPerformFullBackup()
+        every { plugin.getQuota() } returns quota
+        every { inputStream.read(any(), any(), bytes.size) } throws IOException()
+        expectClearState()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `sendBackupData throws exception when writing encrypted data to OutputStream`() {
+        expectPerformFullBackup()
+        every { plugin.getQuota() } returns quota
+        every { inputStream.read(any(), any(), bytes.size) } returns bytes.size
+        every { crypto.encryptSegment(outputStream, any()) } throws IOException()
+        expectClearState()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `sendBackupData runs ok`() {
+        expectPerformFullBackup()
+        val numBytes1 = (quota / 2).toInt()
+        expectSendData(numBytes1)
+        val numBytes2 = (quota / 2).toInt()
+        expectSendData(numBytes2)
+        expectClearState()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes2))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `cancel full backup runs ok`() {
+        expectPerformFullBackup()
+        expectClearState()
+        every { plugin.cancelFullBackup(packageInfo) } just Runs
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        backup.cancelFullBackup()
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `cancel full backup ignores exception when calling plugin`() {
+        expectPerformFullBackup()
+        expectClearState()
+        every { plugin.cancelFullBackup(packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        backup.cancelFullBackup()
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `clearState throws exception when flushing OutputStream`() {
+        expectPerformFullBackup()
+        every { outputStream.write(closeBytes) } just Runs
+        every { outputStream.flush() } throws IOException()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_ERROR, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `clearState ignores exception when closing OutputStream`() {
+        expectPerformFullBackup()
+        every { outputStream.flush() } just Runs
+        every { outputStream.close() } throws IOException()
+        every { inputStream.close() } just Runs
+        every { data.close() } just Runs
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `clearState ignores exception when closing InputStream`() {
+        expectPerformFullBackup()
+        every { outputStream.flush() } just Runs
+        every { outputStream.close() } just Runs
+        every { inputStream.close() } throws IOException()
+        every { data.close() } just Runs
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `clearState ignores exception when closing ParcelFileDescriptor`() {
+        expectPerformFullBackup()
+        every { outputStream.flush() } just Runs
+        every { outputStream.close() } just Runs
+        every { inputStream.close() } just Runs
+        every { data.close() } throws IOException()
+
+        assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    private fun expectPerformFullBackup() {
+        every { plugin.getOutputStream(packageInfo) } returns outputStream
+        every { inputFactory.getInputStream(data) } returns inputStream
+        every { headerWriter.writeVersion(outputStream, header) } just Runs
+        every { crypto.encryptHeader(outputStream, header) } just Runs
+    }
+
+    private fun expectSendData(numBytes: Int, readBytes: Int = numBytes) {
+        every { plugin.getQuota() } returns quota
+        every { inputStream.read(any(), any(), numBytes) } returns readBytes
+        every { crypto.encryptSegment(outputStream, any()) } just Runs
+    }
+
+    private fun expectClearState() {
+        every { outputStream.write(closeBytes) } just Runs
+        every { outputStream.flush() } just Runs
+        every { outputStream.close() } just Runs
+        every { inputStream.close() } just Runs
+        every { data.close() } just Runs
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt
new file mode 100644
index 00000000..5362415b
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt
@@ -0,0 +1,216 @@
+package com.stevesoltys.backup.transport.backup
+
+import android.app.backup.BackupDataInput
+import android.app.backup.BackupTransport.*
+import com.stevesoltys.backup.getRandomString
+import com.stevesoltys.backup.header.MAX_KEY_LENGTH_SIZE
+import com.stevesoltys.backup.header.Utf8
+import com.stevesoltys.backup.header.VersionHeader
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.io.IOException
+import java.util.*
+import kotlin.random.Random
+
+internal class KVBackupTest : BackupTest() {
+
+    private val plugin = mockk<KVBackupPlugin>()
+    private val dataInput = mockk<BackupDataInput>()
+
+    private val backup = KVBackup(plugin, inputFactory, headerWriter, crypto)
+
+    private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
+    private val key64 = Base64.getEncoder().encodeToString(key.toByteArray(Utf8))
+    private val value = ByteArray(23).apply { Random.nextBytes(this) }
+    private val versionHeader = VersionHeader(packageName = packageInfo.packageName, key = key)
+
+    @Test
+    fun `now is a good time for a backup`() {
+        assertEquals(0, backup.requestBackupTime())
+    }
+
+    @Test
+    fun `has no initial state`() {
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `simple backup with one record`() {
+        singleRecordBackup()
+
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `incremental backup with no data gets rejected`() {
+        every { plugin.hasDataForPackage(packageInfo) } returns false
+
+        assertEquals(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, backup.performBackup(packageInfo, data, FLAG_INCREMENTAL))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `check for existing data throws exception`() {
+        every { plugin.hasDataForPackage(packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `non-incremental backup with data clears old data first`() {
+        singleRecordBackup(true)
+        every { plugin.removeDataOfPackage(packageInfo) } just Runs
+
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `ignoring exception when clearing data when non-incremental backup has data`() {
+        singleRecordBackup(true)
+        every { plugin.removeDataOfPackage(packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `ensuring storage throws exception`() {
+        every { plugin.hasDataForPackage(packageInfo) } returns false
+        every { plugin.ensureRecordStorageForPackage(packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `exception while reading next header`() {
+        initPlugin(false)
+        createBackupDataInput()
+        every { dataInput.readNextHeader() } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `exception while reading value`() {
+        initPlugin(false)
+        createBackupDataInput()
+        every { dataInput.readNextHeader() } returns true
+        every { dataInput.key } returns key
+        every { dataInput.dataSize } returns value.size
+        every { dataInput.readEntityData(any(), 0, value.size) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `no data records`() {
+        initPlugin(false)
+        getDataInput(listOf(false))
+
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `exception while writing version header`() {
+        initPlugin(false)
+        getDataInput(listOf(true))
+        every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
+        every { headerWriter.writeVersion(outputStream, versionHeader) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `exception while writing encrypted value to output stream`() {
+        initPlugin(false)
+        getDataInput(listOf(true))
+        writeHeaderAndEncrypt()
+        every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
+        every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
+        every { crypto.encryptSegment(outputStream, any()) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `exception while flushing output stream`() {
+        initPlugin(false)
+        getDataInput(listOf(true))
+        writeHeaderAndEncrypt()
+        every { outputStream.write(value) } just Runs
+        every { outputStream.flush() } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
+        assertFalse(backup.hasState())
+    }
+
+    @Test
+    fun `ignoring exception while closing output stream`() {
+        initPlugin(false)
+        getDataInput(listOf(true, false))
+        writeHeaderAndEncrypt()
+        every { outputStream.write(value) } just Runs
+        every { outputStream.flush() } just Runs
+        every { outputStream.close() } throws IOException()
+
+        assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
+        assertTrue(backup.hasState())
+        assertEquals(TRANSPORT_OK, backup.finishBackup())
+        assertFalse(backup.hasState())
+    }
+
+    private fun singleRecordBackup(hasDataForPackage: Boolean = false) {
+        initPlugin(hasDataForPackage)
+        getDataInput(listOf(true, false))
+        writeHeaderAndEncrypt()
+        every { outputStream.write(value) } just Runs
+        every { outputStream.flush() } just Runs
+        every { outputStream.close() } just Runs
+    }
+
+    private fun initPlugin(hasDataForPackage: Boolean = false) {
+        every { plugin.hasDataForPackage(packageInfo) } returns hasDataForPackage
+        every { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs
+    }
+
+    private fun createBackupDataInput() {
+        every { inputFactory.getBackupDataInput(data) } returns dataInput
+    }
+
+    private fun getDataInput(returnValues: List<Boolean>) {
+        createBackupDataInput()
+        every { dataInput.readNextHeader() } returnsMany returnValues
+        every { dataInput.key } returns key
+        every { dataInput.dataSize } returns value.size
+        every { dataInput.readEntityData(any(), 0, value.size) } returns value.size
+    }
+
+    private fun writeHeaderAndEncrypt() {
+        every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
+        every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
+        every { crypto.encryptHeader(outputStream, versionHeader) } just Runs
+        every { crypto.encryptSegment(outputStream, any()) } just Runs
+    }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt
new file mode 100644
index 00000000..fd6ee47d
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt
@@ -0,0 +1,173 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.app.backup.BackupTransport.*
+import com.stevesoltys.backup.getRandomByteArray
+import com.stevesoltys.backup.header.UnsupportedVersionException
+import com.stevesoltys.backup.header.VERSION
+import com.stevesoltys.backup.header.VersionHeader
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.io.ByteArrayOutputStream
+import java.io.EOFException
+import java.io.IOException
+import kotlin.random.Random
+
+internal class FullRestoreTest : RestoreTest() {
+
+    private val plugin = mockk<FullRestorePlugin>()
+    private val restore = FullRestore(plugin, outputFactory, headerReader, crypto)
+
+    private val encrypted = getRandomByteArray()
+    private val outputStream = ByteArrayOutputStream()
+    private val versionHeader = VersionHeader(VERSION, packageInfo.packageName)
+
+    @Test
+    fun `has no initial state`() {
+        assertFalse(restore.hasState())
+    }
+
+    @Test
+    fun `hasDataForPackage() delegates to plugin`() {
+        val result = Random.nextBoolean()
+        every { plugin.hasDataForPackage(token, packageInfo) } returns result
+        assertEquals(result, restore.hasDataForPackage(token, packageInfo))
+    }
+
+    @Test
+    fun `initializing state leaves a state`() {
+        assertFalse(restore.hasState())
+        restore.initializeState(token, packageInfo)
+        assertTrue(restore.hasState())
+    }
+
+    @Test
+    fun `getting chunks without initializing state throws`() {
+        assertFalse(restore.hasState())
+        assertThrows(IllegalStateException::class.java) {
+            restore.getNextFullRestoreDataChunk(fileDescriptor)
+        }
+    }
+
+    @Test
+    fun `getting InputStream for package when getting first chunk throws`() {
+        restore.initializeState(token, packageInfo)
+
+        every { plugin.getInputStreamForPackage(token, packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
+    }
+
+    @Test
+    fun `reading version header when getting first chunk throws`() {
+        restore.initializeState(token, packageInfo)
+
+        every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
+        every { headerReader.readVersion(inputStream) } throws IOException()
+
+        assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
+    }
+
+    @Test
+    fun `reading unsupported version when getting first chunk`() {
+        restore.initializeState(token, packageInfo)
+
+        every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
+        every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion)
+
+        assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
+    }
+
+    @Test
+    fun `decrypting version header when getting first chunk throws`() {
+        restore.initializeState(token, packageInfo)
+
+        every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws IOException()
+
+        assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
+    }
+
+    @Test
+    fun `decrypting version header when getting first chunk throws security exception`() {
+        restore.initializeState(token, packageInfo)
+
+        every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws SecurityException()
+
+        assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor))
+    }
+
+    @Test
+    fun `decrypting segment throws IOException`() {
+        restore.initializeState(token, packageInfo)
+
+        initInputStream()
+        every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
+        every { crypto.decryptSegment(inputStream) } throws IOException()
+        every { inputStream.close() } just Runs
+        every { fileDescriptor.close() } just Runs
+
+        assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
+    }
+
+    @Test
+    fun `decrypting segment throws EOFException`() {
+        restore.initializeState(token, packageInfo)
+
+        initInputStream()
+        every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
+        every { crypto.decryptSegment(inputStream) } throws EOFException()
+        every { inputStream.close() } just Runs
+        every { fileDescriptor.close() } just Runs
+
+        assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor))
+    }
+
+    @Test
+    fun `full chunk gets encrypted`() {
+        restore.initializeState(token, packageInfo)
+
+        initInputStream()
+        readAndEncryptInputStream(encrypted)
+        every { inputStream.close() } just Runs
+
+        assertEquals(encrypted.size, restore.getNextFullRestoreDataChunk(fileDescriptor))
+        assertArrayEquals(encrypted, outputStream.toByteArray())
+        restore.finishRestore()
+        assertFalse(restore.hasState())
+    }
+
+    @Test
+    fun `aborting full restore closes stream, resets state`() {
+        restore.initializeState(token, packageInfo)
+
+        initInputStream()
+        readAndEncryptInputStream(encrypted)
+
+        restore.getNextFullRestoreDataChunk(fileDescriptor)
+
+        every { inputStream.close() } just Runs
+
+        assertEquals(TRANSPORT_OK, restore.abortFullRestore())
+        assertFalse(restore.hasState())
+    }
+
+    private fun initInputStream() {
+        every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } returns versionHeader
+    }
+
+    private fun readAndEncryptInputStream(encryptedBytes: ByteArray) {
+        every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream
+        every { crypto.decryptSegment(inputStream) } returns encryptedBytes
+        every { fileDescriptor.close() } just Runs
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt
new file mode 100644
index 00000000..f0237ce8
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt
@@ -0,0 +1,221 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.app.backup.BackupDataOutput
+import android.app.backup.BackupTransport.TRANSPORT_ERROR
+import android.app.backup.BackupTransport.TRANSPORT_OK
+import com.stevesoltys.backup.getRandomByteArray
+import com.stevesoltys.backup.header.UnsupportedVersionException
+import com.stevesoltys.backup.header.Utf8
+import com.stevesoltys.backup.header.VERSION
+import com.stevesoltys.backup.header.VersionHeader
+import io.mockk.*
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+import java.io.IOException
+import java.io.InputStream
+import java.util.Base64.getUrlEncoder
+import kotlin.random.Random
+
+internal class KVRestoreTest : RestoreTest() {
+
+    private val plugin = mockk<KVRestorePlugin>()
+    private val output = mockk<BackupDataOutput>()
+    private val restore = KVRestore(plugin, outputFactory, headerReader, crypto)
+
+    private val key = "Restore Key"
+    private val key64 = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8))
+    private val versionHeader = VersionHeader(VERSION, packageInfo.packageName, key)
+    private val key2 = "Restore Key2"
+    private val key264 = getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8))
+    private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2)
+
+    @Test
+    fun `hasDataForPackage() delegates to plugin`() {
+        val result = Random.nextBoolean()
+
+        every { plugin.hasDataForPackage(token, packageInfo) } returns result
+
+        assertEquals(result, restore.hasDataForPackage(token, packageInfo))
+    }
+
+    @Test
+    fun `getRestoreData() throws without initializing state`() {
+        assertThrows(IllegalStateException::class.java) {
+            restore.getRestoreData(fileDescriptor)
+        }
+    }
+
+    @Test
+    fun `listing records throws`() {
+        restore.initializeState(token, packageInfo)
+
+        every { plugin.listRecords(token, packageInfo) } throws IOException()
+
+        assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
+    }
+
+    @Test
+    fun `reading VersionHeader with unsupported version throws`() {
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput()
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion)
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
+        verifyStreamWasClosed()
+    }
+
+    @Test
+    fun `error reading VersionHeader throws`() {
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput()
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } throws IOException()
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
+        verifyStreamWasClosed()
+    }
+
+    @Test
+    fun `decrypting segment throws`() {
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput()
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
+        every { crypto.decryptSegment(inputStream) } throws IOException()
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
+        verifyStreamWasClosed()
+    }
+
+    @Test
+    fun `decrypting header throws`() {
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput()
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws IOException()
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
+        verifyStreamWasClosed()
+    }
+
+    @Test
+    fun `decrypting header throws security exception`() {
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput()
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws SecurityException()
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
+        verifyStreamWasClosed()
+    }
+
+    @Test
+    fun `writing header throws`() {
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput()
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
+        every { crypto.decryptSegment(inputStream) } returns data
+        every { output.writeEntityHeader(key, data.size) } throws IOException()
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
+        verifyStreamWasClosed()
+    }
+
+    @Test
+    fun `writing value throws`() {
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput()
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
+        every { crypto.decryptSegment(inputStream) } returns data
+        every { output.writeEntityHeader(key, data.size) } returns 42
+        every { output.writeEntityData(data, data.size) } throws IOException()
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
+        verifyStreamWasClosed()
+    }
+
+    @Test
+    fun `writing value succeeds`() {
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput()
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
+        every { crypto.decryptSegment(inputStream) } returns data
+        every { output.writeEntityHeader(key, data.size) } returns 42
+        every { output.writeEntityData(data, data.size) } returns data.size
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
+        verifyStreamWasClosed()
+    }
+
+    @Test
+    fun `writing two values succeeds`() {
+        val data2 = getRandomByteArray()
+        val inputStream2 = mockk<InputStream>()
+        restore.initializeState(token, packageInfo)
+
+        getRecordsAndOutput(listOf(key64, key264))
+        // first key/value
+        every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
+        every { headerReader.readVersion(inputStream) } returns VERSION
+        every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
+        every { crypto.decryptSegment(inputStream) } returns data
+        every { output.writeEntityHeader(key, data.size) } returns 42
+        every { output.writeEntityData(data, data.size) } returns data.size
+        // second key/value
+        every { plugin.getInputStreamForRecord(token, packageInfo, key264) } returns inputStream2
+        every { headerReader.readVersion(inputStream2) } returns VERSION
+        every { crypto.decryptHeader(inputStream2, VERSION, packageInfo.packageName, key2) } returns versionHeader2
+        every { crypto.decryptSegment(inputStream2) } returns data2
+        every { output.writeEntityHeader(key2, data2.size) } returns 42
+        every { output.writeEntityData(data2, data2.size) } returns data2.size
+        every { inputStream2.close() } just Runs
+        streamsGetClosed()
+
+        assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
+    }
+
+    private fun getRecordsAndOutput(recordKeys: List<String> = listOf(key64)) {
+        every { plugin.listRecords(token, packageInfo) } returns recordKeys
+        every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
+    }
+
+    private fun streamsGetClosed() {
+        every { inputStream.close() } just Runs
+        every { fileDescriptor.close() } just Runs
+    }
+
+    private fun verifyStreamWasClosed() {
+        verifyAll {
+            inputStream.close()
+            fileDescriptor.close()
+        }
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt
new file mode 100644
index 00000000..10aa6cb0
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt
@@ -0,0 +1,189 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.app.backup.BackupTransport.TRANSPORT_OK
+import android.app.backup.RestoreDescription
+import android.app.backup.RestoreDescription.*
+import android.app.backup.RestoreSet
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import com.stevesoltys.backup.transport.TransportTest
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.io.IOException
+import kotlin.random.Random
+
+internal class RestoreCoordinatorTest : TransportTest() {
+
+    private val plugin = mockk<RestorePlugin>()
+    private val kv = mockk<KVRestore>()
+    private val full = mockk<FullRestore>()
+
+    private val restore = RestoreCoordinator(plugin, kv, full)
+
+    private val token = Random.nextLong()
+    private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
+    private val packageInfoArray = arrayOf(packageInfo)
+    private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
+
+    @Test
+    fun `getAvailableRestoreSets() delegates to plugin`() {
+        val restoreSets = Array(1) { RestoreSet() }
+
+        every { plugin.getAvailableRestoreSets() } returns restoreSets
+
+        assertEquals(restoreSets, restore.getAvailableRestoreSets())
+    }
+
+    @Test
+    fun `getCurrentRestoreSet() delegates to plugin`() {
+        val currentRestoreSet = Random.nextLong()
+
+        every { plugin.getCurrentRestoreSet() } returns currentRestoreSet
+
+        assertEquals(currentRestoreSet, restore.getCurrentRestoreSet())
+    }
+
+    @Test
+    fun `startRestore() returns OK`() {
+        assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray))
+    }
+
+    @Test
+    fun `startRestore() can not be called twice`() {
+        assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray))
+        assertThrows(IllegalStateException::class.javaObjectType) {
+            restore.startRestore(token, packageInfoArray)
+        }
+    }
+
+    @Test
+    fun `nextRestorePackage() throws without startRestore()`() {
+        assertThrows(IllegalStateException::class.javaObjectType) {
+            restore.nextRestorePackage()
+        }
+    }
+
+    @Test
+    fun `nextRestorePackage() returns KV description and takes precedence`() {
+        restore.startRestore(token, packageInfoArray)
+
+        every { kv.hasDataForPackage(token, packageInfo) } returns true
+        every { kv.initializeState(token, packageInfo) } just Runs
+
+        val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
+        assertEquals(expected, restore.nextRestorePackage())
+    }
+
+    @Test
+    fun `nextRestorePackage() returns full description if no KV data found`() {
+        restore.startRestore(token, packageInfoArray)
+
+        every { kv.hasDataForPackage(token, packageInfo) } returns false
+        every { full.hasDataForPackage(token, packageInfo) } returns true
+        every { full.initializeState(token, packageInfo) } just Runs
+
+        val expected = RestoreDescription(packageInfo.packageName, TYPE_FULL_STREAM)
+        assertEquals(expected, restore.nextRestorePackage())
+    }
+
+    @Test
+    fun `nextRestorePackage() returns NO_MORE_PACKAGES if data found`() {
+        restore.startRestore(token, packageInfoArray)
+
+        every { kv.hasDataForPackage(token, packageInfo) } returns false
+        every { full.hasDataForPackage(token, packageInfo) } returns false
+
+        assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage())
+    }
+
+    @Test
+    fun `nextRestorePackage() returns all packages from startRestore()`() {
+        restore.startRestore(token, packageInfoArray2)
+
+        every { kv.hasDataForPackage(token, packageInfo) } returns true
+        every { kv.initializeState(token, packageInfo) } just Runs
+
+        val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
+        assertEquals(expected, restore.nextRestorePackage())
+
+        every { kv.hasDataForPackage(token, packageInfo2) } returns false
+        every { full.hasDataForPackage(token, packageInfo2) } returns true
+        every { full.initializeState(token, packageInfo2) } just Runs
+
+        val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
+        assertEquals(expected2, restore.nextRestorePackage())
+
+        assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage())
+    }
+
+    @Test
+    fun `when kv#hasDataForPackage() throws return null`() {
+        restore.startRestore(token, packageInfoArray)
+
+        every { kv.hasDataForPackage(token, packageInfo) } throws IOException()
+
+        assertNull(restore.nextRestorePackage())
+    }
+
+    @Test
+    fun `when full#hasDataForPackage() throws return null`() {
+        restore.startRestore(token, packageInfoArray)
+
+        every { kv.hasDataForPackage(token, packageInfo) } returns false
+        every { full.hasDataForPackage(token, packageInfo) } throws IOException()
+
+        assertNull(restore.nextRestorePackage())
+    }
+
+    @Test
+    fun `getRestoreData() delegates to KV`() {
+        val data = mockk<ParcelFileDescriptor>()
+        val result = Random.nextInt()
+
+        every { kv.getRestoreData(data) } returns result
+
+        assertEquals(result, restore.getRestoreData(data))
+    }
+
+    @Test
+    fun `getNextFullRestoreDataChunk() delegates to Full`() {
+        val data = mockk<ParcelFileDescriptor>()
+        val result = Random.nextInt()
+
+        every { full.getNextFullRestoreDataChunk(data) } returns result
+
+        assertEquals(result, restore.getNextFullRestoreDataChunk(data))
+    }
+
+    @Test
+    fun `abortFullRestore() delegates to Full`() {
+        val result = Random.nextInt()
+
+        every { full.abortFullRestore() } returns result
+
+        assertEquals(result, restore.abortFullRestore())
+    }
+
+    @Test
+    fun `finishRestore() delegates to Full if it has state`() {
+        val hasState = Random.nextBoolean()
+
+        every { full.hasState() } returns hasState
+        if (hasState) {
+            every { full.finishRestore() } just Runs
+        }
+
+        restore.finishRestore()
+    }
+
+    private fun assertEquals(expected: RestoreDescription, actual: RestoreDescription?) {
+        assertNotNull(actual)
+        assertEquals(expected.packageName, actual?.packageName)
+        assertEquals(expected.dataType, actual?.dataType)
+    }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt
new file mode 100644
index 00000000..577f3a6c
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt
@@ -0,0 +1,24 @@
+package com.stevesoltys.backup.transport.restore
+
+import android.os.ParcelFileDescriptor
+import com.stevesoltys.backup.getRandomByteArray
+import com.stevesoltys.backup.transport.TransportTest
+import com.stevesoltys.backup.header.HeaderReader
+import com.stevesoltys.backup.header.VERSION
+import io.mockk.mockk
+import java.io.InputStream
+import kotlin.random.Random
+
+internal abstract class RestoreTest : TransportTest() {
+
+    protected val outputFactory = mockk<OutputFactory>()
+    protected val headerReader = mockk<HeaderReader>()
+    protected val fileDescriptor = mockk<ParcelFileDescriptor>()
+
+    protected val token = Random.nextLong()
+    protected val data = getRandomByteArray()
+    protected val inputStream = mockk<InputStream>()
+
+    protected val unsupportedVersion = (VERSION + 1).toByte()
+
+}
diff --git a/build.gradle b/build.gradle
index aee7f914..26dd7758 100644
--- a/build.gradle
+++ b/build.gradle
@@ -23,6 +23,7 @@ allprojects {
         mavenCentral()
         jcenter()
         google()
+        maven { url 'https://jitpack.io' }
     }
 }