Merge pull request #553 from seedvault-app/feature/e2e-test
Add end-to-end emulator test running on CI
This commit is contained in:
commit
ba6dc2ff91
35 changed files with 1230 additions and 43 deletions
58
.cirrus.yml
Normal file
58
.cirrus.yml
Normal file
|
@ -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"
|
|
@ -17,7 +17,7 @@
|
||||||
<option name="TARGET_SELECTION_MODE" value="SHOW_DIALOG" />
|
<option name="TARGET_SELECTION_MODE" value="SHOW_DIALOG" />
|
||||||
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
|
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
|
||||||
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
|
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
|
||||||
<option name="DEBUGGER_TYPE" value="Auto" />
|
<option name="DEBUGGER_TYPE" value="Java" />
|
||||||
<Auto>
|
<Auto>
|
||||||
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
|
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
|
||||||
<option name="SHOW_STATIC_VARS" value="true" />
|
<option name="SHOW_STATIC_VARS" value="true" />
|
||||||
|
@ -37,7 +37,7 @@
|
||||||
<option name="DEBUG_SANDBOX_SDK" value="false" />
|
<option name="DEBUG_SANDBOX_SDK" value="false" />
|
||||||
</Hybrid>
|
</Hybrid>
|
||||||
<Java>
|
<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" />
|
<option name="DEBUG_SANDBOX_SDK" value="false" />
|
||||||
</Java>
|
</Java>
|
||||||
<Native>
|
<Native>
|
||||||
|
@ -62,7 +62,7 @@
|
||||||
<option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
|
<option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
|
||||||
<option name="SKIP_ACTIVITY_VALIDATION" value="false" />
|
<option name="SKIP_ACTIVITY_VALIDATION" value="false" />
|
||||||
<method v="2">
|
<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>
|
</method>
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
|
@ -21,8 +21,15 @@ android {
|
||||||
minSdk 32 // leave at 32 for robolectric tests
|
minSdk 32 // leave at 32 for robolectric tests
|
||||||
targetSdk rootProject.ext.targetSdk
|
targetSdk rootProject.ext.targetSdk
|
||||||
versionNameSuffix "-$gitDescribe"
|
versionNameSuffix "-$gitDescribe"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
|
||||||
testInstrumentationRunnerArguments disableAnalytics: 'true'
|
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 {
|
buildTypes {
|
||||||
|
@ -150,10 +157,12 @@ dependencies {
|
||||||
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
|
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
|
||||||
testRuntimeOnly "org.junit.vintage:junit-vintage-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:runner:1.4.0'
|
||||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
|
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
|
||||||
|
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "${rootProject.rootDir}/gradle/ktlint.gradle"
|
apply from: "${rootProject.rootDir}/gradle/ktlint.gradle"
|
||||||
|
@ -182,7 +191,7 @@ tasks.register('provisionEmulator', Exec) {
|
||||||
"seedvault",
|
"seedvault",
|
||||||
"system-images;android-33;google_apis;x86_64"
|
"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']
|
environment "JAVA_HOME", System.properties['java.home']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -193,7 +202,7 @@ tasks.register('startEmulator', Exec) {
|
||||||
doFirst {
|
doFirst {
|
||||||
commandLine "${project.projectDir}/development/scripts/start_emulator.sh", "seedvault"
|
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']
|
environment "JAVA_HOME", System.properties['java.home']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -206,7 +215,18 @@ tasks.register('installEmulatorRelease', Exec) {
|
||||||
doFirst {
|
doFirst {
|
||||||
commandLine "${project.projectDir}/development/scripts/install_app.sh"
|
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']
|
environment "JAVA_HOME", System.properties['java.home']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
22
app/development/scripts/clear_app_data.sh
Executable file
22
app/development/scripts/clear_app_data.sh
Executable file
|
@ -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
|
|
@ -1,8 +1,8 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# assert ANDROID_HOME is set
|
# assert ANDROID_HOME is set
|
||||||
if [ -z "$ANDROID_SDK_HOME" ]; then
|
if [ -z "$ANDROID_HOME" ]; then
|
||||||
echo "ANDROID_SDK_HOME is not set"
|
echo "ANDROID_HOME is not set"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -10,14 +10,14 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
|
||||||
DEVELOPMENT_DIR=$SCRIPT_DIR/..
|
DEVELOPMENT_DIR=$SCRIPT_DIR/..
|
||||||
ROOT_PROJECT_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
|
if [ -z "$EMULATOR_DEVICE_NAME" ]; then
|
||||||
echo "Emulator device name not found"
|
echo "Emulator device name not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ADB="$ANDROID_SDK_HOME/platform-tools/adb -s $EMULATOR_DEVICE_NAME"
|
ADB="$ANDROID_HOME/platform-tools/adb -s $EMULATOR_DEVICE_NAME"
|
||||||
|
|
||||||
$ADB root
|
$ADB root
|
||||||
sleep 3 # wait for adb to restart
|
sleep 3 # wait for adb to restart
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# assert ANDROID_HOME is set
|
# assert ANDROID_HOME is set
|
||||||
if [ -z "$ANDROID_SDK_HOME" ]; then
|
if [ -z "$ANDROID_HOME" ]; then
|
||||||
echo "ANDROID_SDK_HOME is not set"
|
echo "ANDROID_HOME is not set"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -20,30 +20,29 @@ DEVELOPMENT_DIR=$SCRIPT_DIR/..
|
||||||
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
|
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
|
||||||
|
|
||||||
echo "Downloading system image..."
|
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
|
# 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."
|
echo "AVD already exists. Skipping creation."
|
||||||
else
|
else
|
||||||
echo "Creating AVD..."
|
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
|
sleep 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting emulator..."
|
|
||||||
$SCRIPT_DIR/start_emulator.sh "$EMULATOR_NAME"
|
$SCRIPT_DIR/start_emulator.sh "$EMULATOR_NAME"
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
# get emulator device name from ADB
|
# 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
|
if [ -z "$EMULATOR_DEVICE_NAME" ]; then
|
||||||
echo "Emulator device name not found"
|
echo "Emulator device name not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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..."
|
echo "Waiting for emulator to boot..."
|
||||||
$ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
|
$ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
|
||||||
|
@ -68,4 +67,13 @@ echo "Rebooting emulator..."
|
||||||
$ADB reboot
|
$ADB reboot
|
||||||
$ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
|
$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!"
|
echo "Emulator '$EMULATOR_NAME' has been provisioned with Seedvault!"
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# assert ANDROID_HOME is set
|
# assert ANDROID_HOME is set
|
||||||
if [ -z "$ANDROID_SDK_HOME" ]; then
|
if [ -z "$ANDROID_HOME" ]; then
|
||||||
echo "ANDROID_SDK_HOME is not set"
|
echo "ANDROID_HOME is not set"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -19,4 +19,4 @@ DEVELOPMENT_DIR=$SCRIPT_DIR/..
|
||||||
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
|
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
|
||||||
|
|
||||||
echo "Starting emulator..."
|
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 &
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<application android:extractNativeLibs="true" />
|
<application android:extractNativeLibs="true" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package com.stevesoltys.seedvault
|
||||||
|
|
||||||
import androidx.test.core.content.pm.PackageInfoBuilder
|
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
@ -28,6 +29,7 @@ import org.koin.core.component.inject
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
@MediumTest
|
||||||
class PluginTest : KoinComponent {
|
class PluginTest : KoinComponent {
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(" ")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
|
@ -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") }
|
||||||
|
}
|
|
@ -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) }
|
||||||
|
}
|
|
@ -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}") }
|
||||||
|
}
|
|
@ -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") }
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import android.os.Bundle
|
||||||
import android.provider.DocumentsContract.EXTRA_LOADING
|
import android.provider.DocumentsContract.EXTRA_LOADING
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.stevesoltys.seedvault.assertReadEquals
|
import com.stevesoltys.seedvault.assertReadEquals
|
||||||
import com.stevesoltys.seedvault.coAssertThrows
|
import com.stevesoltys.seedvault.coAssertThrows
|
||||||
|
@ -39,6 +40,7 @@ import kotlin.random.Random
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
@MediumTest
|
||||||
class DocumentsStorageTest : KoinComponent {
|
class DocumentsStorageTest : KoinComponent {
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
|
@ -2,12 +2,14 @@ package com.stevesoltys.seedvault.transport.backup
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
class PackageServiceTest : KoinComponent {
|
class PackageServiceTest : KoinComponent {
|
||||||
|
|
||||||
private val packageService: PackageService by inject()
|
private val packageService: PackageService by inject()
|
||||||
|
|
|
@ -84,21 +84,21 @@ open class App : Application() {
|
||||||
protected open fun startKoin() = startKoin {
|
protected open fun startKoin() = startKoin {
|
||||||
androidLogger(Level.ERROR)
|
androidLogger(Level.ERROR)
|
||||||
androidContext(this@App)
|
androidContext(this@App)
|
||||||
modules(
|
modules(appModules())
|
||||||
listOf(
|
|
||||||
cryptoModule,
|
|
||||||
headerModule,
|
|
||||||
metadataModule,
|
|
||||||
documentsProviderModule, // storage plugin
|
|
||||||
backupModule,
|
|
||||||
restoreModule,
|
|
||||||
installModule,
|
|
||||||
storageModule,
|
|
||||||
appModule
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open fun appModules() = listOf(
|
||||||
|
cryptoModule,
|
||||||
|
headerModule,
|
||||||
|
metadataModule,
|
||||||
|
documentsProviderModule, // storage plugin
|
||||||
|
backupModule,
|
||||||
|
restoreModule,
|
||||||
|
installModule,
|
||||||
|
storageModule,
|
||||||
|
appModule
|
||||||
|
)
|
||||||
|
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
private val metadataManager: MetadataManager by inject()
|
private val metadataManager: MetadataManager by inject()
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ package com.stevesoltys.seedvault.crypto
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
|
|
||||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||||
|
|
||||||
val cryptoModule = module {
|
val cryptoModule = module {
|
||||||
factory<CipherFactory> { CipherFactoryImpl(get()) }
|
factory<CipherFactory> { CipherFactoryImpl(get()) }
|
||||||
|
|
|
@ -14,8 +14,8 @@ import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
internal const val KEY_SIZE = 256
|
internal const val KEY_SIZE = 256
|
||||||
internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
|
internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
|
||||||
private const val KEY_ALIAS_BACKUP = "com.stevesoltys.seedvault"
|
internal const val KEY_ALIAS_BACKUP = "com.stevesoltys.seedvault"
|
||||||
private const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main"
|
internal const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main"
|
||||||
private const val KEY_ALGORITHM_BACKUP = "AES"
|
private const val KEY_ALGORITHM_BACKUP = "AES"
|
||||||
private const val KEY_ALGORITHM_MAIN = "HmacSHA256"
|
private const val KEY_ALGORITHM_MAIN = "HmacSHA256"
|
||||||
|
|
||||||
|
|
|
@ -60,8 +60,17 @@ class SettingsManager(private val context: Context) {
|
||||||
* Should only be called by the [BackupCoordinator]
|
* Should only be called by the [BackupCoordinator]
|
||||||
* to ensure that related work is performed after moving to a new token.
|
* to ensure that related work is performed after moving to a new token.
|
||||||
*/
|
*/
|
||||||
fun setNewToken(newToken: Long) {
|
fun setNewToken(newToken: Long?) {
|
||||||
prefs.edit().putLong(PREF_KEY_TOKEN, newToken).apply()
|
if (newToken == null) {
|
||||||
|
prefs.edit()
|
||||||
|
.remove(PREF_KEY_TOKEN)
|
||||||
|
.apply()
|
||||||
|
} else {
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(PREF_KEY_TOKEN, newToken)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
token = newToken
|
token = newToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
org.gradle.jvmargs=-Xmx1g
|
org.gradle.jvmargs=-Xmx4g
|
||||||
org.gradle.configureondemand=true
|
org.gradle.configureondemand=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.daemon=true
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
|
@ -23,7 +23,7 @@ ext.aosp_libs = fileTree(include: [
|
||||||
'android.jar',
|
'android.jar',
|
||||||
// out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar
|
// out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar
|
||||||
'libcore.jar',
|
'libcore.jar',
|
||||||
], dir: "$projectDir/app/libs")
|
], dir: "${rootProject.projectDir}/app/libs")
|
||||||
|
|
||||||
ext.kotlin_libs = [
|
ext.kotlin_libs = [
|
||||||
std: [
|
std: [
|
||||||
|
|
|
@ -18,3 +18,18 @@ include ':app'
|
||||||
include ':contactsbackup'
|
include ':contactsbackup'
|
||||||
include ':storage:lib'
|
include ':storage:lib'
|
||||||
include ':storage:demo'
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue