Merge pull request #553 from seedvault-app/feature/e2e-test

Add end-to-end emulator test running on CI
diff --git a/.cirrus.yml b/.cirrus.yml
new file mode 100644
index 0000000..896a6a4
--- /dev/null
+++ b/.cirrus.yml
@@ -0,0 +1,58 @@
+container:
+  image: ghcr.io/cirruslabs/android-sdk:33
+  kvm: true
+  cpu: 8
+  memory: 16G
+
+instrumentation_tests_task:
+  name: "Cirrus CI Instrumentation Tests"
+  skip: "!changesInclude('.cirrus.yml', '*.gradle', '*.gradle.kts', '**/*.gradle', '**/*.gradle.kts', '*.properties', '**/*.properties', '**/*.kt', '**/*.xml')"
+  start_avd_background_script:
+    sdkmanager --install "system-images;android-33;google_apis;x86_64";
+    echo no | avdmanager create avd -n seedvault -k "system-images;android-33;google_apis;x86_64";
+    $ANDROID_HOME/emulator/emulator
+    -avd seedvault
+    -no-audio
+    -no-boot-anim
+    -gpu swiftshader_indirect
+    -no-snapshot
+    -no-window
+    -writable-system;
+  provision_avd_background_script:
+    wget https://github.com/seedvault-app/seedvault-test-data/releases/download/1/backup.tar.gz;
+
+    adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
+    adb root;
+    sleep 5;
+    adb remount;
+    adb reboot;
+    adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
+    adb root;
+    sleep 5;
+    adb remount;
+    sleep 5;
+  assemble_script:
+    ./gradlew :app:assembleRelease :app:assembleAndroidTest
+  install_app_script:
+    timeout 180s bash -c 'while [[ -z $(adb shell mount | grep "/system " | grep "(rw,") ]]; do sleep 1; done;';
+    adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
+
+    adb shell mkdir -p /sdcard/seedvault_baseline;
+    adb push backup.tar.gz /sdcard/seedvault_baseline/backup.tar.gz;
+    adb shell tar xzf /sdcard/seedvault_baseline/backup.tar.gz --directory=/sdcard/seedvault_baseline;
+
+    adb shell mkdir -p /system/priv-app/Seedvault;
+    adb push app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk;
+    adb push permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml;
+    adb push allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml;
+    adb shell bmgr enable true;
+    adb shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport;
+    adb reboot;
+    adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
+  run_large_tests_script: ./gradlew -Pinstrumented_test_size=large :app:connectedAndroidTest
+  run_medium_tests_script: ./gradlew -Pinstrumented_test_size=medium :app:connectedAndroidTest
+  always:
+    pull_screenshots_script:
+      adb pull /sdcard/seedvault_test_videos
+    screenshots_artifacts:
+      path: "seedvault_test_videos/**/*.mp4"
diff --git a/.idea/runConfigurations/app_emulator.xml b/.idea/runConfigurations/app_emulator.xml
index 0579cb2..60f48b5 100644
--- a/.idea/runConfigurations/app_emulator.xml
+++ b/.idea/runConfigurations/app_emulator.xml
@@ -17,7 +17,7 @@
     <option name="TARGET_SELECTION_MODE" value="SHOW_DIALOG" />
     <option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
     <option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
-    <option name="DEBUGGER_TYPE" value="Auto" />
+    <option name="DEBUGGER_TYPE" value="Java" />
     <Auto>
       <option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
       <option name="SHOW_STATIC_VARS" value="true" />
@@ -37,7 +37,7 @@
       <option name="DEBUG_SANDBOX_SDK" value="false" />
     </Hybrid>
     <Java>
-      <option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
+      <option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="true" />
       <option name="DEBUG_SANDBOX_SDK" value="false" />
     </Java>
     <Native>
@@ -62,7 +62,7 @@
     <option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
     <option name="SKIP_ACTIVITY_VALIDATION" value="false" />
     <method v="2">
-      <option name="RunConfigurationTask" enabled="false" run_configuration_name="seedvault [installEmulatorRelease]" run_configuration_type="GradleRunConfiguration" />
+      <option name="Gradle.BeforeRunTask" enabled="false" tasks="installEmulatorRelease" externalProjectPath="$PROJECT_DIR$/app" vmOptions="" scriptParameters="" />
     </method>
   </configuration>
 </component>
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index d08c828..6f2c7df 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -21,8 +21,15 @@
         minSdk 32 // leave at 32 for robolectric tests
         targetSdk rootProject.ext.targetSdk
         versionNameSuffix "-$gitDescribe"
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        testInstrumentationRunner "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
         testInstrumentationRunnerArguments disableAnalytics: 'true'
+
+        if (project.hasProperty('instrumented_test_size')) {
+            final testSize = project.getProperty('instrumented_test_size')
+            println("Instrumented test size: $testSize")
+
+            testInstrumentationRunnerArguments size: testSize
+        }
     }
 
     buildTypes {
@@ -150,10 +157,12 @@
     testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
     testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit5_version"
 
+    androidTestImplementation rootProject.ext.aosp_libs
     androidTestImplementation 'androidx.test:runner:1.4.0'
     androidTestImplementation 'androidx.test:rules:1.4.0'
     androidTestImplementation 'androidx.test.ext:junit:1.1.3'
     androidTestImplementation "io.mockk:mockk-android:$mockk_version"
+    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
 }
 
 apply from: "${rootProject.rootDir}/gradle/ktlint.gradle"
@@ -182,7 +191,7 @@
                 "seedvault",
                 "system-images;android-33;google_apis;x86_64"
 
-        environment "ANDROID_SDK_HOME", android.sdkDirectory.absolutePath
+        environment "ANDROID_HOME", android.sdkDirectory.absolutePath
         environment "JAVA_HOME", System.properties['java.home']
     }
 }
@@ -193,7 +202,7 @@
     doFirst {
         commandLine "${project.projectDir}/development/scripts/start_emulator.sh", "seedvault"
 
-        environment "ANDROID_SDK_HOME", android.sdkDirectory.absolutePath
+        environment "ANDROID_HOME", android.sdkDirectory.absolutePath
         environment "JAVA_HOME", System.properties['java.home']
     }
 }
@@ -206,7 +215,18 @@
     doFirst {
         commandLine "${project.projectDir}/development/scripts/install_app.sh"
 
-        environment "ANDROID_SDK_HOME", android.sdkDirectory.absolutePath
+        environment "ANDROID_HOME", android.sdkDirectory.absolutePath
+        environment "JAVA_HOME", System.properties['java.home']
+    }
+}
+
+tasks.register('clearEmulatorAppData', Exec) {
+    group("emulator")
+
+    doFirst {
+        commandLine "${project.projectDir}/development/scripts/clear_app_data.sh"
+
+        environment "ANDROID_HOME", android.sdkDirectory.absolutePath
         environment "JAVA_HOME", System.properties['java.home']
     }
 }
diff --git a/app/development/scripts/clear_app_data.sh b/app/development/scripts/clear_app_data.sh
new file mode 100755
index 0000000..8bf4b97
--- /dev/null
+++ b/app/development/scripts/clear_app_data.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+# assert ANDROID_HOME is set
+if [ -z "$ANDROID_HOME" ]; then
+  echo "ANDROID_HOME is not set"
+  exit 1
+fi
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+DEVELOPMENT_DIR=$SCRIPT_DIR/..
+ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
+
+EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
+
+if [ -z "$EMULATOR_DEVICE_NAME" ]; then
+  echo "Emulator device name not found"
+  exit 1
+fi
+
+ADB="$ANDROID_HOME/platform-tools/adb -s $EMULATOR_DEVICE_NAME"
+
+$ADB shell pm clear com.stevesoltys.seedvault
diff --git a/app/development/scripts/install_app.sh b/app/development/scripts/install_app.sh
index 05bd3ee..6891cad 100755
--- a/app/development/scripts/install_app.sh
+++ b/app/development/scripts/install_app.sh
@@ -1,8 +1,8 @@
 #!/usr/bin/env bash
 
 # assert ANDROID_HOME is set
-if [ -z "$ANDROID_SDK_HOME" ]; then
-  echo "ANDROID_SDK_HOME is not set"
+if [ -z "$ANDROID_HOME" ]; then
+  echo "ANDROID_HOME is not set"
   exit 1
 fi
 
@@ -10,14 +10,14 @@
 DEVELOPMENT_DIR=$SCRIPT_DIR/..
 ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
 
-EMULATOR_DEVICE_NAME=$($ANDROID_SDK_HOME/platform-tools/adb devices | grep emulator | cut -f1)
+EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
 
 if [ -z "$EMULATOR_DEVICE_NAME" ]; then
   echo "Emulator device name not found"
   exit 1
 fi
 
-ADB="$ANDROID_SDK_HOME/platform-tools/adb -s $EMULATOR_DEVICE_NAME"
+ADB="$ANDROID_HOME/platform-tools/adb -s $EMULATOR_DEVICE_NAME"
 
 $ADB root
 sleep 3      # wait for adb to restart
diff --git a/app/development/scripts/provision_emulator.sh b/app/development/scripts/provision_emulator.sh
index 5c4bfd7..e8d4591 100755
--- a/app/development/scripts/provision_emulator.sh
+++ b/app/development/scripts/provision_emulator.sh
@@ -1,8 +1,8 @@
 #!/usr/bin/env bash
 
 # assert ANDROID_HOME is set
-if [ -z "$ANDROID_SDK_HOME" ]; then
-  echo "ANDROID_SDK_HOME is not set"
+if [ -z "$ANDROID_HOME" ]; then
+  echo "ANDROID_HOME is not set"
   exit 1
 fi
 
@@ -20,30 +20,29 @@
 ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
 
 echo "Downloading system image..."
-$ANDROID_SDK_HOME/cmdline-tools/latest/bin/sdkmanager --install "$SYSTEM_IMAGE"
+$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "$SYSTEM_IMAGE"
 
 # create AVD if it doesn't exist
-if $ANDROID_SDK_HOME/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$EMULATOR_NAME"; then
+if $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$EMULATOR_NAME"; then
   echo "AVD already exists. Skipping creation."
 else
   echo "Creating AVD..."
-  echo 'no' | $ANDROID_SDK_HOME/cmdline-tools/latest/bin/avdmanager create avd -n "$EMULATOR_NAME" -k "$SYSTEM_IMAGE"
+  echo 'no' | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd -n "$EMULATOR_NAME" -k "$SYSTEM_IMAGE"
   sleep 1
 fi
 
-echo "Starting emulator..."
 $SCRIPT_DIR/start_emulator.sh "$EMULATOR_NAME"
 sleep 3
 
 # get emulator device name from ADB
-EMULATOR_DEVICE_NAME=$($ANDROID_SDK_HOME/platform-tools/adb devices | grep emulator | cut -f1)
+EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
 
 if [ -z "$EMULATOR_DEVICE_NAME" ]; then
   echo "Emulator device name not found"
   exit 1
 fi
 
-ADB="$ANDROID_SDK_HOME/platform-tools/adb -s $EMULATOR_DEVICE_NAME"
+ADB="$ANDROID_HOME/platform-tools/adb -s $EMULATOR_DEVICE_NAME"
 
 echo "Waiting for emulator to boot..."
 $ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
@@ -68,4 +67,13 @@
 $ADB reboot
 $ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
 
+echo "Downloading and extracting test backup to '/sdcard/seedvault'..."
+wget https://github.com/seedvault-app/seedvault-test-data/releases/download/1/backup.tar.gz
+$ADB push backup.tar.gz /sdcard/
+rm backup.tar.gz
+
+$ADB shell mkdir -p /sdcard/seedvault_baseline
+$ADB shell tar xzf /sdcard/backup.tar.gz --directory=/sdcard/seedvault_baseline
+$ADB shell rm /sdcard/backup.tar.gz
+
 echo "Emulator '$EMULATOR_NAME' has been provisioned with Seedvault!"
diff --git a/app/development/scripts/start_emulator.sh b/app/development/scripts/start_emulator.sh
index 387388d..616a538 100755
--- a/app/development/scripts/start_emulator.sh
+++ b/app/development/scripts/start_emulator.sh
@@ -1,8 +1,8 @@
 #!/usr/bin/env bash
 
 # assert ANDROID_HOME is set
-if [ -z "$ANDROID_SDK_HOME" ]; then
-  echo "ANDROID_SDK_HOME is not set"
+if [ -z "$ANDROID_HOME" ]; then
+  echo "ANDROID_HOME is not set"
   exit 1
 fi
 
@@ -19,4 +19,4 @@
 ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
 
 echo "Starting emulator..."
-nohup $ANDROID_SDK_HOME/emulator/emulator -avd "$EMULATOR_NAME" -gpu swiftshader_indirect -writable-system -no-snapshot-load >/dev/null 2>&1 &
+nohup $ANDROID_HOME/emulator/emulator -avd "$EMULATOR_NAME" -gpu swiftshader_indirect -writable-system -no-snapshot-load >/dev/null 2>&1 &
diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
index 7770cfb..00f58f9 100644
--- a/app/src/androidTest/AndroidManifest.xml
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -1,5 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
-
     <application android:extractNativeLibs="true" />
 </manifest>
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt
new file mode 100644
index 0000000..338a426
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt
@@ -0,0 +1,42 @@
+package com.stevesoltys.seedvault
+
+import com.stevesoltys.seedvault.restore.RestoreViewModel
+import com.stevesoltys.seedvault.transport.backup.FullBackup
+import com.stevesoltys.seedvault.transport.backup.InputFactory
+import com.stevesoltys.seedvault.transport.backup.KVBackup
+import com.stevesoltys.seedvault.transport.restore.FullRestore
+import com.stevesoltys.seedvault.transport.restore.KVRestore
+import com.stevesoltys.seedvault.transport.restore.OutputFactory
+import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
+import io.mockk.spyk
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+internal var currentRestoreViewModel: RestoreViewModel? = null
+
+class KoinInstrumentationTestApp : App() {
+
+    override fun appModules(): List<Module> {
+        val testModule = module {
+            val context = this@KoinInstrumentationTestApp
+
+            single { spyk(BackupNotificationManager(context)) }
+            single { spyk(FullBackup(get(), get(), get(), get())) }
+            single { spyk(KVBackup(get(), get(), get(), get(), get())) }
+            single { spyk(InputFactory()) }
+
+            single { spyk(FullRestore(get(), get(), get(), get(), get())) }
+            single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) }
+            single { spyk(OutputFactory()) }
+
+            viewModel {
+                currentRestoreViewModel =
+                    spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get()))
+                currentRestoreViewModel!!
+            }
+        }
+
+        return super.appModules().plus(testModule)
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestRunner.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestRunner.kt
new file mode 100644
index 0000000..1ee3fe9
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestRunner.kt
@@ -0,0 +1,20 @@
+package com.stevesoltys.seedvault
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+
+class KoinInstrumentationTestRunner : AndroidJUnitRunner() {
+
+    override fun newApplication(
+        classLoader: ClassLoader?,
+        className: String?,
+        context: Context?,
+    ): Application {
+        return super.newApplication(
+            classLoader,
+            KoinInstrumentationTestApp::class.java.name,
+            context
+        )
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
index 3188686..f5dddd7 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
@@ -2,6 +2,7 @@
 
 import androidx.test.core.content.pm.PackageInfoBuilder
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
 import androidx.test.platform.app.InstrumentationRegistry
 import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
 import com.stevesoltys.seedvault.plugins.StoragePlugin
@@ -28,6 +29,7 @@
 
 @RunWith(AndroidJUnit4::class)
 @Suppress("BlockingMethodInNonBlockingContext")
+@MediumTest
 class PluginTest : KoinComponent {
 
     private val context = InstrumentationRegistry.getInstrumentation().targetContext
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt
new file mode 100644
index 0000000..0533eea
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt
@@ -0,0 +1,175 @@
+package com.stevesoltys.seedvault.e2e
+
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept
+import com.stevesoltys.seedvault.e2e.io.InputStreamIntercept
+import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen
+import com.stevesoltys.seedvault.transport.backup.FullBackup
+import com.stevesoltys.seedvault.transport.backup.InputFactory
+import com.stevesoltys.seedvault.transport.backup.KVBackup
+import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
+import io.mockk.clearMocks
+import io.mockk.coEvery
+import io.mockk.every
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.koin.core.component.get
+import java.io.ByteArrayOutputStream
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal interface LargeBackupTestBase : LargeTestBase {
+
+    companion object {
+        private const val BACKUP_TIMEOUT = 360 * 1000L
+    }
+
+    val spyBackupNotificationManager: BackupNotificationManager get() = get()
+
+    val spyFullBackup: FullBackup get() = get()
+
+    val spyKVBackup: KVBackup get() = get()
+
+    val spyInputFactory: InputFactory get() = get()
+
+    fun launchBackupActivity() {
+        runCommand("am start -n ${targetContext.packageName}/.settings.SettingsActivity")
+        waitUntilIdle()
+    }
+
+    fun startBackup() {
+        BackupScreen {
+            backupMenu.clickAndWaitForNewWindow()
+            waitUntilIdle()
+
+            backupNowButton.clickAndWaitForNewWindow()
+            waitUntilIdle()
+
+            backupStatusButton.clickAndWaitForNewWindow()
+            waitUntilIdle()
+        }
+    }
+
+    fun performBackup(): SeedvaultLargeTestResult {
+
+        val backupResult = SeedvaultLargeTestResult(
+            full = mutableMapOf(),
+            kv = mutableMapOf(),
+            userApps = packageService.userApps,
+            userNotAllowedApps = packageService.userNotAllowedApps
+        )
+
+        val completed = spyOnBackup(backupResult)
+        startBackup()
+        waitForBackupResult(completed)
+
+        return backupResult.copy(
+            backupResults = backupResult.allUserApps().associate {
+                it.packageName to spyMetadataManager.getPackageMetadata(it.packageName)
+            }.toMutableMap()
+        )
+    }
+
+    private fun waitForBackupResult(completed: AtomicBoolean) {
+        runBlocking {
+            withTimeout(BACKUP_TIMEOUT) {
+                while (!completed.get()) {
+                    delay(100)
+                }
+            }
+        }
+    }
+
+    private fun spyOnBackup(backupResult: SeedvaultLargeTestResult): AtomicBoolean {
+        clearMocks(spyInputFactory, spyKVBackup, spyFullBackup)
+        spyOnFullBackupData(backupResult)
+        spyOnKVBackupData(backupResult)
+
+        return spyOnBackupCompletion()
+    }
+
+    private fun spyOnKVBackupData(backupResult: SeedvaultLargeTestResult) {
+        var packageName: String? = null
+        var data = mutableMapOf<String, ByteArray>()
+
+        coEvery {
+            spyKVBackup.performBackup(any(), any(), any(), any(), any())
+        } answers {
+            packageName = firstArg<PackageInfo>().packageName
+            callOriginal()
+        }
+
+        every {
+            spyInputFactory.getBackupDataInput(any())
+        } answers {
+            val fd = firstArg<ParcelFileDescriptor>().fileDescriptor
+
+            BackupDataInputIntercept(fd) { key, value ->
+                data[key] = value
+            }
+        }
+
+        coEvery {
+            spyKVBackup.finishBackup()
+        } answers {
+            backupResult.kv[packageName!!] = data
+                .mapValues { entry -> entry.value.sha256() }
+                .toMutableMap()
+
+            packageName = null
+            data = mutableMapOf()
+            callOriginal()
+        }
+    }
+
+    private fun spyOnFullBackupData(backupResult: SeedvaultLargeTestResult) {
+        var packageName: String? = null
+        var dataIntercept = ByteArrayOutputStream()
+
+        coEvery {
+            spyFullBackup.performFullBackup(any(), any(), any(), any(), any())
+        } answers {
+            packageName = firstArg<PackageInfo>().packageName
+            callOriginal()
+        }
+
+        every {
+            spyInputFactory.getInputStream(any())
+        } answers {
+            InputStreamIntercept(
+                inputStream = callOriginal(),
+                intercept = dataIntercept
+            )
+        }
+
+        every {
+            spyFullBackup.finishBackup()
+        } answers {
+            val result = callOriginal()
+            backupResult.full[packageName!!] = dataIntercept.toByteArray().sha256()
+
+            packageName = null
+            dataIntercept = ByteArrayOutputStream()
+            result
+        }
+    }
+
+    private fun spyOnBackupCompletion(): AtomicBoolean {
+        val completed = AtomicBoolean(false)
+
+        clearMocks(spyBackupNotificationManager)
+
+        every {
+            spyBackupNotificationManager.onBackupFinished(any(), any())
+        } answers {
+            val success = firstArg<Boolean>()
+            assert(success) { "Backup failed." }
+
+            callOriginal()
+            completed.set(true)
+        }
+
+        return completed
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt
new file mode 100644
index 0000000..5458ae2
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt
@@ -0,0 +1,197 @@
+package com.stevesoltys.seedvault.e2e
+
+import android.content.pm.PackageInfo
+import android.os.ParcelFileDescriptor
+import com.stevesoltys.seedvault.e2e.io.BackupDataOutputIntercept
+import com.stevesoltys.seedvault.e2e.io.OutputStreamIntercept
+import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
+import com.stevesoltys.seedvault.e2e.screen.impl.RestoreScreen
+import com.stevesoltys.seedvault.transport.restore.FullRestore
+import com.stevesoltys.seedvault.transport.restore.KVRestore
+import com.stevesoltys.seedvault.transport.restore.OutputFactory
+import io.mockk.clearMocks
+import io.mockk.coEvery
+import io.mockk.every
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import org.koin.core.component.get
+import java.io.ByteArrayOutputStream
+
+internal interface LargeRestoreTestBase : LargeTestBase {
+
+    companion object {
+        private const val RESTORE_TIMEOUT = 360 * 1000L
+    }
+
+    val spyFullRestore: FullRestore get() = get()
+
+    val spyKVRestore: KVRestore get() = get()
+
+    val spyOutputFactory: OutputFactory get() = get()
+
+    fun launchRestoreActivity() {
+        runCommand("am start -n ${targetContext.packageName}/.restore.RestoreActivity")
+        waitUntilIdle()
+    }
+
+    fun typeInRestoreCode(code: List<String>) {
+        assert(code.size == 12) { "Code must have 12 words." }
+
+        RecoveryCodeScreen {
+            waitUntilIdle()
+
+            code.forEachIndexed { index, word ->
+                wordTextField(index).text = word
+            }
+
+            waitUntilIdle()
+            verifyCodeButton.scrollTo().click()
+        }
+    }
+
+    fun performRestore(): SeedvaultLargeTestResult {
+
+        val result = SeedvaultLargeTestResult(
+            full = mutableMapOf(),
+            kv = mutableMapOf(),
+            userApps = emptyList(), // will update everything below this after restore
+            userNotAllowedApps = emptyList()
+        )
+
+        spyOnRestoreData(result)
+
+        RestoreScreen {
+            backupListItem.clickAndWaitForNewWindow()
+            waitUntilIdle()
+
+            waitForInstallResult()
+            nextButton.clickAndWaitForNewWindow()
+
+            waitForRestoreDataResult()
+            finishButton.clickAndWaitForNewWindow()
+            skipButton.clickAndWaitForNewWindow()
+            waitUntilIdle()
+        }
+
+        return result.copy(
+            userApps = packageService.userApps,
+            userNotAllowedApps = packageService.userNotAllowedApps
+        )
+    }
+
+    private fun spyOnRestoreData(result: SeedvaultLargeTestResult) {
+        clearMocks(spyOutputFactory)
+
+        spyOnFullRestoreData(result)
+        spyOnKVRestoreData(result)
+    }
+
+    private fun waitForInstallResult() = runBlocking {
+
+        withContext(Dispatchers.Main) {
+            withTimeout(RESTORE_TIMEOUT) {
+                while (spyRestoreViewModel.installResult.value == null ||
+                    spyRestoreViewModel.nextButtonEnabled.value == false
+                ) {
+                    delay(100)
+                }
+            }
+
+            val restoreResultValue = spyRestoreViewModel.installResult.value
+                ?: error("Restore APKs timed out")
+
+            assert(!restoreResultValue.hasFailed) { "Failed to install packages" }
+        }
+
+        waitUntilIdle()
+    }
+
+    private fun waitForRestoreDataResult() = runBlocking {
+        withContext(Dispatchers.Main) {
+            withTimeout(RESTORE_TIMEOUT) {
+                while (spyRestoreViewModel.restoreBackupResult.value == null) {
+                    delay(100)
+                }
+            }
+
+            val restoreResultValue = spyRestoreViewModel.restoreBackupResult.value
+                ?: error("Restore app data timed out")
+
+            assert(!restoreResultValue.hasError()) {
+                "Restore failed: ${restoreResultValue.errorMsg}"
+            }
+
+            waitUntilIdle()
+        }
+    }
+
+    private fun spyOnKVRestoreData(restoreResult: SeedvaultLargeTestResult) {
+        var packageName: String? = null
+
+        clearMocks(spyKVRestore)
+
+        coEvery {
+            spyKVRestore.initializeState(any(), any(), any(), any(), any())
+        } answers {
+            packageName = arg<PackageInfo>(3).packageName
+            restoreResult.kv[packageName!!] = mutableMapOf()
+            callOriginal()
+        }
+
+        every {
+            spyOutputFactory.getBackupDataOutput(any())
+        } answers {
+            val fd = firstArg<ParcelFileDescriptor>().fileDescriptor
+
+            BackupDataOutputIntercept(fd) { key, value ->
+                restoreResult.kv[packageName!!]!![key] = value.sha256()
+            }
+        }
+    }
+
+    private fun spyOnFullRestoreData(restoreResult: SeedvaultLargeTestResult) {
+        var packageName: String? = null
+        var dataIntercept = ByteArrayOutputStream()
+
+        clearMocks(spyFullRestore)
+
+        coEvery {
+            spyFullRestore.initializeState(any(), any(), any(), any())
+        } answers {
+            packageName = arg<PackageInfo>(3).packageName
+            dataIntercept = ByteArrayOutputStream()
+
+            callOriginal()
+        }
+
+        every {
+            spyOutputFactory.getOutputStream(any())
+        } answers {
+            OutputStreamIntercept(
+                outputStream = callOriginal(),
+                intercept = dataIntercept
+            )
+        }
+
+        every {
+            spyFullRestore.abortFullRestore()
+        } answers {
+            packageName = null
+            dataIntercept = ByteArrayOutputStream()
+            callOriginal()
+        }
+
+        every {
+            spyFullRestore.finishRestore()
+        } answers {
+            restoreResult.full[packageName!!] = dataIntercept.toByteArray().sha256()
+
+            packageName = null
+            dataIntercept = ByteArrayOutputStream()
+            callOriginal()
+        }
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt
new file mode 100644
index 0000000..31aa0e0
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt
@@ -0,0 +1,189 @@
+package com.stevesoltys.seedvault.e2e
+
+import android.app.UiAutomation
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.os.Environment
+import androidx.annotation.WorkerThread
+import androidx.preference.PreferenceManager
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import com.stevesoltys.seedvault.crypto.ANDROID_KEY_STORE
+import com.stevesoltys.seedvault.crypto.KEY_ALIAS_BACKUP
+import com.stevesoltys.seedvault.crypto.KEY_ALIAS_MAIN
+import com.stevesoltys.seedvault.crypto.KeyManager
+import com.stevesoltys.seedvault.currentRestoreViewModel
+import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen
+import com.stevesoltys.seedvault.e2e.screen.impl.DocumentPickerScreen
+import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
+import com.stevesoltys.seedvault.metadata.MetadataManager
+import com.stevesoltys.seedvault.permitDiskReads
+import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
+import com.stevesoltys.seedvault.restore.RestoreViewModel
+import com.stevesoltys.seedvault.settings.SettingsManager
+import com.stevesoltys.seedvault.transport.backup.PackageService
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+import java.io.File
+import java.lang.Thread.sleep
+import java.security.KeyStore
+import java.security.MessageDigest
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal interface LargeTestBase : KoinComponent {
+
+    companion object {
+        private const val TEST_STORAGE_FOLDER = "seedvault_test"
+        private const val TEST_VIDEO_FOLDER = "seedvault_test_videos"
+    }
+
+    val externalStorageDir: String get() = Environment.getExternalStorageDirectory().absolutePath
+
+    val testStoragePath get() = "$externalStorageDir/$TEST_STORAGE_FOLDER"
+
+    val testVideoPath get() = "$externalStorageDir/$TEST_VIDEO_FOLDER"
+
+    val targetContext: Context
+        get() = InstrumentationRegistry.getInstrumentation().targetContext
+
+    val uiAutomation: UiAutomation
+        get() = InstrumentationRegistry.getInstrumentation().uiAutomation
+
+    val device: UiDevice
+        get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    val packageService: PackageService get() = get()
+
+    val settingsManager: SettingsManager get() = get()
+
+    val keyManager: KeyManager get() = get()
+
+    val documentsStorage: DocumentsStorage get() = get()
+
+    val spyMetadataManager: MetadataManager get() = get()
+
+    val spyRestoreViewModel: RestoreViewModel
+        get() = currentRestoreViewModel ?: error("currentRestoreViewModel is null")
+
+    fun resetApplicationState() {
+        settingsManager.setNewToken(null)
+        documentsStorage.reset(null)
+
+        val sharedPreferences = permitDiskReads {
+            PreferenceManager.getDefaultSharedPreferences(targetContext)
+        }
+        sharedPreferences.edit().clear().apply()
+
+        KeyStore.getInstance(ANDROID_KEY_STORE).apply {
+            load(null)
+        }.apply {
+            deleteEntry(KEY_ALIAS_MAIN)
+            deleteEntry(KEY_ALIAS_BACKUP)
+        }
+
+        clearDocumentPickerAppData()
+    }
+
+    fun waitUntilIdle() {
+        device.waitForIdle()
+        sleep(3000)
+    }
+
+    fun runCommand(command: String) {
+        uiAutomation.executeShellCommand(command).close()
+    }
+
+    @OptIn(DelicateCoroutinesApi::class)
+    @WorkerThread
+    suspend fun startScreenRecord(
+        keepRecordingScreen: AtomicBoolean,
+        testName: String,
+    ) {
+        val simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss")
+        val timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
+        val fileName = "${timeStamp}_${testName.replace(" ", "_")}"
+
+        val folder = testVideoPath
+        runCommand("mkdir -p $folder")
+
+        // screen record automatically stops after 3 minutes
+        // we need to block on a loop and split it into multiple files
+        GlobalScope.launch(Dispatchers.IO) {
+            var index = 0
+
+            while (keepRecordingScreen.get()) {
+                device.executeShellCommand("screenrecord $folder/$fileName-${index++}.mp4")
+            }
+        }
+    }
+
+    @WorkerThread
+    fun stopScreenRecord(keepRecordingScreen: AtomicBoolean) {
+        keepRecordingScreen.set(false)
+
+        runCommand("pkill -2 screenrecord")
+    }
+
+    fun uninstallPackages(packages: Collection<PackageInfo>) {
+        packages.forEach { runCommand("pm uninstall ${it.packageName}") }
+    }
+
+    fun clearDocumentPickerAppData() {
+        runCommand("pm clear com.google.android.documentsui")
+    }
+
+    fun clearTestBackups() {
+        File(testStoragePath).deleteRecursively()
+    }
+
+    fun changeBackupLocation(
+        folderName: String = TEST_STORAGE_FOLDER,
+        exists: Boolean = false,
+    ) {
+        BackupScreen {
+            clearDocumentPickerAppData()
+            backupLocationButton.clickAndWaitForNewWindow()
+
+            chooseStorageLocation(folderName, exists)
+        }
+    }
+
+    fun chooseStorageLocation(
+        folderName: String = TEST_STORAGE_FOLDER,
+        exists: Boolean = false,
+    ) {
+        DocumentPickerScreen {
+            if (exists) {
+                existingFolder(folderName).scrollTo().clickAndWaitForNewWindow()
+
+            } else {
+                createNewFolderButton.clickAndWaitForNewWindow()
+                textBox.text = folderName
+                okButton.clickAndWaitForNewWindow()
+            }
+
+            useThisFolderButton.clickAndWaitForNewWindow()
+            allowButton.clickAndWaitForNewWindow()
+        }
+    }
+
+    fun confirmCode() {
+        RecoveryCodeScreen {
+            confirmCodeButton.click()
+
+            verifyCodeButton.scrollTo().click()
+        }
+    }
+
+    fun ByteArray.sha256(): String {
+        val data = MessageDigest.getInstance("SHA-256").digest(this)
+
+        return data.joinToString("") { "%02x".format(it) }
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt
new file mode 100644
index 0000000..af68a66
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt
@@ -0,0 +1,76 @@
+package com.stevesoltys.seedvault.e2e
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.rules.TestName
+import org.junit.runner.RunWith
+import org.koin.core.component.KoinComponent
+import java.io.File
+import java.util.concurrent.atomic.AtomicBoolean
+
+@RunWith(AndroidJUnit4::class)
+internal abstract class SeedvaultLargeTest :
+    LargeBackupTestBase, LargeRestoreTestBase, KoinComponent {
+
+    @JvmField
+    @Rule
+    var name = TestName()
+
+    companion object {
+        private const val BASELINE_BACKUP_FOLDER = "seedvault_baseline"
+        private const val RECOVERY_CODE_FILE = "recovery-code.txt"
+    }
+
+    private val baselineBackupFolderPath get() = "$externalStorageDir/$BASELINE_BACKUP_FOLDER"
+
+    private val baselineBackupPath get() = "$baselineBackupFolderPath/.SeedVaultAndroidBackup"
+
+    private val baselineRecoveryCodePath = "$baselineBackupFolderPath/$RECOVERY_CODE_FILE"
+
+    private val keepRecordingScreen = AtomicBoolean(true)
+
+    @Before
+    open fun setUp() = runBlocking {
+        resetApplicationState()
+        clearTestBackups()
+
+        startScreenRecord(keepRecordingScreen, name.methodName)
+        restoreBaselineBackup()
+    }
+
+    @After
+    open fun tearDown() {
+        stopScreenRecord(keepRecordingScreen)
+    }
+
+    /**
+     * Restore the baseline backup, if it exists.
+     *
+     * This is a hand-crafted backup containing various apps and app data that we use for
+     * provisioning tests: https://github.com/seedvault-app/seedvault-test-data
+     */
+    private fun restoreBaselineBackup() {
+        val backupFile = File(baselineBackupPath)
+
+        if (backupFile.exists()) {
+            launchRestoreActivity()
+            chooseStorageLocation(folderName = BASELINE_BACKUP_FOLDER, exists = true)
+            typeInRestoreCode(baselineBackupRecoveryCode())
+            performRestore()
+
+            resetApplicationState()
+        }
+    }
+
+    private fun baselineBackupRecoveryCode(): List<String> {
+        val recoveryCodeFile = File(baselineRecoveryCodePath)
+
+        return recoveryCodeFile.readLines()
+            .filter { it.isNotBlank() }
+            .joinToString(separator = " ") { it.trim() }
+            .split(" ")
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTestResult.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTestResult.kt
new file mode 100644
index 0000000..3223aa5
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTestResult.kt
@@ -0,0 +1,23 @@
+package com.stevesoltys.seedvault.e2e
+
+import android.content.pm.PackageInfo
+import com.stevesoltys.seedvault.metadata.PackageMetadata
+
+/**
+ * Contains maps of (package name -> SHA-256 hashes) of application data.
+ *
+ * During backups and restores, we intercept the package data and store the result here.
+ * We can use this to validate that the restored app data actually matches the backed up data.
+ *
+ * For full backups, the mapping is: Map<PackageName, SHA-256>
+ * For K/V backups, the mapping is: Map<PackageName, Map<Key, SHA-256>>
+ */
+data class SeedvaultLargeTestResult(
+    val backupResults: Map<String, PackageMetadata?> = emptyMap(),
+    val full: MutableMap<String, String>,
+    val kv: MutableMap<String, MutableMap<String, String>>,
+    val userApps: List<PackageInfo>,
+    val userNotAllowedApps: List<PackageInfo>,
+) {
+    fun allUserApps() = userApps + userNotAllowedApps
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt
new file mode 100644
index 0000000..83e638b
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt
@@ -0,0 +1,140 @@
+package com.stevesoltys.seedvault.e2e.impl
+
+import androidx.test.filters.LargeTest
+import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest
+import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult
+import com.stevesoltys.seedvault.metadata.PackageState
+import org.junit.Test
+
+@LargeTest
+internal class BackupRestoreTest : SeedvaultLargeTest() {
+
+    @Test
+    fun `backup and restore applications`() {
+        launchBackupActivity()
+
+        if (!keyManager.hasBackupKey()) {
+            confirmCode()
+        }
+
+        if (settingsManager.getStorage() == null) {
+            chooseStorageLocation()
+        } else {
+            changeBackupLocation()
+        }
+
+        val backupResult = performBackup()
+        assertValidBackupMetadata(backupResult)
+
+        uninstallPackages(backupResult.allUserApps())
+
+        launchRestoreActivity()
+        val restoreResult = performRestore()
+
+        assertValidResults(backupResult, restoreResult)
+    }
+
+    private fun assertValidBackupMetadata(backup: SeedvaultLargeTestResult) {
+        // Assert all user apps have metadata.
+        backup.allUserApps().forEach { app ->
+            assert(backup.backupResults.containsKey(app.packageName)) {
+                "Metadata for $app missing from backup."
+            }
+        }
+
+        // Assert all metadata has a valid state.
+        backup.backupResults.forEach { (pkg, metadata) ->
+            assert(metadata != null) { "Metadata for $pkg is null." }
+
+            assert(metadata!!.state != PackageState.UNKNOWN_ERROR) {
+                "Metadata for $pkg has an unknown state."
+            }
+        }
+    }
+
+    private fun assertValidResults(
+        backup: SeedvaultLargeTestResult,
+        restore: SeedvaultLargeTestResult,
+    ) {
+        assertAllUserAppsWereRestored(backup, restore)
+        assertValidFullData(backup, restore)
+        assertValidKeyValueData(backup, restore)
+    }
+
+    private fun assertAllUserAppsWereRestored(
+        backup: SeedvaultLargeTestResult,
+        restore: SeedvaultLargeTestResult,
+    ) {
+        val backupUserApps = backup.allUserApps()
+            .map { it.packageName }.toSet()
+
+        val restoreUserApps = restore.allUserApps()
+            .map { it.packageName }.toSet()
+
+        // Assert we re-installed all user apps.
+        assert(restoreUserApps.containsAll(backupUserApps)) {
+            val missingApps = backupUserApps
+                .minus(restoreUserApps)
+                .joinToString(", ")
+
+            "Not all user apps were restored. Missing: $missingApps"
+        }
+
+        // Assert we restored data for all user apps that had successful backups.
+        // This is expected to succeed because we are uninstalling the apps before restoring.
+        val missingFromRestore = backup.userApps
+            .map { it.packageName }
+            .filter { backup.backupResults[it]?.state == PackageState.APK_AND_DATA }
+            .filter { !restore.kv.containsKey(it) && !restore.full.containsKey(it) }
+
+        if (missingFromRestore.isNotEmpty()) {
+            val failedApps = missingFromRestore.joinToString(", ")
+
+            error("Not all user apps had their data restored. Missing: $failedApps")
+        }
+    }
+
+    private fun assertValidFullData(
+        backup: SeedvaultLargeTestResult,
+        restore: SeedvaultLargeTestResult,
+    ) {
+        // Assert all "full" restored data matches the backup data.
+        val allUserPkgs = backup.allUserApps().map { it.packageName }
+
+        restore.full.forEach { (pkg, fullData) ->
+            if (allUserPkgs.contains(pkg)) {
+                assert(backup.full.containsKey(pkg)) {
+                    "Full data for $pkg missing from restore."
+                }
+
+                if (backup.backupResults[pkg]!!.state == PackageState.APK_AND_DATA) {
+                    assert(fullData == backup.full[pkg]!!) {
+                        "Full data for $pkg does not match."
+                    }
+                }
+            }
+        }
+    }
+
+    private fun assertValidKeyValueData(
+        backup: SeedvaultLargeTestResult,
+        restore: SeedvaultLargeTestResult,
+    ) {
+        // Assert all "key/value" restored data matches the backup data.
+        restore.kv.forEach { (pkg, kvData) ->
+            assert(backup.kv.containsKey(pkg)) {
+                "KV data for $pkg missing from backup."
+            }
+
+            kvData.forEach { (key, value) ->
+                assert(backup.kv[pkg]!!.containsKey(key)) {
+                    "KV data for $pkg/$key exists in restore but is missing from backup."
+                }
+
+                assert(value.contentEquals(backup.kv[pkg]!![key]!!)) {
+                    "KV data for $pkg/$key does not match."
+                }
+            }
+        }
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/BackupDataInputIntercept.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/BackupDataInputIntercept.kt
new file mode 100644
index 0000000..2277fff
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/BackupDataInputIntercept.kt
@@ -0,0 +1,24 @@
+package com.stevesoltys.seedvault.e2e.io
+
+import android.app.backup.BackupDataInput
+import java.io.FileDescriptor
+
+class BackupDataInputIntercept(
+    fileDescriptor: FileDescriptor,
+    private val callback: (String, ByteArray) -> Unit,
+) : BackupDataInput(fileDescriptor) {
+
+    var currentKey: String? = null
+
+    override fun getKey(): String? {
+        currentKey = super.getKey()
+        return currentKey
+    }
+
+    override fun readEntityData(data: ByteArray, offset: Int, size: Int): Int {
+        val result = super.readEntityData(data, offset, size)
+
+        callback(currentKey!!, data.copyOf(result))
+        return result
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/BackupDataOutputIntercept.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/BackupDataOutputIntercept.kt
new file mode 100644
index 0000000..0da5880
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/BackupDataOutputIntercept.kt
@@ -0,0 +1,23 @@
+package com.stevesoltys.seedvault.e2e.io
+
+import android.app.backup.BackupDataOutput
+import java.io.FileDescriptor
+
+class BackupDataOutputIntercept(
+    fileDescriptor: FileDescriptor,
+    private val callback: (String, ByteArray) -> Unit,
+) : BackupDataOutput(fileDescriptor) {
+
+    private var currentKey: String? = null
+
+    override fun writeEntityHeader(key: String, dataSize: Int): Int {
+        currentKey = key
+        return super.writeEntityHeader(key, dataSize)
+    }
+
+    override fun writeEntityData(data: ByteArray, size: Int): Int {
+        callback(currentKey!!, data.copyOf())
+
+        return super.writeEntityData(data, size)
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/InputStreamIntercept.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/InputStreamIntercept.kt
new file mode 100644
index 0000000..876c10b
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/InputStreamIntercept.kt
@@ -0,0 +1,26 @@
+package com.stevesoltys.seedvault.e2e.io
+
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+
+class InputStreamIntercept(
+    private val inputStream: InputStream,
+    private val intercept: ByteArrayOutputStream
+) : InputStream() {
+
+    override fun read(): Int {
+        val byte = inputStream.read()
+        if (byte != -1) {
+            intercept.write(byte)
+        }
+        return byte
+    }
+
+    override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
+        val bytesRead = inputStream.read(buffer, offset, length)
+        if (bytesRead != -1) {
+            intercept.write(buffer, offset, bytesRead)
+        }
+        return bytesRead
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/OutputStreamIntercept.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/OutputStreamIntercept.kt
new file mode 100644
index 0000000..601b833
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/io/OutputStreamIntercept.kt
@@ -0,0 +1,20 @@
+package com.stevesoltys.seedvault.e2e.io
+
+import java.io.ByteArrayOutputStream
+import java.io.OutputStream
+
+class OutputStreamIntercept(
+    private val outputStream: OutputStream,
+    private val intercept: ByteArrayOutputStream
+) : OutputStream() {
+
+    override fun write(byte: Int) {
+        intercept.write(byte)
+        outputStream.write(byte)
+    }
+
+    override fun write(buffer: ByteArray, offset: Int, length: Int) {
+        intercept.write(buffer, offset, length)
+        outputStream.write(buffer, offset, length)
+    }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/UiDeviceScreen.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/UiDeviceScreen.kt
new file mode 100644
index 0000000..7a82621
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/UiDeviceScreen.kt
@@ -0,0 +1,33 @@
+package com.stevesoltys.seedvault.e2e.screen
+
+import android.widget.ScrollView
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiObject
+import androidx.test.uiautomator.UiScrollable
+import androidx.test.uiautomator.UiSelector
+import java.lang.Thread.sleep
+
+abstract class UiDeviceScreen<T> {
+
+    operator fun invoke(function: T.() -> Unit) {
+        function.invoke(this as T)
+    }
+
+    fun UiObject.scrollTo(
+        scrollSelector: UiSelector = UiSelector().className(ScrollView::class.java),
+    ): UiObject {
+        UiScrollable(scrollSelector).scrollIntoView(this)
+        waitForExists(15000)
+        sleep(2000)
+        return this
+    }
+
+    fun findObject(
+        block: UiSelector.() -> UiSelector,
+    ): UiObject = device().findObject(
+        UiSelector().let { it.block() }
+    )
+
+    private fun device() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/BackupScreen.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/BackupScreen.kt
new file mode 100644
index 0000000..dc33be7
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/BackupScreen.kt
@@ -0,0 +1,14 @@
+package com.stevesoltys.seedvault.e2e.screen.impl
+
+import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
+
+object BackupScreen : UiDeviceScreen<BackupScreen>() {
+
+    val backupMenu = findObject { description("More options") }
+
+    val backupNowButton = findObject { text("Backup now") }
+
+    val backupStatusButton = findObject { text("Backup status") }
+
+    val backupLocationButton = findObject { text("Backup location") }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/DocumentPickerScreen.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/DocumentPickerScreen.kt
new file mode 100644
index 0000000..050e57b
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/DocumentPickerScreen.kt
@@ -0,0 +1,19 @@
+package com.stevesoltys.seedvault.e2e.screen.impl
+
+import android.widget.EditText
+import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
+
+object DocumentPickerScreen : UiDeviceScreen<DocumentPickerScreen>() {
+
+    val createNewFolderButton = findObject { text("CREATE NEW FOLDER") }
+
+    val useThisFolderButton = findObject { text("USE THIS FOLDER") }
+
+    val textBox = findObject { className(EditText::class.java) }
+
+    val okButton = findObject { text("OK") }
+
+    val allowButton = findObject { text("ALLOW") }
+
+    fun existingFolder(folderName: String) = findObject { text(folderName) }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RecoveryCodeScreen.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RecoveryCodeScreen.kt
new file mode 100644
index 0000000..6804c5c
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RecoveryCodeScreen.kt
@@ -0,0 +1,12 @@
+package com.stevesoltys.seedvault.e2e.screen.impl
+
+import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
+
+object RecoveryCodeScreen : UiDeviceScreen<RecoveryCodeScreen>() {
+
+    val confirmCodeButton = findObject { text("Confirm code") }
+
+    val verifyCodeButton = findObject { text("Verify") }
+
+    fun wordTextField(index: Int) = findObject { text("Word ${index + 1}") }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RestoreScreen.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RestoreScreen.kt
new file mode 100644
index 0000000..2091475
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RestoreScreen.kt
@@ -0,0 +1,14 @@
+package com.stevesoltys.seedvault.e2e.screen.impl
+
+import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
+
+object RestoreScreen : UiDeviceScreen<RestoreScreen>() {
+
+    val backupListItem = findObject { textContains("Last backup") }
+
+    val nextButton = findObject { text("Next") }
+
+    val finishButton = findObject { text("Finish") }
+
+    val skipButton = findObject { text("Skip restoring files") }
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt
index d6bdf68..353d768 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt
@@ -7,6 +7,7 @@
 import android.provider.DocumentsContract.EXTRA_LOADING
 import androidx.documentfile.provider.DocumentFile
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
 import androidx.test.platform.app.InstrumentationRegistry
 import com.stevesoltys.seedvault.assertReadEquals
 import com.stevesoltys.seedvault.coAssertThrows
@@ -39,6 +40,7 @@
 
 @RunWith(AndroidJUnit4::class)
 @Suppress("BlockingMethodInNonBlockingContext")
+@MediumTest
 class DocumentsStorageTest : KoinComponent {
 
     private val context = InstrumentationRegistry.getInstrumentation().targetContext
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt
index 64ea9c4..f140eb2 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt
@@ -2,12 +2,14 @@
 
 import android.util.Log
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 
 @RunWith(AndroidJUnit4::class)
+@MediumTest
 class PackageServiceTest : KoinComponent {
 
     private val packageService: PackageService by inject()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index 69d3b6d..c035481 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -84,21 +84,21 @@
     protected open fun startKoin() = startKoin {
         androidLogger(Level.ERROR)
         androidContext(this@App)
-        modules(
-            listOf(
-                cryptoModule,
-                headerModule,
-                metadataModule,
-                documentsProviderModule, // storage plugin
-                backupModule,
-                restoreModule,
-                installModule,
-                storageModule,
-                appModule
-            )
-        )
+        modules(appModules())
     }
 
+    open fun appModules() = listOf(
+        cryptoModule,
+        headerModule,
+        metadataModule,
+        documentsProviderModule, // storage plugin
+        backupModule,
+        restoreModule,
+        installModule,
+        storageModule,
+        appModule
+    )
+
     private val settingsManager: SettingsManager by inject()
     private val metadataManager: MetadataManager by inject()
 
diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/CryptoModule.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/CryptoModule.kt
index d15c960..f484bf6 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/crypto/CryptoModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/CryptoModule.kt
@@ -3,7 +3,7 @@
 import org.koin.dsl.module
 import java.security.KeyStore
 
-private const val ANDROID_KEY_STORE = "AndroidKeyStore"
+const val ANDROID_KEY_STORE = "AndroidKeyStore"
 
 val cryptoModule = module {
     factory<CipherFactory> { CipherFactoryImpl(get()) }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt
index eded4ce..4b605fe 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt
@@ -14,8 +14,8 @@
 
 internal const val KEY_SIZE = 256
 internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
-private const val KEY_ALIAS_BACKUP = "com.stevesoltys.seedvault"
-private const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main"
+internal const val KEY_ALIAS_BACKUP = "com.stevesoltys.seedvault"
+internal const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main"
 private const val KEY_ALGORITHM_BACKUP = "AES"
 private const val KEY_ALGORITHM_MAIN = "HmacSHA256"
 
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
index a1fa0ca..e26e61d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
@@ -60,8 +60,17 @@
      * Should only be called by the [BackupCoordinator]
      * to ensure that related work is performed after moving to a new token.
      */
-    fun setNewToken(newToken: Long) {
-        prefs.edit().putLong(PREF_KEY_TOKEN, newToken).apply()
+    fun setNewToken(newToken: Long?) {
+        if (newToken == null) {
+            prefs.edit()
+                .remove(PREF_KEY_TOKEN)
+                .apply()
+        } else {
+            prefs.edit()
+                .putLong(PREF_KEY_TOKEN, newToken)
+                .apply()
+        }
+
         token = newToken
     }
 
diff --git a/gradle.properties b/gradle.properties
index c6adc6d..8af1ea3 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,5 +1,8 @@
-org.gradle.jvmargs=-Xmx1g
+org.gradle.jvmargs=-Xmx4g
 org.gradle.configureondemand=true
+org.gradle.caching=true
+org.gradle.parallel=true
+org.gradle.daemon=true
 android.useAndroidX=true
 android.enableJetifier=false
 kotlin.code.style=official
diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle
index cb1f6a1..e132341 100644
--- a/gradle/dependencies.gradle
+++ b/gradle/dependencies.gradle
@@ -23,7 +23,7 @@
     'android.jar',
     // out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar
     'libcore.jar',
-], dir: "$projectDir/app/libs")
+], dir: "${rootProject.projectDir}/app/libs")
 
 ext.kotlin_libs = [
     std: [
diff --git a/settings.gradle b/settings.gradle
index ae99556..0724391 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -18,3 +18,18 @@
 include ':contactsbackup'
 include ':storage:lib'
 include ':storage:demo'
+
+ext.isCiServer = System.getenv().containsKey("CIRRUS_CI")
+ext.isMasterBranch = System.getenv().getOrDefault("CIRRUS_BRANCH", "").matches("android[0-9]+")
+ext.buildCacheHost = System.getenv().getOrDefault("CIRRUS_HTTP_CACHE_HOST", "localhost:12321")
+
+buildCache {
+    local {
+        enabled = !isCiServer
+    }
+    remote(HttpBuildCache) {
+        url = "http://${buildCacheHost}/"
+        enabled = isCiServer
+        push = isMasterBranch
+    }
+}