Add storage library (and demo app)
and use for periodic files backup
This commit is contained in:
parent
1cd3a3a0e6
commit
6c633b70c3
179 changed files with 9668 additions and 182 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -6,7 +6,7 @@ hs_err_pid*
|
|||
|
||||
## Intellij
|
||||
out/
|
||||
lib/
|
||||
/lib/
|
||||
.idea/*
|
||||
!.idea/runConfigurations*
|
||||
!.idea/inspectionProfiles*
|
||||
|
@ -33,7 +33,8 @@ local.properties
|
|||
|
||||
## NetBeans
|
||||
**/nbproject/private/
|
||||
build/
|
||||
/build/
|
||||
/app/build/
|
||||
nbbuild/
|
||||
dist/
|
||||
nbdist/
|
||||
|
@ -50,6 +51,3 @@ gradle-app.setting
|
|||
|
||||
## Android
|
||||
gen/
|
||||
|
||||
## Prebuilt
|
||||
Backup.apk
|
||||
|
|
8
.idea/runConfigurations/All_unit_tests.xml
Normal file
8
.idea/runConfigurations/All_unit_tests.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="All unit tests" type="CompoundRunConfigurationType">
|
||||
<toRun name="Unit tests: storage/lib" type="AndroidJUnit" />
|
||||
<toRun name="Unit tests: app" type="AndroidJUnit" />
|
||||
<toRun name="Unit tests: contactsbackup" type="AndroidJUnit" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
|
@ -1,5 +1,5 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests" singleton="true">
|
||||
<configuration default="false" name="Instrumentation tests: app" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests" singleton="true">
|
||||
<module name="seedvault.app" />
|
||||
<option name="TESTING_TYPE" value="0" />
|
||||
<option name="METHOD_NAME" value="" />
|
|
@ -1,5 +1,5 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit">
|
||||
<configuration default="false" name="Unit tests: app" type="AndroidJUnit" factoryName="Android JUnit">
|
||||
<module name="seedvault.app" />
|
||||
<useClassPathOnly />
|
||||
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
|
15
.idea/runConfigurations/Unit_tests__contactsbackup.xml
Normal file
15
.idea/runConfigurations/Unit_tests__contactsbackup.xml
Normal file
|
@ -0,0 +1,15 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Unit tests: contactsbackup" type="AndroidJUnit" factoryName="Android JUnit">
|
||||
<module name="seedvault.contactsbackup" />
|
||||
<useClassPathOnly />
|
||||
<option name="MAIN_CLASS_NAME" value="" />
|
||||
<option name="METHOD_NAME" value="" />
|
||||
<option name="TEST_OBJECT" value="directory" />
|
||||
<option name="PARAMETERS" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
|
||||
<dir value="$PROJECT_DIR$/contactsbackup/src/test/java" />
|
||||
<method v="2">
|
||||
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
15
.idea/runConfigurations/Unit_tests__storage_lib.xml
Normal file
15
.idea/runConfigurations/Unit_tests__storage_lib.xml
Normal file
|
@ -0,0 +1,15 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Unit tests: storage/lib" type="AndroidJUnit" factoryName="Android JUnit">
|
||||
<module name="seedvault.storage.lib" />
|
||||
<useClassPathOnly />
|
||||
<option name="MAIN_CLASS_NAME" value="" />
|
||||
<option name="METHOD_NAME" value="" />
|
||||
<option name="TEST_OBJECT" value="directory" />
|
||||
<option name="PARAMETERS" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
|
||||
<dir value="$PROJECT_DIR$/storage/lib/src/test/java" />
|
||||
<method v="2">
|
||||
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
|
@ -0,0 +1,21 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="seedvault:storage:lib [assembleRelease]" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/storage/lib" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value="assembleRelease" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" value="" />
|
||||
</ExternalSystemSettings>
|
||||
<GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
|
@ -31,9 +31,16 @@ android_app {
|
|||
"androidx.lifecycle_lifecycle-livedata-ktx",
|
||||
"androidx-constraintlayout_constraintlayout",
|
||||
"com.google.android.material_material",
|
||||
// storage
|
||||
"seedvault-lib-storage", // did not manage to add this as transitive dependency
|
||||
"seedvault-lib-tink-android",
|
||||
"androidx.room_room-runtime",
|
||||
"libprotobuf-java-lite",
|
||||
// koin
|
||||
"seedvault-lib-koin-core", // did not manage to add this as transitive dependency
|
||||
"seedvault-lib-koin-android",
|
||||
"seedvault-lib-koin-androidx-viewmodel",
|
||||
// bip39
|
||||
"seedvault-lib-novacrypto-bip39",
|
||||
],
|
||||
manifest: "app/src/main/AndroidManifest.xml",
|
||||
|
|
|
@ -30,6 +30,9 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
|
|||
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup.
|
||||
* `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup.
|
||||
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
|
||||
* `android.permission.MANAGE_EXTERNAL_STORAGE` to backup and restore files from device storage.
|
||||
* `android.permission.ACCESS_MEDIA_LOCATION` to backup original media files e.g. without stripped EXIF metadata.
|
||||
* `android.permission.FOREGROUND_SERVICE` to do periodic storage backups without interruption.
|
||||
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX.
|
||||
|
||||
## Contributing
|
||||
|
|
|
@ -16,13 +16,12 @@ def gitDescribe = { ->
|
|||
}
|
||||
|
||||
android {
|
||||
|
||||
compileSdkVersion 30
|
||||
buildToolsVersion '30.0.2'
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 29 // leave at 29 for robolectric tests
|
||||
targetSdkVersion 30
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionNameSuffix "-$gitDescribe"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunnerArguments disableAnalytics: 'true'
|
||||
|
@ -44,6 +43,7 @@ android {
|
|||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
languageVersion = "1.3"
|
||||
}
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
|
@ -81,7 +81,72 @@ android {
|
|||
buildTypes.debug.signingConfig = signingConfigs.aosp
|
||||
}
|
||||
|
||||
apply from: '../gradle/dependencies.gradle'
|
||||
dependencies {
|
||||
compileOnly rootProject.ext.aosp_libs
|
||||
|
||||
/**
|
||||
* Dependencies in AOSP
|
||||
*
|
||||
* We try to keep the dependencies in sync with what AOSP ships as Seedvault is meant to be built
|
||||
* with the AOSP build system and gradle builds are just for more pleasant development.
|
||||
* Using the AOSP versions in gradle builds allows us to spot issues early on.
|
||||
*/
|
||||
implementation rootProject.ext.kotlin_libs.std
|
||||
// These coroutine libraries get upgraded otherwise to versions incompatible with kotlin version
|
||||
implementation rootProject.ext.kotlin_libs.coroutines
|
||||
|
||||
implementation rootProject.ext.std_libs.androidx_core
|
||||
// A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
|
||||
implementation rootProject.ext.std_libs.androidx_fragment
|
||||
implementation rootProject.ext.std_libs.androidx_preference
|
||||
implementation rootProject.ext.std_libs.androidx_lifecycle_viewmodel_ktx
|
||||
implementation rootProject.ext.std_libs.androidx_lifecycle_livedata_ktx
|
||||
implementation rootProject.ext.std_libs.androidx_constraintlayout
|
||||
implementation rootProject.ext.std_libs.androidx_documentfile
|
||||
implementation rootProject.ext.std_libs.com_google_android_material
|
||||
|
||||
/**
|
||||
* Storage Dependencies
|
||||
*/
|
||||
implementation project(':storage:lib')
|
||||
// implementation fileTree(include: ['storage.aar'], dir: "${rootProject.rootDir}/storage/lib/libs")
|
||||
|
||||
/**
|
||||
* External Dependencies
|
||||
*
|
||||
* If the dependencies below are updated,
|
||||
* please make sure to update the prebuilt libraries and the Android.bp files
|
||||
* in the top-level `libs` folder to reflect that.
|
||||
* You can copy these libraries from ~/.gradle/caches/modules-2
|
||||
*/
|
||||
// later versions than 2.1.1 require newer kotlin version
|
||||
implementation fileTree(include: ['*.jar'], dir: "${rootProject.rootDir}/libs/koin-android")
|
||||
implementation fileTree(include: ['*.aar'], dir: "${rootProject.rootDir}/libs/koin-android")
|
||||
|
||||
implementation fileTree(include: ['*.jar'], dir: "${rootProject.rootDir}/libs/novacrypto-bip39")
|
||||
|
||||
/**
|
||||
* Test Dependencies (do not concern the AOSP build)
|
||||
*/
|
||||
lintChecks rootProject.ext.lint_libs.exceptions
|
||||
|
||||
// anything less than 'implementation' fails tests run with gradlew
|
||||
testImplementation rootProject.ext.aosp_libs
|
||||
testImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
testImplementation('org.robolectric:robolectric:4.3.1') { // 4.4 has issue with non-idle Looper
|
||||
// https://github.com/robolectric/robolectric/issues/5245
|
||||
exclude group: "com.google.auto.service", module: "auto-service"
|
||||
}
|
||||
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5_version"
|
||||
testImplementation "io.mockk:mockk:$mockk_version"
|
||||
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
|
||||
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit5_version"
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.3.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.3.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version = "0.36.0" // https://github.com/pinterest/ktlint/issues/764
|
||||
|
|
|
@ -113,5 +113,18 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Used to start actual BackupService depending on scheduling criteria -->
|
||||
<service
|
||||
android:name=".storage.StorageBackupJobService"
|
||||
android:exported="false"
|
||||
android:label="BackupJobService"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
<!-- Does the actual backup work as a foreground service -->
|
||||
<service
|
||||
android:name=".storage.StorageBackupService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="BackupService" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -17,8 +17,10 @@ import com.stevesoltys.seedvault.restore.install.installModule
|
|||
import com.stevesoltys.seedvault.settings.AppListRetriever
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.settings.SettingsViewModel
|
||||
import com.stevesoltys.seedvault.storage.storageModule
|
||||
import com.stevesoltys.seedvault.transport.backup.backupModule
|
||||
import com.stevesoltys.seedvault.transport.restore.restoreModule
|
||||
import com.stevesoltys.seedvault.ui.files.FileSelectionViewModel
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
|
||||
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
|
||||
|
@ -43,11 +45,12 @@ open class App : Application() {
|
|||
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
||||
factory { AppListRetriever(this@App, get(), get(), get()) }
|
||||
|
||||
viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get()) }
|
||||
viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
|
||||
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
|
||||
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
|
||||
viewModel { FileSelectionViewModel(this@App, get()) }
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
|
@ -85,6 +88,7 @@ open class App : Application() {
|
|||
backupModule,
|
||||
restoreModule,
|
||||
installModule,
|
||||
storageModule,
|
||||
appModule
|
||||
)
|
||||
)
|
||||
|
|
|
@ -36,7 +36,7 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
|
|||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): View {
|
||||
setHasOptionsMenu(true)
|
||||
val v: View = inflater.inflate(R.layout.fragment_app_status, container, false)
|
||||
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
package com.stevesoltys.seedvault.settings
|
||||
|
||||
import android.app.backup.IBackupManager
|
||||
import android.content.Context.BACKUP_SERVICE // ktlint-disable no-unused-imports
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.Settings
|
||||
import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE // ktlint-disable no-unused-imports
|
||||
import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
|
@ -38,6 +37,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
private lateinit var apkBackup: TwoStatePreference
|
||||
private lateinit var backupLocation: Preference
|
||||
private lateinit var backupStatus: Preference
|
||||
private lateinit var backupStorage: TwoStatePreference
|
||||
private lateinit var backupRecoveryCode: Preference
|
||||
|
||||
private var menuBackupNow: MenuItem? = null
|
||||
private var menuRestore: MenuItem? = null
|
||||
|
@ -102,6 +103,19 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
return@OnPreferenceChangeListener false
|
||||
}
|
||||
backupStatus = findPreference("backup_status")!!
|
||||
|
||||
backupStorage = findPreference("backup_storage")!!
|
||||
backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
val disable = !(newValue as Boolean)
|
||||
if (disable) {
|
||||
viewModel.disableStorageBackup()
|
||||
return@OnPreferenceChangeListener true
|
||||
}
|
||||
onEnablingStorageBackup()
|
||||
return@OnPreferenceChangeListener false
|
||||
}
|
||||
|
||||
backupRecoveryCode = findPreference("backup_recovery_code")!!
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
@ -110,6 +124,11 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
viewModel.lastBackupTime.observe(viewLifecycleOwner, Observer { time ->
|
||||
setAppBackupStatusSummary(time)
|
||||
})
|
||||
|
||||
val backupFiles: Preference = findPreference("backup_files")!!
|
||||
viewModel.filesSummary.observe(viewLifecycleOwner, Observer { summary ->
|
||||
backupFiles.summary = summary
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
@ -190,4 +209,40 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup)
|
||||
}
|
||||
|
||||
private fun onEnablingStorageBackup() {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setTitle(R.string.settings_backup_storage_dialog_title)
|
||||
.setMessage(R.string.settings_backup_storage_dialog_message)
|
||||
.setPositiveButton(R.string.settings_backup_storage_dialog_ok) { dialog, _ ->
|
||||
if (viewModel.hasMainKey()) {
|
||||
viewModel.enableStorageBackup()
|
||||
backupStorage.isChecked = true
|
||||
} else {
|
||||
showCodeVerificationNeededDialog()
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showCodeVerificationNeededDialog() {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setIcon(R.drawable.ic_vpn_key)
|
||||
.setTitle(R.string.settings_backup_storage_code_dialog_title)
|
||||
.setMessage(R.string.settings_backup_storage_code_dialog_message)
|
||||
.setPositiveButton(R.string.settings_backup_storage_code_dialog_ok) { dialog, _ ->
|
||||
val callback = (requireActivity() as OnPreferenceStartFragmentCallback)
|
||||
callback.onPreferenceStartFragment(this, backupRecoveryCode)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.stevesoltys.seedvault.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.app.job.JobInfo.NETWORK_TYPE_UNMETERED
|
||||
import android.database.ContentObserver
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
|
@ -22,11 +23,15 @@ import com.stevesoltys.seedvault.R
|
|||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||
import com.stevesoltys.seedvault.transport.requestBackup
|
||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.backup.BackupJobService
|
||||
import java.util.concurrent.TimeUnit.HOURS
|
||||
|
||||
private const val TAG = "SettingsViewModel"
|
||||
private const val USER_FULL_DATA_BACKUP_AWARE = "user_full_data_backup_aware"
|
||||
|
@ -37,7 +42,8 @@ internal class SettingsViewModel(
|
|||
keyManager: KeyManager,
|
||||
private val notificationManager: BackupNotificationManager,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val appListRetriever: AppListRetriever
|
||||
private val appListRetriever: AppListRetriever,
|
||||
private val storageBackup: StorageBackup
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
|
||||
|
||||
private val contentResolver = app.contentResolver
|
||||
|
@ -59,6 +65,9 @@ internal class SettingsViewModel(
|
|||
private val mAppEditMode = MutableLiveData<Boolean>()
|
||||
internal val appEditMode: LiveData<Boolean> = mAppEditMode
|
||||
|
||||
private val _filesSummary = MutableLiveData<String>()
|
||||
internal val filesSummary: LiveData<String> = _filesSummary
|
||||
|
||||
private val storageObserver = object : ContentObserver(null) {
|
||||
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) {
|
||||
onStorageLocationChanged()
|
||||
|
@ -89,6 +98,7 @@ internal class SettingsViewModel(
|
|||
metadataManager.getLastBackupTime()
|
||||
}
|
||||
onStorageLocationChanged()
|
||||
loadFilesSummary()
|
||||
}
|
||||
|
||||
override fun onStorageLocationChanged() {
|
||||
|
@ -134,8 +144,8 @@ internal class SettingsViewModel(
|
|||
// maybe replace the check below with one that checks if our transport service is running
|
||||
if (notificationManager.hasActiveBackupNotifications()) {
|
||||
Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show()
|
||||
} else {
|
||||
Thread { requestBackup(app) }.start()
|
||||
} else viewModelScope.launch(Dispatchers.IO) {
|
||||
requestBackup(app)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -156,6 +166,14 @@ internal class SettingsViewModel(
|
|||
settingsManager.onAppBackupStatusChanged(status)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun loadFilesSummary() = viewModelScope.launch {
|
||||
val uriSummary = storageBackup.getUriSummaryString()
|
||||
_filesSummary.value = if (uriSummary.isEmpty()) {
|
||||
app.getString(R.string.settings_backup_files_summary)
|
||||
} else uriSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the call log will be included in backups.
|
||||
*
|
||||
|
@ -170,4 +188,21 @@ internal class SettingsViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun hasMainKey(): Boolean {
|
||||
return keyManager.hasMainKey()
|
||||
}
|
||||
|
||||
fun enableStorageBackup() = BackupJobService.scheduleJob(
|
||||
context = app,
|
||||
jobServiceClass = StorageBackupJobService::class.java,
|
||||
periodMillis = HOURS.toMillis(24),
|
||||
networkType = NETWORK_TYPE_UNMETERED,
|
||||
deviceIdle = false,
|
||||
charging = true
|
||||
)
|
||||
|
||||
fun disableStorageBackup() {
|
||||
BackupJobService.cancelJob(app)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
package com.stevesoltys.seedvault.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
internal class SeedvaultStoragePlugin(
|
||||
context: Context,
|
||||
private val storage: DocumentsStorage,
|
||||
private val keyManager: KeyManager
|
||||
) : SafStoragePlugin(context) {
|
||||
override val root: DocumentFile
|
||||
get() = storage.rootBackupDir ?: error("No storage set")
|
||||
|
||||
override fun getMasterKey(): SecretKey = keyManager.getMainKey()
|
||||
override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package com.stevesoltys.seedvault.storage
|
||||
|
||||
import org.calyxos.backup.storage.api.BackupObserver
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.backup.BackupJobService
|
||||
import org.calyxos.backup.storage.backup.BackupService
|
||||
import org.calyxos.backup.storage.backup.NotificationBackupObserver
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
/*
|
||||
test and debug with
|
||||
|
||||
adb shell dumpsys jobscheduler |
|
||||
grep -A 23 -B 4 "Service: com.stevesoltys.seedvault/.storage.StorageBackupJobService"
|
||||
|
||||
force running with:
|
||||
|
||||
adb shell cmd jobscheduler run -f com.stevesoltys.seedvault 0
|
||||
|
||||
*/
|
||||
internal class StorageBackupJobService : BackupJobService(StorageBackupService::class.java)
|
||||
|
||||
internal class StorageBackupService : BackupService() {
|
||||
override val storageBackup: StorageBackup by inject()
|
||||
|
||||
// use lazy delegate because context isn't available during construction time
|
||||
override val backupObserver: BackupObserver by lazy {
|
||||
NotificationBackupObserver(applicationContext)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.stevesoltys.seedvault.storage
|
||||
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.api.StoragePlugin
|
||||
import org.koin.dsl.module
|
||||
|
||||
val storageModule = module {
|
||||
single<StoragePlugin> { SeedvaultStoragePlugin(get(), get(), get()) }
|
||||
single { StorageBackup(get(), get()) }
|
||||
}
|
|
@ -9,7 +9,7 @@ import com.stevesoltys.seedvault.ui.storage.StorageViewModel
|
|||
abstract class RequireProvisioningViewModel(
|
||||
protected val app: Application,
|
||||
protected val settingsManager: SettingsManager,
|
||||
private val keyManager: KeyManager
|
||||
protected val keyManager: KeyManager
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
abstract val isRestoreOperation: Boolean
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
package com.stevesoltys.seedvault.ui.files
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.settings.SettingsViewModel
|
||||
import org.calyxos.backup.storage.ui.backup.BackupContentFragment
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class FileSelectionFragment() : BackupContentFragment() {
|
||||
|
||||
override val viewModel by viewModel<FileSelectionViewModel>()
|
||||
private val settingsViewModel by sharedViewModel<SettingsViewModel>()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
requireActivity().setTitle(R.string.settings_backup_files_title)
|
||||
return super.onCreateView(inflater, container, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// reload files summary when we navigate away (it might have changed)
|
||||
settingsViewModel.loadFilesSummary()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package com.stevesoltys.seedvault.ui.files
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.ui.backup.BackupContentViewModel
|
||||
|
||||
class FileSelectionViewModel(
|
||||
app: Application,
|
||||
override val storageBackup: StorageBackup
|
||||
) : BackupContentViewModel(app) {
|
||||
|
||||
init {
|
||||
viewModelScope.launch { loadContent() }
|
||||
}
|
||||
|
||||
}
|
|
@ -62,7 +62,7 @@ class RecoveryCodeInputFragment : Fragment() {
|
|||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): View {
|
||||
val v: View = inflater.inflate(R.layout.fragment_recovery_code_input, container, false)
|
||||
|
||||
introText = v.findViewById(R.id.introText)
|
||||
|
@ -93,6 +93,8 @@ class RecoveryCodeInputFragment : Fragment() {
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
activity?.setTitle(R.string.recovery_code_title)
|
||||
|
||||
if (viewModel.isRestore) {
|
||||
introText.setText(R.string.recovery_code_input_intro)
|
||||
backView.visibility = VISIBLE
|
||||
|
|
10
app/src/main/res/drawable/ic_library_add.xml
Normal file
10
app/src/main/res/drawable/ic_library_add.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM19,11h-4v4h-2v-4L9,11L9,9h4L13,5h2v4h4v2z" />
|
||||
</vector>
|
10
app/src/main/res/drawable/ic_save_alt.xml
Normal file
10
app/src/main/res/drawable/ic_save_alt.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,12v7L5,19v-7L3,12v7c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2zM13,12.67l2.59,-2.58L17,11.5l-5,5 -5,-5 1.41,-1.41L11,12.67L11,3h2z" />
|
||||
</vector>
|
|
@ -1,7 +1,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
<string name="restore_backup_button">Restore backup</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_backup">Backup my data</string>
|
||||
<string name="settings_category_apps">App backup</string>
|
||||
<string name="settings_backup">Backup my apps</string>
|
||||
<string name="settings_backup_location">Backup location</string>
|
||||
<string name="settings_backup_location_none">None</string>
|
||||
<string name="settings_backup_location_internal">Internal storage</string>
|
||||
|
@ -28,10 +29,20 @@
|
|||
<string name="settings_backup_status_summary">Last backup: %1$s</string>
|
||||
<string name="settings_backup_exclude_apps">Exclude apps</string>
|
||||
<string name="settings_backup_now">Backup now</string>
|
||||
<string name="settings_category_storage">Storage backup (experimental)</string>
|
||||
<string name="settings_backup_storage_title">Backup my files</string>
|
||||
<string name="settings_backup_files_title">Included files and folders</string>
|
||||
<string name="settings_backup_files_summary">None</string>
|
||||
<string name="settings_backup_recovery_code">Recovery code</string>
|
||||
<string name="settings_backup_recovery_code_summary">Verify existing code or generate a new one</string>
|
||||
<string name="settings_backup_storage_dialog_title">Experimental feature</string>
|
||||
<string name="settings_backup_storage_dialog_message">Backing up files is still experimental and might not work. Do not rely on it for important data.</string>
|
||||
<string name="settings_backup_storage_dialog_ok">Enable anyway</string>
|
||||
<string name="settings_backup_storage_code_dialog_title">Recovery code verification required</string>
|
||||
<string name="settings_backup_storage_code_dialog_message">To enable storage backup, you need to first verify your recovery code or generate a new one.</string>
|
||||
<string name="settings_backup_storage_code_dialog_ok">Verify code</string>
|
||||
|
||||
<!-- Storage -->
|
||||
<!-- Storage Location -->
|
||||
<string name="storage_fragment_backup_title">Choose where to store backups</string>
|
||||
<string name="storage_fragment_restore_title">Where to find your backups?</string>
|
||||
<string name="storage_fragment_warning">People with access to your storage location can learn which apps you use, but do not get access to the apps\' data.</string>
|
||||
|
|
|
@ -1,33 +1,43 @@
|
|||
<androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<androidx.preference.Preference
|
||||
app:icon="@drawable/ic_save_alt"
|
||||
app:key="backup_location"
|
||||
app:summary="@string/settings_backup_location_none"
|
||||
app:title="@string/settings_backup_location" />
|
||||
|
||||
<androidx.preference.Preference
|
||||
app:fragment="com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeInputFragment"
|
||||
app:icon="@drawable/ic_vpn_key"
|
||||
app:key="backup_recovery_code"
|
||||
app:summary="@string/settings_backup_recovery_code_summary"
|
||||
app:title="@string/settings_backup_recovery_code" />
|
||||
|
||||
<PreferenceCategory android:title="@string/settings_category_apps">
|
||||
|
||||
<androidx.preference.SwitchPreferenceCompat
|
||||
app:allowDividerBelow="true"
|
||||
app:icon="@drawable/ic_cloud_upload"
|
||||
app:key="backup"
|
||||
app:persistent="false"
|
||||
app:title="@string/settings_backup" />
|
||||
|
||||
<androidx.preference.Preference
|
||||
app:allowDividerAbove="true"
|
||||
app:fragment="com.stevesoltys.seedvault.settings.AppStatusFragment"
|
||||
app:icon="@drawable/ic_apps"
|
||||
app:key="backup_status"
|
||||
app:title="@string/settings_backup_status_title"
|
||||
tools:summary="Last backup: Never" />
|
||||
|
||||
<androidx.preference.Preference
|
||||
app:dependency="backup"
|
||||
app:icon="@drawable/ic_storage"
|
||||
app:key="backup_location"
|
||||
app:summary="@string/settings_backup_location_none"
|
||||
app:title="@string/settings_backup_location" />
|
||||
|
||||
<androidx.preference.SwitchPreferenceCompat
|
||||
app:dependency="backup"
|
||||
app:key="auto_restore"
|
||||
app:persistent="false"
|
||||
app:summary="@string/settings_auto_restore_summary"
|
||||
app:title="@string/settings_auto_restore_title" />
|
||||
app:title="@string/settings_auto_restore_title"
|
||||
tools:defaultValue="true" />
|
||||
|
||||
<androidx.preference.SwitchPreferenceCompat
|
||||
app:defaultValue="true"
|
||||
|
@ -36,18 +46,30 @@
|
|||
app:summary="@string/settings_backup_apk_summary"
|
||||
app:title="@string/settings_backup_apk_title" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="@string/settings_category_storage">
|
||||
|
||||
<androidx.preference.SwitchPreferenceCompat
|
||||
app:defaultValue="false"
|
||||
app:icon="@drawable/ic_storage"
|
||||
app:key="backup_storage"
|
||||
app:title="@string/settings_backup_storage_title" />
|
||||
|
||||
<androidx.preference.Preference
|
||||
app:dependency="backup"
|
||||
app:fragment="com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeInputFragment"
|
||||
app:icon="@drawable/ic_vpn_key"
|
||||
app:key="backup_recovery_code"
|
||||
app:summary="@string/settings_backup_recovery_code_summary"
|
||||
app:title="@string/settings_backup_recovery_code" />
|
||||
android:dependency="backup_storage"
|
||||
app:dependency="backup_storage"
|
||||
app:fragment="com.stevesoltys.seedvault.ui.files.FileSelectionFragment"
|
||||
app:icon="@drawable/ic_library_add"
|
||||
app:key="backup_files"
|
||||
app:summary="@string/settings_backup_files_summary"
|
||||
app:title="@string/settings_backup_files_title" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
<androidx.preference.Preference
|
||||
app:allowDividerAbove="true"
|
||||
app:allowDividerBelow="false"
|
||||
app:dependency="backup"
|
||||
app:icon="@drawable/ic_info_outline"
|
||||
app:selectable="false"
|
||||
app:summary="@string/settings_info" />
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
package com.stevesoltys.seedvault.plugins.saf
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.stevesoltys.seedvault.TestApp
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
|
@ -27,6 +29,14 @@ internal class DocumentFileTest {
|
|||
"content://com.android.externalstorage.documents/tree/" +
|
||||
"primary%3A/document/primary%3A.SeedVaultAndroidBackup"
|
||||
)
|
||||
|
||||
init {
|
||||
// needed since 'androidx.documentfile:documentfile:1.0.1'
|
||||
val pm: PackageManager = mockk()
|
||||
every { context.packageManager } returns pm
|
||||
every { pm.queryIntentContentProviders(any(), 0) } returns emptyList()
|
||||
}
|
||||
|
||||
private val parentFile: DocumentFile = DocumentFile.fromTreeUri(context, parentUri)!!
|
||||
private val uri: Uri = Uri.parse(
|
||||
"content://com.android.externalstorage.documents/tree/" +
|
||||
|
|
16
build.gradle
16
build.gradle
|
@ -1,10 +1,10 @@
|
|||
buildscript {
|
||||
|
||||
// 1.3.21 Android 10
|
||||
// 1.3.61 Android 11
|
||||
// Check:
|
||||
// https://android.googlesource.com/platform/external/kotlinc/+/refs/tags/android-11.0.0_r3/build.txt
|
||||
ext.kotlin_version = '1.3.61'
|
||||
ext.aosp_kotlin_version = '1.3.61'
|
||||
ext.kotlin_version = '1.4.31'
|
||||
|
||||
repositories {
|
||||
jcenter()
|
||||
|
@ -13,10 +13,20 @@ buildscript {
|
|||
dependencies {
|
||||
//noinspection DifferentKotlinGradleVersion
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.android.tools.build:gradle:4.1.0'
|
||||
classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.14"
|
||||
classpath 'com.android.tools.build:gradle:4.1.2'
|
||||
}
|
||||
}
|
||||
|
||||
ext {
|
||||
buildToolsVersion = '30.0.2'
|
||||
compileSdkVersion = 30
|
||||
minSdkVersion = 29
|
||||
targetSdkVersion = 30
|
||||
}
|
||||
|
||||
apply from: 'gradle/dependencies.gradle'
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
|
|
@ -50,16 +50,12 @@ def aospDeps = fileTree(include: [
|
|||
dependencies {
|
||||
implementation aospDeps
|
||||
|
||||
//noinspection GradleDependency
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
testImplementation 'junit:junit:4.13.1'
|
||||
def mockk_version = "1.10.2"
|
||||
testImplementation "junit:junit:$junit4_version"
|
||||
testImplementation "io.mockk:mockk:$mockk_version"
|
||||
|
||||
//noinspection GradleDependency
|
||||
androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
def espresso_version = "3.3.0"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
|
||||
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
org.gradle.jvmargs=-Xmx1g
|
||||
org.gradle.configureondemand=true
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=false
|
||||
kotlin.code.style=official
|
||||
|
|
|
@ -1,6 +1,17 @@
|
|||
ext {
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2713
|
||||
ext.room_version = "2.3.0-alpha02"
|
||||
// http://aosp.opersys.com/xref/android-11.0.0_r27/xref/external/protobuf/java/pom.xml#7
|
||||
ext.protobuf_version = "3.9.1"
|
||||
junit4_version = "4.13.1"
|
||||
junit5_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
|
||||
mockk_version = "1.10.2"
|
||||
espresso_version = "3.3.0"
|
||||
}
|
||||
|
||||
// To produce these binaries, in latest AOSP source tree, run
|
||||
// $ m
|
||||
def aospDeps = fileTree(include: [
|
||||
ext.aosp_libs = fileTree(include: [
|
||||
// For more information about this module:
|
||||
// https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
|
||||
// framework_intermediates/classes-header.jar works for gradle build as well,
|
||||
|
@ -9,122 +20,82 @@ def aospDeps = fileTree(include: [
|
|||
// out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
|
||||
'android.jar',
|
||||
// out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art.release_intermediates/classes.jar
|
||||
'libcore.jar'
|
||||
], dir: 'libs')
|
||||
'libcore.jar',
|
||||
], dir: "$projectDir/app/libs")
|
||||
|
||||
dependencies {
|
||||
compileOnly aospDeps
|
||||
|
||||
/**
|
||||
* Dependencies in AOSP
|
||||
*
|
||||
* We try to keep the dependencies in sync with what AOSP ships as Seedvault is meant to be built
|
||||
* with the AOSP build system and gradle builds are just for more pleasant development.
|
||||
* Using the AOSP versions in gradle builds allows us to spot issues early on.
|
||||
*/
|
||||
|
||||
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {
|
||||
version { strictly "$kotlin_version" }
|
||||
}
|
||||
implementation('org.jetbrains.kotlin:kotlin-stdlib-common') {
|
||||
version { strictly "$kotlin_version" }
|
||||
}
|
||||
implementation('org.jetbrains.kotlin:kotlin-stdlib') {
|
||||
version { strictly "$kotlin_version" }
|
||||
}
|
||||
|
||||
// These coroutine libraries get upgraded otherwise to versions incompatible with kotlin version
|
||||
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-core') {
|
||||
ext.kotlin_libs = [
|
||||
std: [
|
||||
dependencies.create('org.jetbrains.kotlin:kotlin-stdlib') {
|
||||
version { strictly "$aosp_kotlin_version" }
|
||||
},
|
||||
dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {
|
||||
version { strictly "$aosp_kotlin_version" }
|
||||
},
|
||||
dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-common') {
|
||||
version { strictly "$aosp_kotlin_version" }
|
||||
},
|
||||
],
|
||||
coroutines: [
|
||||
dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-core') {
|
||||
// https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#326
|
||||
version { strictly '1.3.0' }
|
||||
}
|
||||
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-android') {
|
||||
},
|
||||
dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-android') {
|
||||
// https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#340
|
||||
version { strictly '1.3.0' }
|
||||
}
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
implementation('androidx.core:core-ktx') {
|
||||
ext.std_libs = [
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#610
|
||||
androidx_core: dependencies.create('androidx.core:core-ktx') {
|
||||
version { strictly '1.5.0-alpha01' }
|
||||
}
|
||||
|
||||
// A newer version gets pulled in with AOSP via core, so we include this here explicitly
|
||||
implementation('androidx.fragment:fragment-ktx') {
|
||||
},
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#930
|
||||
androidx_fragment: dependencies.create('androidx.fragment:fragment-ktx') {
|
||||
version { strictly '1.3.0-alpha07' }
|
||||
}
|
||||
|
||||
implementation('androidx.preference:preference') {
|
||||
},
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2412
|
||||
androidx_preference: dependencies.create('androidx.preference:preference') {
|
||||
version { strictly '1.1.1' } // should be 1.2.0-alpha01, but that is not even released, yet
|
||||
}
|
||||
|
||||
implementation('androidx.lifecycle:lifecycle-viewmodel-ktx') {
|
||||
},
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1553
|
||||
androidx_lifecycle_viewmodel_ktx: dependencies.create('androidx.lifecycle:lifecycle-viewmodel-ktx') {
|
||||
version { strictly '2.3.0-alpha05' }
|
||||
}
|
||||
|
||||
implementation('androidx.lifecycle:lifecycle-livedata-ktx') {
|
||||
},
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#1353
|
||||
androidx_lifecycle_livedata_ktx: dependencies.create('androidx.lifecycle:lifecycle-livedata-ktx') {
|
||||
version { strictly '2.3.0-alpha05' }
|
||||
}
|
||||
|
||||
implementation('androidx.constraintlayout:constraintlayout') {
|
||||
},
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/constraint-layout-x/Android.bp#30
|
||||
androidx_constraintlayout: dependencies.create('androidx.constraintlayout:constraintlayout') {
|
||||
version { strictly '2.0.0-beta7' }
|
||||
}
|
||||
|
||||
implementation('com.google.android.material:material') {
|
||||
},
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#708
|
||||
androidx_documentfile: dependencies.create('androidx.documentfile:documentfile') {
|
||||
version { strictly '1.0.1' } // should be 1.1.0-alpha01, but that is not even released, yet
|
||||
},
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/extras/material-design-x/Android.bp#6
|
||||
com_google_android_material: dependencies.create('com.google.android.material:material') {
|
||||
version { strictly '1.1.0-alpha05' }
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
ext.lint_libs = [
|
||||
exceptions: 'com.github.thirdegg:lint-rules:0.0.6-beta'
|
||||
]
|
||||
|
||||
/**
|
||||
* External Dependencies
|
||||
*
|
||||
* If the dependencies below are updated,
|
||||
* please make sure to update the prebuilt libraries and the Android.bp files
|
||||
* in the top-level `libs` folder to reflect that.
|
||||
* You can copy these libraries from ~/.gradle/caches/modules-2
|
||||
*/
|
||||
|
||||
def koin_version = '2.1.1' // later versions require newer kotlin version
|
||||
//noinspection GradleDependency
|
||||
implementation("org.koin:koin-android:$koin_version") {
|
||||
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib'
|
||||
}
|
||||
//noinspection GradleDependency
|
||||
implementation("org.koin:koin-androidx-viewmodel:$koin_version") {
|
||||
exclude group: 'org.koin', module: 'koin-androidx-scope'
|
||||
exclude group: 'androidx.lifecycle'
|
||||
}
|
||||
|
||||
implementation('io.github.novacrypto:BIP39:2019.01.27') {
|
||||
exclude group: 'com.madgag.spongycastle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Dependencies (do not concern the AOSP build)
|
||||
*/
|
||||
|
||||
lintChecks 'com.github.thirdegg:lint-rules:0.0.5-alpha'
|
||||
|
||||
def junit_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
|
||||
def mockk_version = "1.10.0"
|
||||
testImplementation aospDeps // anything less than 'implementation' fails tests run with gradlew
|
||||
testImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
testImplementation('org.robolectric:robolectric:4.3.1') { // 4.4 has issue with non-idle Looper
|
||||
// https://github.com/robolectric/robolectric/issues/5245
|
||||
exclude group: "com.google.auto.service", module: "auto-service"
|
||||
}
|
||||
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
|
||||
testImplementation "io.mockk:mockk:$mockk_version"
|
||||
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
|
||||
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.3.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.3.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
|
||||
}
|
||||
ext.storage_libs = [
|
||||
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#2711
|
||||
androidx_room_runtime: dependencies.create('androidx.room:room-runtime') {
|
||||
version { strictly "$room_version" }
|
||||
},
|
||||
// http://aosp.opersys.com/xref/android-11.0.0_r27/xref/external/protobuf/java/pom.xml#7
|
||||
com_google_protobuf_javalite: dependencies.create('com.google.protobuf:protobuf-javalite') {
|
||||
version { strictly "$protobuf_version" }
|
||||
},
|
||||
com_google_crypto_tink_android: dependencies.create('com.google.crypto.tink:tink-android') {
|
||||
version { strictly '1.5.0' }
|
||||
},
|
||||
]
|
||||
|
|
|
@ -5,5 +5,6 @@
|
|||
<permission name="android.permission.MANAGE_USB"/>
|
||||
<permission name="android.permission.INSTALL_PACKAGES"/>
|
||||
<permission name="android.permission.WRITE_SECURE_SETTINGS"/>
|
||||
<permission name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
</privapp-permissions>
|
||||
</permissions>
|
|
@ -1,2 +1,4 @@
|
|||
include ':app'
|
||||
include ':contactsbackup'
|
||||
include ':storage:lib'
|
||||
include ':storage:demo'
|
1
storage/.gitignore
vendored
Normal file
1
storage/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
release.sh
|
2
storage/demo/.gitignore
vendored
Normal file
2
storage/demo/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/build
|
||||
/debug
|
57
storage/demo/build.gradle
Normal file
57
storage/demo/build.gradle
Normal file
|
@ -0,0 +1,57 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'com.google.protobuf'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
|
||||
defaultConfig {
|
||||
applicationId "de.grobox.storagebackuptester"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 18
|
||||
versionName "0.9.5"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunnerArguments disableAnalytics: 'true'
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'META-INF/*.kotlin_module'
|
||||
exclude 'META-INF/androidx.*.version'
|
||||
exclude 'META-INF/services/kotlin*'
|
||||
exclude 'kotlin/internal/internal.kotlin_builtins'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':storage:lib')
|
||||
|
||||
implementation rootProject.ext.std_libs.androidx_core
|
||||
// A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
|
||||
implementation rootProject.ext.std_libs.androidx_fragment
|
||||
implementation rootProject.ext.std_libs.androidx_lifecycle_viewmodel_ktx
|
||||
implementation rootProject.ext.std_libs.androidx_lifecycle_livedata_ktx
|
||||
implementation rootProject.ext.std_libs.androidx_constraintlayout
|
||||
implementation rootProject.ext.std_libs.com_google_android_material
|
||||
|
||||
implementation rootProject.ext.storage_libs.com_google_protobuf_javalite
|
||||
}
|
23
storage/demo/proguard-rules.pro
vendored
Normal file
23
storage/demo/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-dontobfuscate
|
43
storage/demo/src/main/AndroidManifest.xml
Normal file
43
storage/demo/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="de.grobox.storagebackuptester">
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.StorageBackupTester">
|
||||
|
||||
<activity android:name=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Used to start actual BackupService depending on scheduling criteria -->
|
||||
<service
|
||||
android:name=".DemoBackupJobService"
|
||||
android:exported="false"
|
||||
android:label="BackupJobService"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
<!-- Does the actual backup work as a foreground service -->
|
||||
<service
|
||||
android:name=".DemoBackupService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="BackupService" />
|
||||
<!-- Does restore as a foreground service -->
|
||||
<service
|
||||
android:name=".DemoRestoreService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="RestoreService" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
BIN
storage/demo/src/main/assets/test.jpg
Normal file
BIN
storage/demo/src/main/assets/test.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
storage/demo/src/main/ic_launcher-playstore.png
Normal file
BIN
storage/demo/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.9 KiB |
|
@ -0,0 +1,40 @@
|
|||
package de.grobox.storagebackuptester
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import de.grobox.storagebackuptester.plugin.TestSafStoragePlugin
|
||||
import de.grobox.storagebackuptester.settings.SettingsManager
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
|
||||
class App : Application() {
|
||||
|
||||
val settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) }
|
||||
val storageBackup: StorageBackup by lazy {
|
||||
val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
|
||||
StorageBackup(this, plugin)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
super.onLowMemory()
|
||||
Log.e("TEST", "ON LOW MEMORY!!!")
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package de.grobox.storagebackuptester
|
||||
|
||||
import android.content.Context
|
||||
import org.calyxos.backup.storage.api.BackupObserver
|
||||
import org.calyxos.backup.storage.api.RestoreObserver
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.backup.BackupJobService
|
||||
import org.calyxos.backup.storage.backup.BackupService
|
||||
import org.calyxos.backup.storage.backup.NotificationBackupObserver
|
||||
import org.calyxos.backup.storage.restore.NotificationRestoreObserver
|
||||
import org.calyxos.backup.storage.restore.RestoreService
|
||||
import java.util.concurrent.TimeUnit.HOURS
|
||||
|
||||
// debug with:
|
||||
// adb shell dumpsys jobscheduler | grep -B 4 -A 24 "Service: de.grobox.storagebackuptester/.DemoBackupJobService"
|
||||
|
||||
class DemoBackupJobService : BackupJobService(DemoBackupService::class.java) {
|
||||
companion object {
|
||||
fun scheduleJob(context: Context) {
|
||||
scheduleJob(
|
||||
context = context,
|
||||
jobServiceClass = DemoBackupJobService::class.java,
|
||||
// periodMillis = JobInfo.getMinPeriodMillis(), // for testing
|
||||
periodMillis = HOURS.toMillis(12), // less than 15min won't work
|
||||
deviceIdle = false,
|
||||
charging = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DemoBackupService : BackupService() {
|
||||
// use lazy delegate because context isn't available during construction time
|
||||
override val storageBackup: StorageBackup by lazy { (application as App).storageBackup }
|
||||
override val backupObserver: BackupObserver by lazy {
|
||||
NotificationBackupObserver(applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
class DemoRestoreService : RestoreService() {
|
||||
// use lazy delegate because context isn't available during construction time
|
||||
override val storageBackup: StorageBackup by lazy { (application as App).storageBackup }
|
||||
override val restoreObserver: RestoreObserver by lazy {
|
||||
NotificationRestoreObserver(applicationContext)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package de.grobox.storagebackuptester
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class LogAdapter : RecyclerView.Adapter<LogAdapter.ViewHolder>() {
|
||||
|
||||
val items: ArrayList<String> = ArrayList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_log, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.bind(item)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun addItem(item: String) {
|
||||
items.add(item)
|
||||
notifyItemInserted(items.size - 1)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
items.clear()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val logView: TextView = view.findViewById(R.id.logView)
|
||||
|
||||
fun bind(item: String) {
|
||||
logView.text = item
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
package de.grobox.storagebackuptester
|
||||
|
||||
import android.Manifest.permission.ACCESS_MEDIA_LOCATION
|
||||
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import de.grobox.storagebackuptester.settings.SettingsFragment
|
||||
|
||||
private const val EMAIL = "incoming+grote-storage-backup-tester-22079635-issue-@incoming.gitlab.com"
|
||||
|
||||
open class LogFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance(): LogFragment = LogFragment()
|
||||
}
|
||||
|
||||
private val viewModel: MainViewModel by activityViewModels()
|
||||
|
||||
private lateinit var list: RecyclerView
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var horizontalProgressBar: ProgressBar
|
||||
private lateinit var button: Button
|
||||
private val adapter = LogAdapter()
|
||||
|
||||
private val permissionRequest =
|
||||
registerForActivityResult(RequestMultiplePermissions()) { grantedMap ->
|
||||
if (grantedMap[WRITE_EXTERNAL_STORAGE] == true) {
|
||||
Toast.makeText(requireContext(), "Please try again now!", LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
setHasOptionsMenu(true)
|
||||
val v = inflater.inflate(R.layout.fragment_log, container, false)
|
||||
list = v.findViewById(R.id.listView)
|
||||
list.adapter = adapter
|
||||
progressBar = v.findViewById(R.id.progressBar)
|
||||
horizontalProgressBar = v.findViewById(R.id.horizontalProgressBar)
|
||||
button = v.findViewById(R.id.button)
|
||||
viewModel.backupLog.observe(viewLifecycleOwner, { progress ->
|
||||
progress.text?.let { adapter.addItem(it) }
|
||||
horizontalProgressBar.max = progress.total
|
||||
horizontalProgressBar.setProgress(progress.current, true)
|
||||
list.postDelayed({
|
||||
list.scrollToPosition(adapter.itemCount - 1)
|
||||
}, 50)
|
||||
})
|
||||
viewModel.backupButtonEnabled.observe(viewLifecycleOwner, { enabled ->
|
||||
button.isEnabled = enabled
|
||||
progressBar.visibility = if (enabled) INVISIBLE else VISIBLE
|
||||
if (!enabled) adapter.clear()
|
||||
})
|
||||
button.setOnClickListener {
|
||||
if (!checkPermission()) return@setOnClickListener
|
||||
viewModel.simulateBackup()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
requireActivity().setTitle(R.string.app_name)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.fragment_main, menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.settings -> {
|
||||
if (!checkPermission()) return false
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, SettingsFragment.newInstance())
|
||||
.addToBackStack("SETTINGS")
|
||||
.commit()
|
||||
true
|
||||
}
|
||||
R.id.share -> {
|
||||
val subject = adapter.items.takeLast(2).joinToString(" - ").replace("\n", "")
|
||||
val text = adapter.items.takeLast(333).joinToString("\n")
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL))
|
||||
putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
startActivity(shareIntent)
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= 30) {
|
||||
if (Environment.isExternalStorageManager()) return true
|
||||
Toast.makeText(requireContext(), "Permission needed", LENGTH_SHORT).show()
|
||||
val i = Intent(ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
|
||||
data = Uri.parse("package:${requireContext().packageName}")
|
||||
}
|
||||
startActivity(i)
|
||||
false
|
||||
} else {
|
||||
if (requireContext().checkSelfPermission(WRITE_EXTERNAL_STORAGE) == PERMISSION_GRANTED) {
|
||||
true
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "No storage permission", LENGTH_SHORT).show()
|
||||
permissionRequest.launch(arrayOf(WRITE_EXTERNAL_STORAGE, ACCESS_MEDIA_LOCATION))
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package de.grobox.storagebackuptester
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import de.grobox.storagebackuptester.crypto.KeyManager
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, LogFragment.newInstance())
|
||||
.commitNow()
|
||||
}
|
||||
|
||||
KeyManager.storeMasterKey()
|
||||
|
||||
if (!KeyManager.hasMasterKey()) {
|
||||
Log.e("TEST", "storing new key")
|
||||
KeyManager.storeMasterKey()
|
||||
} else {
|
||||
Log.e("TEST", "already have key")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
package de.grobox.storagebackuptester
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.grobox.storagebackuptester.backup.BackupProgress
|
||||
import de.grobox.storagebackuptester.backup.BackupStats
|
||||
import de.grobox.storagebackuptester.restore.RestoreProgress
|
||||
import de.grobox.storagebackuptester.restore.RestoreStats
|
||||
import de.grobox.storagebackuptester.scanner.scanTree
|
||||
import de.grobox.storagebackuptester.scanner.scanUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.calyxos.backup.storage.api.SnapshotItem
|
||||
import org.calyxos.backup.storage.api.SnapshotResult
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.backup.BackupJobService
|
||||
import org.calyxos.backup.storage.scanner.DocumentScanner
|
||||
import org.calyxos.backup.storage.scanner.MediaScanner
|
||||
import org.calyxos.backup.storage.ui.backup.BackupContentViewModel
|
||||
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
|
||||
|
||||
private val logEmptyState = """
|
||||
Press the button below to simulate a backup. Your files won't be changed and not uploaded anywhere. This is just to test code for a future real backup.
|
||||
|
||||
Please come back to this app from time to time and run a backup again to see if it correctly identifies files that were added/changed.
|
||||
|
||||
Note that after updating this app, it might need to re-backup all files again.
|
||||
|
||||
Thanks for testing!
|
||||
""".trimIndent()
|
||||
private const val TAG = "MainViewModel"
|
||||
|
||||
class MainViewModel(application: Application) : BackupContentViewModel(application),
|
||||
SnapshotViewModel {
|
||||
|
||||
private val app: App = application as App
|
||||
private val settingsManager = app.settingsManager
|
||||
override val storageBackup: StorageBackup = app.storageBackup
|
||||
|
||||
private val _backupLog = MutableLiveData(BackupProgress(0, 0, logEmptyState))
|
||||
val backupLog: LiveData<BackupProgress> = _backupLog
|
||||
|
||||
private val _buttonEnabled = MutableLiveData<Boolean>()
|
||||
val backupButtonEnabled: LiveData<Boolean> = _buttonEnabled
|
||||
|
||||
private val _restoreLog = MutableLiveData<RestoreProgress>()
|
||||
val restoreLog: LiveData<RestoreProgress> = _restoreLog
|
||||
|
||||
private val _restoreProgressVisible = MutableLiveData<Boolean>()
|
||||
val restoreProgressVisible: LiveData<Boolean> = _restoreProgressVisible
|
||||
|
||||
override val snapshots: LiveData<SnapshotResult>
|
||||
get() = storageBackup.getBackupSnapshots().asLiveData(Dispatchers.IO)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { loadContent() }
|
||||
}
|
||||
|
||||
fun simulateBackup() {
|
||||
_buttonEnabled.value = false
|
||||
val backupObserver = BackupStats(app, storageBackup, _backupLog)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val text = storageBackup.getUriSummaryString()
|
||||
_backupLog.postValue(BackupProgress(0, 0, "Scanning: $text\n"))
|
||||
if (storageBackup.runBackup(backupObserver)) {
|
||||
// only prune old backups when backup run was successful
|
||||
storageBackup.pruneOldBackups(backupObserver)
|
||||
}
|
||||
_buttonEnabled.postValue(true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun scanMediaUri(uri: Uri): String = withContext(Dispatchers.Default) {
|
||||
scanUri(app, MediaScanner(app), uri)
|
||||
}
|
||||
|
||||
suspend fun scanDocumentUri(uri: Uri): String = withContext(Dispatchers.Default) {
|
||||
scanTree(app, DocumentScanner(app), uri)
|
||||
}
|
||||
|
||||
fun clearDb() {
|
||||
viewModelScope.launch(Dispatchers.IO) { storageBackup.clearCache() }
|
||||
}
|
||||
|
||||
fun setBackupLocation(uri: Uri?) {
|
||||
if (uri != null) clearDb()
|
||||
settingsManager.setBackupLocation(uri)
|
||||
}
|
||||
|
||||
fun hasBackupLocation(): Boolean {
|
||||
return settingsManager.getBackupLocation() != null
|
||||
}
|
||||
|
||||
fun setAutomaticBackupsEnabled(enabled: Boolean) {
|
||||
if (enabled) DemoBackupJobService.scheduleJob(app)
|
||||
else BackupJobService.cancelJob(app)
|
||||
settingsManager.setAutomaticBackupsEnabled(enabled)
|
||||
}
|
||||
|
||||
fun areAutomaticBackupsEnabled(): Boolean {
|
||||
val enabled = settingsManager.areAutomaticBackupsEnabled()
|
||||
if (enabled && !BackupJobService.isScheduled(app)) {
|
||||
Log.w(TAG, "Automatic backups enabled, but job not scheduled. Scheduling...")
|
||||
DemoBackupJobService.scheduleJob(app)
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
fun onSnapshotClicked(item: SnapshotItem) {
|
||||
val snapshot = item.snapshot
|
||||
check(snapshot != null)
|
||||
|
||||
// example for how to do restore via foreground service
|
||||
// app.startForegroundService(Intent(app, DemoRestoreService::class.java).apply {
|
||||
// putExtra(EXTRA_TIMESTAMP_START, snapshot.timeStart)
|
||||
// })
|
||||
|
||||
// example for how to do restore via fragment
|
||||
_restoreProgressVisible.value = true
|
||||
val restoreObserver = RestoreStats(app, _restoreLog)
|
||||
viewModelScope.launch {
|
||||
storageBackup.restoreBackupSnapshot(snapshot, restoreObserver)
|
||||
_restoreProgressVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,182 @@
|
|||
package de.grobox.storagebackuptester.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.text.format.DateUtils.FORMAT_ABBREV_ALL
|
||||
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.calyxos.backup.storage.api.BackupFile
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.backup.NotificationBackupObserver
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.toDuration
|
||||
|
||||
data class BackupProgress(
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val text: String?,
|
||||
)
|
||||
|
||||
internal class BackupStats(
|
||||
private val context: Context,
|
||||
private val storageBackup: StorageBackup,
|
||||
private val liveData: MutableLiveData<BackupProgress>,
|
||||
) : NotificationBackupObserver(context) {
|
||||
private var filesProcessed: Int = 0
|
||||
private var totalFiles: Int = 0
|
||||
private var filesUploaded: Int = 0
|
||||
private var expectedSize: Long = 0L
|
||||
private var size: Long = 0L
|
||||
private var savedChunks: Int = 0
|
||||
private val errorStrings = ArrayList<String>()
|
||||
|
||||
override suspend fun onBackupStart(
|
||||
totalSize: Long,
|
||||
numFiles: Int,
|
||||
numSmallFiles: Int,
|
||||
numLargeFiles: Int
|
||||
) {
|
||||
super.onBackupStart(totalSize, numFiles, numSmallFiles, numLargeFiles)
|
||||
|
||||
totalFiles = numFiles
|
||||
expectedSize = totalSize
|
||||
|
||||
val totalSizeStr = Formatter.formatShortFileSize(context, totalSize)
|
||||
val text = "Backing up $totalFiles file(s) $totalSizeStr...\n" +
|
||||
" ($numSmallFiles small, $numLargeFiles large)\n"
|
||||
liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
|
||||
}
|
||||
|
||||
override suspend fun onFileBackedUp(
|
||||
file: BackupFile,
|
||||
wasUploaded: Boolean,
|
||||
reusedChunks: Int,
|
||||
bytesWritten: Long,
|
||||
tag: String,
|
||||
) {
|
||||
super.onFileBackedUp(file, wasUploaded, reusedChunks, bytesWritten, tag)
|
||||
|
||||
filesProcessed++
|
||||
if (!wasUploaded) return
|
||||
|
||||
savedChunks += reusedChunks
|
||||
filesUploaded++
|
||||
size += bytesWritten
|
||||
|
||||
val sizeStr = Formatter.formatShortFileSize(context, file.size)
|
||||
val now = System.currentTimeMillis()
|
||||
val modStr = file.lastModified?.let {
|
||||
getRelativeTimeSpanString(it, now, 0L, FORMAT_ABBREV_ALL)
|
||||
} ?: "NULL"
|
||||
|
||||
val volume =
|
||||
if (file.volume == MediaStore.VOLUME_EXTERNAL_PRIMARY) "" else "v: ${file.volume}"
|
||||
val text = "${file.path}\n s: $sizeStr m: $modStr $volume"
|
||||
liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
|
||||
}
|
||||
|
||||
override suspend fun onFileBackupError(file: BackupFile, tag: String) {
|
||||
super.onFileBackupError(file, tag)
|
||||
|
||||
filesProcessed++
|
||||
errorStrings.add("ERROR $tag: ${file.path}")
|
||||
liveData.postValue(BackupProgress(filesProcessed, totalFiles, null))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override suspend fun onBackupComplete(backupDuration: Long?) {
|
||||
super.onBackupComplete(backupDuration)
|
||||
|
||||
val sb = StringBuilder("\n")
|
||||
errorStrings.forEach { sb.appendLine(it) }
|
||||
sb.appendLine()
|
||||
|
||||
if (savedChunks > 0) sb.appendLine("Chunks re-used: $savedChunks")
|
||||
|
||||
Log.e("TEST", "Total file size: $expectedSize")
|
||||
Log.e("TEST", "Actual size processed: $size")
|
||||
|
||||
val sizeStr = Formatter.formatShortFileSize(context, size)
|
||||
if (backupDuration != null) {
|
||||
val speed = getSpeed(size, backupDuration / 1000)
|
||||
|
||||
val duration = backupDuration.toDuration(DurationUnit.MILLISECONDS)
|
||||
val perFile = if (filesUploaded > 0) duration.div(filesUploaded) else duration
|
||||
|
||||
sb.appendLine("New/changed files backed up: $filesUploaded ($sizeStr)")
|
||||
if (filesUploaded > 0) sb.append(" ($perFile per file - ${speed}MB/sec)")
|
||||
}
|
||||
liveData.postValue(BackupProgress(filesProcessed, totalFiles, sb.toString()))
|
||||
}
|
||||
|
||||
override suspend fun onPruneStart(snapshotsToDelete: List<Long>) {
|
||||
super.onPruneStart(snapshotsToDelete)
|
||||
|
||||
filesProcessed = 0
|
||||
totalFiles = snapshotsToDelete.size
|
||||
size = 0L
|
||||
val r = storageBackup.getSnapshotRetention()
|
||||
|
||||
if (totalFiles > 0) {
|
||||
val text = """
|
||||
Pruning $totalFiles old backup(s) from storage...
|
||||
Retaining snapshots:
|
||||
- ${r.daily} daily
|
||||
- ${r.weekly} weekly
|
||||
- ${r.monthly} monthly
|
||||
- ${r.yearly} yearly
|
||||
""".trimIndent()
|
||||
liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPruneSnapshot(snapshot: Long, numChunksToDelete: Int, size: Long) {
|
||||
super.onPruneSnapshot(snapshot, numChunksToDelete, size)
|
||||
|
||||
filesProcessed++
|
||||
this.size += size
|
||||
val now = System.currentTimeMillis()
|
||||
val time = getRelativeTimeSpanString(snapshot, now, 0L, FORMAT_ABBREV_ALL)
|
||||
val sizeStr = Formatter.formatShortFileSize(context, size)
|
||||
val text = "Pruning snapshot from $time\n deleting $numChunksToDelete chunks ($sizeStr)..."
|
||||
liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
|
||||
}
|
||||
|
||||
override suspend fun onPruneError(snapshot: Long?, e: Exception) {
|
||||
super.onPruneError(snapshot, e)
|
||||
|
||||
val time = if (snapshot != null) {
|
||||
filesProcessed++
|
||||
val now = System.currentTimeMillis()
|
||||
getRelativeTimeSpanString(snapshot, now, 0L, FORMAT_ABBREV_ALL)
|
||||
} else "null"
|
||||
val text = "ERROR $snapshot $time\n e: ${e.message}"
|
||||
liveData.postValue(BackupProgress(filesProcessed, totalFiles, text))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override suspend fun onPruneComplete(pruneDuration: Long) {
|
||||
super.onPruneComplete(pruneDuration)
|
||||
|
||||
val sb = StringBuilder("\n")
|
||||
|
||||
val sizeStr = Formatter.formatShortFileSize(context, size)
|
||||
|
||||
val duration = pruneDuration.toDuration(DurationUnit.MILLISECONDS)
|
||||
val perSnapshot = if (filesProcessed > 0) duration.div(filesProcessed) else duration
|
||||
|
||||
sb.appendLine("Deleting $filesProcessed old backup snapshot(s) took $duration.")
|
||||
if (filesProcessed > 0) sb.append(" (freed $sizeStr - took $perSnapshot per snapshot)")
|
||||
liveData.postValue(BackupProgress(filesProcessed, totalFiles, sb.toString()))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun getSpeed(size: Long, duration: Long): Long {
|
||||
val mb = size / 1024 / 1024
|
||||
return if (duration == 0L) (mb.toDouble() / 0.01).toLong()
|
||||
else mb / duration
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package de.grobox.storagebackuptester.crypto
|
||||
|
||||
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
|
||||
import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
|
||||
import android.security.keystore.KeyProperties.PURPOSE_SIGN
|
||||
import android.security.keystore.KeyProperties.PURPOSE_VERIFY
|
||||
import android.security.keystore.KeyProtection
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object KeyManager {
|
||||
|
||||
private const val KEY_SIZE = 256
|
||||
internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
|
||||
private const val KEY_ALIAS_MASTER = "com.stevesoltys.seedvault.master"
|
||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||
private const val ALGORITHM_HMAC = "HmacSHA256"
|
||||
|
||||
private const val FAKE_SEED = "This is a legacy backup key 1234"
|
||||
|
||||
private val keyStore by lazy {
|
||||
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
|
||||
load(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun storeMasterKey() {
|
||||
val seed = FAKE_SEED.toByteArray()
|
||||
storeMasterKey(seed)
|
||||
}
|
||||
|
||||
private fun storeMasterKey(seed: ByteArray) {
|
||||
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
|
||||
val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, ALGORITHM_HMAC)
|
||||
val ksEntry = KeyStore.SecretKeyEntry(secretKeySpec)
|
||||
keyStore.setEntry(KEY_ALIAS_MASTER, ksEntry, getKeyProtection())
|
||||
}
|
||||
|
||||
fun hasMasterKey(): Boolean = keyStore.containsAlias(KEY_ALIAS_MASTER)
|
||||
|
||||
fun getMasterKey(): SecretKey {
|
||||
val ksEntry = keyStore.getEntry(KEY_ALIAS_MASTER, null) as KeyStore.SecretKeyEntry
|
||||
return ksEntry.secretKey
|
||||
}
|
||||
|
||||
private fun getKeyProtection(): KeyProtection {
|
||||
val builder =
|
||||
KeyProtection.Builder(PURPOSE_ENCRYPT or PURPOSE_DECRYPT or PURPOSE_SIGN or PURPOSE_VERIFY)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package de.grobox.storagebackuptester.plugin
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import de.grobox.storagebackuptester.crypto.KeyManager
|
||||
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
class TestSafStoragePlugin(
|
||||
private val context: Context,
|
||||
private val getLocationUri: () -> Uri?,
|
||||
) : SafStoragePlugin(context) {
|
||||
|
||||
override val root: DocumentFile?
|
||||
get() {
|
||||
val uri = getLocationUri() ?: return null
|
||||
return DocumentFile.fromTreeUri(context, uri) ?: error("No doc file from tree Uri")
|
||||
}
|
||||
|
||||
private val nullStream = object : OutputStream() {
|
||||
override fun write(b: Int) {
|
||||
// oops
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMasterKey(): SecretKey {
|
||||
return KeyManager.getMasterKey()
|
||||
}
|
||||
|
||||
override fun hasMasterKey(): Boolean {
|
||||
return KeyManager.hasMasterKey()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun getChunkOutputStream(chunkId: String): OutputStream {
|
||||
if (getLocationUri() == null) return nullStream
|
||||
return super.getChunkOutputStream(chunkId)
|
||||
}
|
||||
|
||||
override fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
||||
if (root == null) return nullStream
|
||||
return super.getBackupSnapshotOutputStream(timestamp)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package de.grobox.storagebackuptester.restore
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewStub
|
||||
import android.widget.Button
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import de.grobox.storagebackuptester.MainViewModel
|
||||
import de.grobox.storagebackuptester.R
|
||||
import org.calyxos.backup.storage.api.SnapshotItem
|
||||
import org.calyxos.backup.storage.ui.restore.SnapshotFragment
|
||||
|
||||
class DemoSnapshotFragment : SnapshotFragment() {
|
||||
|
||||
override val viewModel: MainViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val v = super.onCreateView(inflater, container, savedInstanceState)
|
||||
val bottomStub: ViewStub = v.findViewById(R.id.bottomStub)
|
||||
bottomStub.layoutResource = R.layout.footer_snapshot
|
||||
val footer = bottomStub.inflate()
|
||||
footer.findViewById<Button>(R.id.button).setOnClickListener {
|
||||
requireActivity().onBackPressed()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onSnapshotClicked(item: SnapshotItem) {
|
||||
viewModel.onSnapshotClicked(item)
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, RestoreFragment.newInstance())
|
||||
.addToBackStack("RESTORE")
|
||||
.commit()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package de.grobox.storagebackuptester.restore
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ProgressBar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import de.grobox.storagebackuptester.LogAdapter
|
||||
import de.grobox.storagebackuptester.MainViewModel
|
||||
import de.grobox.storagebackuptester.R
|
||||
|
||||
class RestoreFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance() = RestoreFragment()
|
||||
}
|
||||
|
||||
private val viewModel: MainViewModel by activityViewModels()
|
||||
|
||||
private lateinit var list: RecyclerView
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var horizontalProgressBar: ProgressBar
|
||||
private lateinit var button: Button
|
||||
private val adapter = LogAdapter()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
setHasOptionsMenu(true)
|
||||
val v = inflater.inflate(R.layout.fragment_log, container, false)
|
||||
list = v.findViewById(R.id.listView)
|
||||
list.adapter = adapter
|
||||
progressBar = v.findViewById(R.id.progressBar)
|
||||
horizontalProgressBar = v.findViewById(R.id.horizontalProgressBar)
|
||||
button = v.findViewById(R.id.button)
|
||||
button.visibility = GONE
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel.restoreLog.observe(viewLifecycleOwner, { progress ->
|
||||
progress.text?.let { adapter.addItem(it) }
|
||||
horizontalProgressBar.max = progress.total
|
||||
horizontalProgressBar.setProgress(progress.current, true)
|
||||
list.postDelayed({
|
||||
list.scrollToPosition(adapter.itemCount - 1)
|
||||
}, 50)
|
||||
})
|
||||
viewModel.restoreProgressVisible.observe(viewLifecycleOwner, { visible ->
|
||||
progressBar.visibility = if (visible) VISIBLE else INVISIBLE
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
requireActivity().title = "Restore"
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
package de.grobox.storagebackuptester.restore
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import de.grobox.storagebackuptester.backup.getSpeed
|
||||
import org.calyxos.backup.storage.api.BackupFile
|
||||
import org.calyxos.backup.storage.restore.NotificationRestoreObserver
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.toDuration
|
||||
|
||||
data class RestoreProgress(
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val text: String? = null,
|
||||
)
|
||||
|
||||
class RestoreStats(
|
||||
private val context: Context,
|
||||
private val liveData: MutableLiveData<RestoreProgress>,
|
||||
) : NotificationRestoreObserver(context) {
|
||||
|
||||
private var filesProcessed: Int = 0
|
||||
private var totalFiles: Int = 0
|
||||
private var size: Long = 0L
|
||||
private val errorStrings = ArrayList<String>()
|
||||
|
||||
override fun onRestoreStart(numFiles: Int, totalSize: Long) {
|
||||
super.onRestoreStart(numFiles, totalSize)
|
||||
totalFiles = numFiles
|
||||
|
||||
val totalSizeStr = Formatter.formatShortFileSize(context, totalSize)
|
||||
val text = "Restoring $totalFiles file(s) $totalSizeStr...\n"
|
||||
liveData.postValue(RestoreProgress(filesProcessed, totalFiles, text))
|
||||
}
|
||||
|
||||
override fun onFileRestored(
|
||||
file: BackupFile,
|
||||
bytesWritten: Long,
|
||||
tag: String,
|
||||
) {
|
||||
super.onFileRestored(file, bytesWritten, tag)
|
||||
filesProcessed++
|
||||
size += bytesWritten
|
||||
|
||||
val sizeStr = Formatter.formatShortFileSize(context, file.size)
|
||||
val now = System.currentTimeMillis()
|
||||
val modStr = file.lastModified?.let {
|
||||
DateUtils.getRelativeTimeSpanString(it, now, 0L, DateUtils.FORMAT_ABBREV_ALL)
|
||||
} ?: "NULL"
|
||||
|
||||
val volume = if (file.volume == "") "" else "v: ${file.volume}"
|
||||
val text = "${file.path}\n s: $sizeStr m: $modStr $tag $volume"
|
||||
liveData.postValue(RestoreProgress(filesProcessed, totalFiles, text))
|
||||
}
|
||||
|
||||
override fun onFileRestoreError(file: BackupFile, e: Exception) {
|
||||
super.onFileRestoreError(file, e)
|
||||
filesProcessed++
|
||||
errorStrings.add("E ${file.path}\n e: ${e.message}")
|
||||
liveData.postValue(RestoreProgress(filesProcessed, totalFiles))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun onRestoreComplete(restoreDuration: Long) {
|
||||
super.onRestoreComplete(restoreDuration)
|
||||
val sb = StringBuilder("\n")
|
||||
errorStrings.forEach { sb.appendLine(it) }
|
||||
sb.appendLine()
|
||||
|
||||
val sizeStr = Formatter.formatShortFileSize(context, size)
|
||||
val speed = getSpeed(size, restoreDuration / 1000)
|
||||
|
||||
val duration = restoreDuration.toDuration(DurationUnit.MILLISECONDS)
|
||||
val perFile = if (filesProcessed > 0) duration.div(filesProcessed) else duration
|
||||
|
||||
sb.appendLine("Files restored: $filesProcessed ($sizeStr)")
|
||||
if (filesProcessed > 0) sb.append(" ($perFile per file - ${speed}MB/sec)")
|
||||
liveData.postValue(RestoreProgress(filesProcessed, totalFiles, sb.toString()))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package de.grobox.storagebackuptester.scanner
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
|
||||
class DocumentScanFragment : MediaScanFragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance(name: String, uri: Uri) = DocumentScanFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString("name", name)
|
||||
putString("uri", uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getText(): String {
|
||||
return viewModel.scanDocumentUri(getUri())
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package de.grobox.storagebackuptester.scanner
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.text.format.Formatter
|
||||
import org.calyxos.backup.storage.api.BackupFile
|
||||
import org.calyxos.backup.storage.scanner.DocumentScanner
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun scanTree(context: Context, documentScanner: DocumentScanner, treeUri: Uri): String {
|
||||
val sb = StringBuilder()
|
||||
val timedResult = measureTimedValue {
|
||||
val documentId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
|
||||
scanDocument(context, documentScanner.scanUri(documentUri), sb)
|
||||
}
|
||||
return appendStats(context, sb, timedResult)
|
||||
}
|
||||
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
fun scanDocument(
|
||||
context: Context,
|
||||
documentFiles: List<BackupFile>,
|
||||
sb: StringBuilder? = null
|
||||
): ScanResult {
|
||||
var totalSize = 0L
|
||||
val warnings = StringBuilder()
|
||||
for (documentFile in documentFiles) {
|
||||
totalSize += documentFile.size
|
||||
val lastModified = documentFile.lastModified?.let {
|
||||
dateFormat.format(Date(it))
|
||||
}
|
||||
sb?.appendLine("┣ ${documentFile.path}")
|
||||
sb?.appendLine("┃ lastModified: $lastModified")
|
||||
sb?.appendLine("┃ size: ${Formatter.formatShortFileSize(context, documentFile.size)}")
|
||||
|
||||
if (lastModified == null) {
|
||||
warnings.appendLine("WARNING: ${documentFile.path} has no lastModified timestamp.")
|
||||
}
|
||||
if (documentFile.size == 0L && !documentFile.path.endsWith(".nomedia")) {
|
||||
warnings.appendLine("WARNING: ${documentFile.path} has no 0 size. Please check if real.")
|
||||
}
|
||||
}
|
||||
|
||||
if (sb?.isEmpty() == true) sb.appendLine("Empty folder")
|
||||
else if (warnings.isNotEmpty()) sb?.appendLine()?.appendLine(warnings)
|
||||
return ScanResult(documentFiles.size.toLong(), totalSize)
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
package de.grobox.storagebackuptester.scanner
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.View.FOCUS_DOWN
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import de.grobox.storagebackuptester.MainViewModel
|
||||
import de.grobox.storagebackuptester.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val EMAIL = "incoming+grote-storage-backup-tester-22079635-issue-@incoming.gitlab.com"
|
||||
|
||||
open class MediaScanFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance(name: String, uri: Uri) = MediaScanFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString("name", name)
|
||||
putString("uri", uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected val viewModel: MainViewModel by activityViewModels()
|
||||
|
||||
private lateinit var scrollView: NestedScrollView
|
||||
protected lateinit var logView: TextView
|
||||
protected lateinit var progressBar: ProgressBar
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
setHasOptionsMenu(true)
|
||||
requireActivity().title = arguments?.getString("name")
|
||||
val v = inflater.inflate(R.layout.fragment_scan, container, false)
|
||||
scrollView = v.findViewById(R.id.scrollView)
|
||||
logView = v.findViewById(R.id.logView)
|
||||
progressBar = v.findViewById(R.id.progressBar)
|
||||
loadText()
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.fragment_scan, menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.refresh -> {
|
||||
loadText()
|
||||
true
|
||||
}
|
||||
R.id.share -> {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL))
|
||||
putExtra(Intent.EXTRA_SUBJECT, arguments?.getString("name"))
|
||||
putExtra(Intent.EXTRA_TEXT, logView.text)
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
startActivity(shareIntent)
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadText() {
|
||||
progressBar.visibility = VISIBLE
|
||||
lifecycleScope.launch {
|
||||
logView.text = getText()
|
||||
logView.postDelayed({
|
||||
scrollView.fullScroll(FOCUS_DOWN)
|
||||
}, 50)
|
||||
progressBar.visibility = INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
protected open suspend fun getText(): String {
|
||||
Log.e("TEST", "loading text")
|
||||
return viewModel.scanMediaUri(getUri())
|
||||
}
|
||||
|
||||
protected fun getUri(): Uri {
|
||||
return Uri.parse(arguments?.getString("uri"))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package de.grobox.storagebackuptester.scanner
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.text.format.Formatter
|
||||
import org.calyxos.backup.storage.api.BackupFile
|
||||
import org.calyxos.backup.storage.scanner.MediaScanner
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.TimedValue
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
data class ScanResult(
|
||||
var itemsFound: Long,
|
||||
var totalSize: Long
|
||||
) {
|
||||
operator fun plusAssign(other: ScanResult) {
|
||||
itemsFound += other.itemsFound
|
||||
totalSize += other.totalSize
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun scanUri(context: Context, mediaScanner: MediaScanner, uri: Uri): String {
|
||||
val sb = StringBuilder()
|
||||
val timedResult = measureTimedValue {
|
||||
dump(context, mediaScanner.scanUri(uri), sb)
|
||||
}
|
||||
return appendStats(context, sb, timedResult)
|
||||
}
|
||||
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
fun dump(context: Context, mediaFiles: List<BackupFile>, sb: StringBuilder? = null): ScanResult {
|
||||
var itemsFound = 0L
|
||||
var totalSize = 0L
|
||||
for (mediaFile in mediaFiles) {
|
||||
itemsFound++
|
||||
totalSize += mediaFile.size
|
||||
val dateModified = mediaFile.lastModified?.let {
|
||||
dateFormat.format(Date(it))
|
||||
}
|
||||
sb?.appendLine(mediaFile.path)
|
||||
sb?.appendLine(" modified: $dateModified")
|
||||
sb?.appendLine(" size: ${Formatter.formatShortFileSize(context, mediaFile.size)}")
|
||||
}
|
||||
|
||||
if (sb?.isEmpty() == true) sb.appendLine("No files found")
|
||||
return ScanResult(itemsFound, totalSize)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun appendStats(
|
||||
context: Context,
|
||||
sb: StringBuilder,
|
||||
timedResult: TimedValue<ScanResult>,
|
||||
title: String? = null
|
||||
): String {
|
||||
val result = timedResult.value
|
||||
if (title != null || sb.isNotEmpty()) {
|
||||
sb.appendLine(title ?: "")
|
||||
sb.appendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
sb.appendLine()
|
||||
}
|
||||
sb.appendLine("Scanning took ${timedResult.duration}")
|
||||
sb.appendLine("Files found: ${result.itemsFound}")
|
||||
sb.appendLine("Total size: ${Formatter.formatShortFileSize(context, result.totalSize)}")
|
||||
val avgSize = if (result.itemsFound > 0) result.totalSize / result.itemsFound else 0
|
||||
sb.appendLine("Average size: ${Formatter.formatShortFileSize(context, avgSize)}")
|
||||
return sb.toString()
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
package de.grobox.storagebackuptester.settings
|
||||
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import de.grobox.storagebackuptester.App
|
||||
import de.grobox.storagebackuptester.scanner.MediaScanFragment
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.calyxos.backup.storage.api.EXTERNAL_STORAGE_PROVIDER_AUTHORITY
|
||||
import org.calyxos.backup.storage.api.mediaUris
|
||||
import org.calyxos.backup.storage.scanner.DocumentScanner
|
||||
import org.calyxos.backup.storage.scanner.MediaScanner
|
||||
|
||||
class InfoFragment : MediaScanFragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance(name: String) = InfoFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString("name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val storageBackup by lazy { (requireActivity().application as App).storageBackup }
|
||||
private val mediaScanner by lazy { MediaScanner(requireContext()) }
|
||||
private val documentScanner by lazy { DocumentScanner(requireContext()) }
|
||||
|
||||
override suspend fun getText(): String {
|
||||
val sb = StringBuilder()
|
||||
val context = requireContext()
|
||||
|
||||
val volumeNames = MediaStore.getExternalVolumeNames(context)
|
||||
sb.appendLine("Storage Volumes:")
|
||||
for (volumeName in volumeNames) {
|
||||
val version = try {
|
||||
MediaStore.getVersion(context, volumeName)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.toString()
|
||||
}
|
||||
val gen = if (SDK_INT >= 30) try {
|
||||
MediaStore.getGeneration(context, volumeName)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.toString()
|
||||
} else null
|
||||
sb.appendLine(" $volumeName")
|
||||
sb.appendLine(" version: $version")
|
||||
if (gen != null) {
|
||||
sb.appendLine(" generation: $gen")
|
||||
}
|
||||
}
|
||||
sb.appendLine()
|
||||
sb.appendLine("Media files smaller than 100 KB: ${mediaFilesSmallerThan(100 * 1024)}")
|
||||
sb.appendLine("Media files smaller than 500 KB: ${mediaFilesSmallerThan(500 * 1024)}")
|
||||
sb.appendLine("Media files smaller than 1 MB: ${mediaFilesSmallerThan(1024 * 1024)}")
|
||||
sb.appendLine()
|
||||
sb.appendLine("Storage files smaller than 100 KB: ${docFilesSmallerThan(100 * 1024)}")
|
||||
sb.appendLine("Storage files smaller than 500 KB: ${docFilesSmallerThan(500 * 1024)}")
|
||||
sb.appendLine("Storage files smaller than 1 MB: ${docFilesSmallerThan(1024 * 1024)}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private suspend fun mediaFilesSmallerThan(size: Long): Int = withContext(Dispatchers.IO) {
|
||||
var count = 0
|
||||
mediaUris.forEach {
|
||||
count += mediaScanner.scanUri(it, size).size
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
private suspend fun docFilesSmallerThan(size: Long): Int = withContext(Dispatchers.IO) {
|
||||
var count = 0
|
||||
storageBackup.uris.forEach { uri ->
|
||||
if (uri.authority == EXTERNAL_STORAGE_PROVIDER_AUTHORITY) {
|
||||
val documentId = DocumentsContract.getTreeDocumentId(uri)
|
||||
val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
|
||||
count += documentScanner.scanUri(documentUri, size).size
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
package de.grobox.storagebackuptester.settings
|
||||
|
||||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import de.grobox.storagebackuptester.MainViewModel
|
||||
import de.grobox.storagebackuptester.R
|
||||
import de.grobox.storagebackuptester.restore.DemoSnapshotFragment
|
||||
import de.grobox.storagebackuptester.scanner.DocumentScanFragment
|
||||
import de.grobox.storagebackuptester.scanner.MediaScanFragment
|
||||
import org.calyxos.backup.storage.api.MediaType
|
||||
import org.calyxos.backup.storage.ui.backup.BackupContentFragment
|
||||
import org.calyxos.backup.storage.ui.backup.BackupContentItem
|
||||
import org.calyxos.backup.storage.ui.backup.OpenTree
|
||||
|
||||
class SettingsFragment : BackupContentFragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance(): SettingsFragment = SettingsFragment()
|
||||
}
|
||||
|
||||
override val viewModel: MainViewModel by activityViewModels()
|
||||
private lateinit var backupLocationItem: MenuItem
|
||||
private lateinit var jobItem: MenuItem
|
||||
private lateinit var restoreItem: MenuItem
|
||||
|
||||
private val backupLocationRequest = registerForActivityResult(OpenTree()) { uri ->
|
||||
onBackupUriReceived(uri)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
setHasOptionsMenu(true)
|
||||
requireActivity().title = "Settings"
|
||||
return super.onCreateView(inflater, container, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.fragment_settings, menu)
|
||||
backupLocationItem = menu.findItem(R.id.backup_location)
|
||||
backupLocationItem.isChecked = viewModel.hasBackupLocation()
|
||||
jobItem = menu.findItem(R.id.backup_job)
|
||||
jobItem.isEnabled = backupLocationItem.isChecked
|
||||
jobItem.isChecked = viewModel.areAutomaticBackupsEnabled()
|
||||
restoreItem = menu.findItem(R.id.restore_backup)
|
||||
restoreItem.isEnabled = backupLocationItem.isChecked
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.info -> {
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, InfoFragment.newInstance("Media Info"))
|
||||
.addToBackStack("INFO")
|
||||
.commit()
|
||||
true
|
||||
}
|
||||
R.id.backup_location -> {
|
||||
if (item.isChecked) {
|
||||
viewModel.setBackupLocation(null)
|
||||
item.isChecked = false
|
||||
jobItem.isEnabled = false
|
||||
restoreItem.isEnabled = false
|
||||
} else backupLocationRequest.launch(null)
|
||||
true
|
||||
}
|
||||
R.id.backup_job -> {
|
||||
if (item.isChecked) {
|
||||
viewModel.setAutomaticBackupsEnabled(false)
|
||||
item.isChecked = false
|
||||
} else {
|
||||
viewModel.setAutomaticBackupsEnabled(true)
|
||||
item.isChecked = true
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.restore_backup -> {
|
||||
onRestoreClicked()
|
||||
true
|
||||
}
|
||||
R.id.clear_db -> {
|
||||
viewModel.clearDb()
|
||||
Toast.makeText(requireContext(), "Cache cleared", LENGTH_SHORT).show()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContentClicked(view: View, item: BackupContentItem) {
|
||||
val f = if (item.contentType is MediaType) {
|
||||
MediaScanFragment.newInstance(item.getName(requireContext()), item.uri)
|
||||
} else {
|
||||
DocumentScanFragment.newInstance(item.getName(requireContext()), item.uri)
|
||||
}
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, f)
|
||||
.addToBackStack("LOG")
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun onBackupUriReceived(uri: Uri?) {
|
||||
if (uri == null) {
|
||||
Toast.makeText(requireContext(), "No location set", LENGTH_SHORT).show()
|
||||
} else {
|
||||
requireContext().contentResolver.takePersistableUriPermission(
|
||||
uri, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
viewModel.setBackupLocation(uri)
|
||||
backupLocationItem.isChecked = true
|
||||
jobItem.isEnabled = true
|
||||
restoreItem.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreClicked() {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setIcon(android.R.drawable.stat_sys_warning)
|
||||
.setTitle("Warning")
|
||||
.setMessage("This will override data and should only be used on a clean phone. Not the one you just made the backup on.")
|
||||
.setPositiveButton("I have been warned") { dialog, _ ->
|
||||
onStartRestore()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun onStartRestore() {
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, DemoSnapshotFragment())
|
||||
.addToBackStack("SNAPSHOTS")
|
||||
.commit()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package de.grobox.storagebackuptester.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
|
||||
private const val PREFS = "prefs"
|
||||
private const val PREF_BACKUP_LOCATION = "backupLocationUri"
|
||||
private const val PREF_AUTOMATIC_BACKUP = "automaticBackups"
|
||||
|
||||
class SettingsManager(private val context: Context) {
|
||||
|
||||
fun setBackupLocation(uri: Uri?) {
|
||||
context.getSharedPreferences(PREFS, 0)
|
||||
.edit()
|
||||
.putString(PREF_BACKUP_LOCATION, uri?.toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getBackupLocation(): Uri? {
|
||||
val uriStr = context.getSharedPreferences(PREFS, 0)
|
||||
.getString(PREF_BACKUP_LOCATION, null)
|
||||
return uriStr?.let { Uri.parse(it) }
|
||||
}
|
||||
|
||||
fun setAutomaticBackupsEnabled(enabled: Boolean) {
|
||||
context.getSharedPreferences(PREFS, 0)
|
||||
.edit()
|
||||
.putBoolean(PREF_AUTOMATIC_BACKUP, enabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun areAutomaticBackupsEnabled(): Boolean {
|
||||
return context.getSharedPreferences(PREFS, 0)
|
||||
.getBoolean(PREF_AUTOMATIC_BACKUP, false)
|
||||
}
|
||||
|
||||
}
|
10
storage/demo/src/main/res/drawable/ic_info.xml
Normal file
10
storage/demo/src/main/res/drawable/ic_info.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
|
||||
</vector>
|
170
storage/demo/src/main/res/drawable/ic_launcher_background.xml
Normal file
170
storage/demo/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#303F9F"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:tint="#FFFFFF"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:scaleX="2.349"
|
||||
android:scaleY="2.349"
|
||||
android:translateX="25.812"
|
||||
android:translateY="25.812">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M2,20h20v-4L2,16v4zM4,17h2v2L4,19v-2zM2,4v4h20L22,4L2,4zM6,7L4,7L4,5h2v2zM2,14h20v-4L2,10v4zM4,11h2v2L4,13v-2z" />
|
||||
</group>
|
||||
</vector>
|
10
storage/demo/src/main/res/drawable/ic_refresh.xml
Normal file
10
storage/demo/src/main/res/drawable/ic_refresh.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
10
storage/demo/src/main/res/drawable/ic_settings.xml
Normal file
10
storage/demo/src/main/res/drawable/ic_settings.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
|
||||
</vector>
|
10
storage/demo/src/main/res/drawable/ic_share.xml
Normal file
10
storage/demo/src/main/res/drawable/ic_share.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
|
||||
</vector>
|
7
storage/demo/src/main/res/layout/activity_main.xml
Normal file
7
storage/demo/src/main/res/layout/activity_main.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity" />
|
16
storage/demo/src/main/res/layout/footer_snapshot.xml
Normal file
16
storage/demo/src/main/res/layout/footer_snapshot.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Don't restore"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
29
storage/demo/src/main/res/layout/fragment_content.xml
Normal file
29
storage/demo/src/main/res/layout/fragment_content.xml
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/list"
|
||||
android:name="de.grobox.storagebackuptester.MainFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
app:layoutManager="LinearLayoutManager"
|
||||
tools:context=".settings.SettingsFragment"
|
||||
tools:listitem="@layout/item_media" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="16dp"
|
||||
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
|
||||
android:contentDescription="Add"
|
||||
android:src="@drawable/ic_add" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
53
storage/demo/src/main/res/layout/fragment_log.xml
Normal file
53
storage/demo/src/main/res/layout/fragment_log.xml
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/horizontalProgressBar"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="5dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:max="10"
|
||||
tools:progress="7" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/listView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_margin="8dp"
|
||||
android:focusable="true"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toTopOf="@+id/button"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/horizontalProgressBar"
|
||||
tools:listitem="@layout/item_log" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="invisible"
|
||||
app:layout_constraintBottom_toTopOf="@+id/button"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Run (Test) Backup Now"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
40
storage/demo/src/main/res/layout/fragment_scan.xml
Normal file
40
storage/demo/src/main/res/layout/fragment_scan.xml
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fadeScrollbars="false"
|
||||
android:scrollbars="vertical"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/logView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="8sp"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="12sp"
|
||||
tools:text="@tools:sample/lorem/random" />
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
13
storage/demo/src/main/res/layout/item_log.xml
Normal file
13
storage/demo/src/main/res/layout/item_log.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/logView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="8sp"
|
||||
android:textColor="@color/matrix"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="12sp"
|
||||
tools:text="@tools:sample/lorem/random" />
|
17
storage/demo/src/main/res/menu/fragment_main.xml
Normal file
17
storage/demo/src/main/res/menu/fragment_main.xml
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/settings"
|
||||
android:icon="@drawable/ic_settings"
|
||||
android:title="Settings"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/share"
|
||||
android:icon="@drawable/ic_share"
|
||||
android:title="Share"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
</menu>
|
17
storage/demo/src/main/res/menu/fragment_scan.xml
Normal file
17
storage/demo/src/main/res/menu/fragment_scan.xml
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/refresh"
|
||||
android:icon="@drawable/ic_refresh"
|
||||
android:title="Refresh"
|
||||
app:showAsAction="always"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/share"
|
||||
android:icon="@drawable/ic_share"
|
||||
android:title="Share"
|
||||
app:showAsAction="always"/>
|
||||
|
||||
</menu>
|
31
storage/demo/src/main/res/menu/fragment_settings.xml
Normal file
31
storage/demo/src/main/res/menu/fragment_settings.xml
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/info"
|
||||
android:icon="@drawable/ic_info"
|
||||
android:title="Info"
|
||||
app:showAsAction="ifRoom" />
|
||||
<item
|
||||
android:id="@+id/backup_location"
|
||||
android:checkable="true"
|
||||
android:title="Store backups"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/backup_job"
|
||||
android:checkable="true"
|
||||
android:enabled="false"
|
||||
android:title="Automatic backups"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/restore_backup"
|
||||
android:enabled="false"
|
||||
android:title="Restore backup"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/clear_db"
|
||||
android:title="Clear cache"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
17
storage/demo/src/main/res/values-night/themes.xml
Normal file
17
storage/demo/src/main/res/values-night/themes.xml
Normal file
|
@ -0,0 +1,17 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.StorageBackupTester" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/matrix</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorAccent">@color/matrix</item>
|
||||
</style>
|
||||
</resources>
|
11
storage/demo/src/main/res/values/colors.xml
Normal file
11
storage/demo/src/main/res/values/colors.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#151515</color>
|
||||
<color name="white">#FAFAFA</color>
|
||||
<color name="matrix">#689F38</color>
|
||||
</resources>
|
3
storage/demo/src/main/res/values/strings.xml
Normal file
3
storage/demo/src/main/res/values/strings.xml
Normal file
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Storage Backup Tester</string>
|
||||
</resources>
|
13
storage/demo/src/main/res/values/themes.xml
Normal file
13
storage/demo/src/main/res/values/themes.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<resources>
|
||||
<style name="Theme.StorageBackupTester" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<item name="colorPrimary">@color/matrix</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
|
||||
<item name="colorAccent">@color/matrix</item>
|
||||
</style>
|
||||
</resources>
|
2
storage/lib/.gitignore
vendored
Normal file
2
storage/lib/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
build/*
|
||||
!build/outputs/aar/lib-release.aar
|
15
storage/lib/Android.bp
Normal file
15
storage/lib/Android.bp
Normal file
|
@ -0,0 +1,15 @@
|
|||
android_library_import {
|
||||
name: "seedvault-lib-storage",
|
||||
aars: ["libs/storage.aar"],
|
||||
sdk_version: "current",
|
||||
static_libs: [
|
||||
"androidx-constraintlayout_constraintlayout",
|
||||
"com.google.android.material_material",
|
||||
]
|
||||
}
|
||||
|
||||
java_import {
|
||||
name: "seedvault-lib-tink-android",
|
||||
jars: ["libs/tink-android-1.5.0.jar"],
|
||||
sdk_version: "current",
|
||||
}
|
92
storage/lib/build.gradle
Normal file
92
storage/lib/build.gradle
Normal file
|
@ -0,0 +1,92 @@
|
|||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'com.google.protobuf'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
id 'org.jetbrains.dokka' version '1.4.20'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "0.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunnerArguments disableAnalytics: 'true'
|
||||
|
||||
consumerProguardFiles "consumer-rules.pro"
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
all {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
|
||||
freeCompilerArgs += '-Xexplicit-api=strict'
|
||||
}
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:$protobuf_version"
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().each { task ->
|
||||
task.builtins {
|
||||
java {
|
||||
option "lite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
explicitApi = 'strict'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
|
||||
implementation rootProject.ext.std_libs.androidx_core
|
||||
// A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
|
||||
implementation rootProject.ext.std_libs.androidx_fragment
|
||||
implementation rootProject.ext.std_libs.androidx_lifecycle_viewmodel_ktx
|
||||
implementation rootProject.ext.std_libs.androidx_lifecycle_livedata_ktx
|
||||
implementation rootProject.ext.std_libs.androidx_constraintlayout
|
||||
implementation rootProject.ext.std_libs.androidx_documentfile
|
||||
implementation rootProject.ext.std_libs.com_google_android_material
|
||||
|
||||
implementation rootProject.ext.storage_libs.androidx_room_runtime
|
||||
implementation rootProject.ext.storage_libs.com_google_protobuf_javalite
|
||||
implementation rootProject.ext.storage_libs.com_google_crypto_tink_android
|
||||
kapt('androidx.room:room-compiler') {
|
||||
version { strictly "$room_version" }
|
||||
}
|
||||
|
||||
lintChecks rootProject.ext.lint_libs.exceptions
|
||||
|
||||
testImplementation "junit:junit:$junit4_version"
|
||||
testImplementation "io.mockk:mockk:$mockk_version"
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
|
||||
}
|
BIN
storage/lib/build/outputs/aar/lib-release.aar
Normal file
BIN
storage/lib/build/outputs/aar/lib-release.aar
Normal file
Binary file not shown.
0
storage/lib/consumer-rules.pro
Normal file
0
storage/lib/consumer-rules.pro
Normal file
1
storage/lib/libs/storage.aar
Symbolic link
1
storage/lib/libs/storage.aar
Symbolic link
|
@ -0,0 +1 @@
|
|||
../build/outputs/aar/lib-release.aar
|
BIN
storage/lib/libs/tink-android-1.5.0.jar
Normal file
BIN
storage/lib/libs/tink-android-1.5.0.jar
Normal file
Binary file not shown.
23
storage/lib/proguard-rules.pro
vendored
Normal file
23
storage/lib/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keep class org.calyxos.backup.storage.** {*;}
|
128
storage/lib/schemas/org.calyxos.backup.storage.db.Db/1.json
Normal file
128
storage/lib/schemas/org.calyxos.backup.storage.db.Db/1.json
Normal file
|
@ -0,0 +1,128 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "6286a22113131a780d1a28ba059d5a6d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "StoredUri",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, PRIMARY KEY(`uri`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uri",
|
||||
"columnName": "uri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uri"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "CachedFile",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, `size` INTEGER NOT NULL, `last_modified` INTEGER, `generation_modified` INTEGER, `chunks` TEXT NOT NULL, `zip_index` INTEGER, `last_seen` INTEGER NOT NULL, PRIMARY KEY(`uri`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uri",
|
||||
"columnName": "uri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "last_modified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "generationModified",
|
||||
"columnName": "generation_modified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chunks",
|
||||
"columnName": "chunks",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "zipIndex",
|
||||
"columnName": "zip_index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSeen",
|
||||
"columnName": "last_seen",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uri"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "CachedChunk",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `ref_count` INTEGER NOT NULL, `size` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "refCount",
|
||||
"columnName": "ref_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6286a22113131a780d1a28ba059d5a6d')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
package org.calyxos.backup.storage.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
internal class ChunksCacheTest {
|
||||
|
||||
private lateinit var chunksCache: ChunksCache
|
||||
private lateinit var db: Db
|
||||
|
||||
private val chunk1 = CachedChunk("id1", 1, Random.nextLong())
|
||||
private val chunk2 = CachedChunk("id2", 2, Random.nextLong())
|
||||
private val chunk3 = CachedChunk("id3", 3, Random.nextLong())
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, Db::class.java).build()
|
||||
chunksCache = db.getChunksCache()
|
||||
chunksCache.insert(chunk1)
|
||||
chunksCache.insert(chunk2)
|
||||
chunksCache.insert(chunk3)
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertAndGet() {
|
||||
assertThat(chunksCache.get(chunk1.id), equalTo(chunk1))
|
||||
assertThat(chunksCache.get(chunk2.id), equalTo(chunk2))
|
||||
assertThat(chunksCache.get(chunk3.id), equalTo(chunk3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertAndDelete() {
|
||||
chunksCache.deleteChunks(listOf(chunk1))
|
||||
assertThat(chunksCache.get(chunk1.id), equalTo(null))
|
||||
assertThat(chunksCache.get(chunk2.id), equalTo(chunk2))
|
||||
assertThat(chunksCache.get(chunk3.id), equalTo(chunk3))
|
||||
|
||||
chunksCache.deleteChunks(listOf(chunk2, chunk3))
|
||||
assertThat(chunksCache.get(chunk1.id), equalTo(null))
|
||||
assertThat(chunksCache.get(chunk2.id), equalTo(null))
|
||||
assertThat(chunksCache.get(chunk3.id), equalTo(null))
|
||||
|
||||
chunksCache.insert(chunk1)
|
||||
chunksCache.deleteChunks(listOf(chunk1.copy(refCount = 1337)))
|
||||
assertThat(chunksCache.get(chunk1.id), equalTo(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRefCounts() {
|
||||
assertThat(chunksCache.getUnreferencedChunks(), equalTo(emptyList()))
|
||||
|
||||
chunksCache.decrementRefCount(listOf(chunk1.id, chunk2.id))
|
||||
chunksCache.decrementRefCount(listOf(chunk2.id))
|
||||
assertThat(
|
||||
chunksCache.getUnreferencedChunks(),
|
||||
equalTo(listOf(chunk1.copy(refCount = 0), chunk2.copy(refCount = 0)))
|
||||
)
|
||||
|
||||
chunksCache.decrementRefCount(listOf(chunk3.id))
|
||||
chunksCache.decrementRefCount(listOf(chunk3.id))
|
||||
chunksCache.decrementRefCount(listOf(chunk3.id))
|
||||
assertThat(
|
||||
chunksCache.getUnreferencedChunks(),
|
||||
equalTo(
|
||||
listOf(
|
||||
chunk1.copy(refCount = 0),
|
||||
chunk2.copy(refCount = 0),
|
||||
chunk3.copy(refCount = 0)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
chunksCache.incrementRefCount(listOf(chunk1.id, chunk2.id))
|
||||
assertThat(chunksCache.getUnreferencedChunks(), equalTo(listOf(chunk3.copy(refCount = 0))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAreAllAvailableChunksCached() {
|
||||
assertTrue(chunksCache.areAllAvailableChunksCached(db, listOf()))
|
||||
assertTrue(chunksCache.areAllAvailableChunksCached(db, listOf("id1")))
|
||||
assertTrue(chunksCache.areAllAvailableChunksCached(db, listOf("id1", "id2")))
|
||||
assertTrue(chunksCache.areAllAvailableChunksCached(db, listOf("id1", "id2", "id3")))
|
||||
assertTrue(chunksCache.areAllAvailableChunksCached(db, listOf("id1", "id2", "id3")))
|
||||
assertFalse(chunksCache.areAllAvailableChunksCached(db, listOf("id1", "id2", "id3", "id4")))
|
||||
assertFalse(chunksCache.areAllAvailableChunksCached(db, listOf("foo", "bar")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearAndRepopulate() {
|
||||
val newChunks = listOf(
|
||||
chunk1.copy(id = "newId1", refCount = 4),
|
||||
chunk2.copy(id = "newId2", refCount = 6),
|
||||
chunk3.copy(id = "newId3", refCount = 8)
|
||||
)
|
||||
chunksCache.clearAndRepopulate(db, newChunks)
|
||||
|
||||
assertNull(chunksCache.get("id1"))
|
||||
assertNull(chunksCache.get("id2"))
|
||||
assertNull(chunksCache.get("id3"))
|
||||
|
||||
assertThat(chunksCache.get("newId1"), equalTo(newChunks[0]))
|
||||
assertThat(chunksCache.get("newId2"), equalTo(newChunks[1]))
|
||||
assertThat(chunksCache.get("newId3"), equalTo(newChunks[2]))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package org.calyxos.backup.storage.db
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
internal class FilesCacheTest {
|
||||
|
||||
private lateinit var filesCache: FilesCache
|
||||
private lateinit var db: Db
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, Db::class.java).build()
|
||||
filesCache = db.getFilesCache()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWriteAndRead() {
|
||||
val uri1 = Uri.parse("content://foo/bar")
|
||||
val file1 = CachedFile(
|
||||
uri = uri1,
|
||||
size = Random.nextLong(),
|
||||
lastModified = Random.nextLong(),
|
||||
chunks = listOf("foo", "bar"),
|
||||
lastSeen = Random.nextLong()
|
||||
)
|
||||
val uri2 = Uri.parse("content://media/external")
|
||||
val file2 = CachedFile(
|
||||
uri = uri2,
|
||||
size = Random.nextLong(),
|
||||
lastModified = Random.nextLong(),
|
||||
chunks = listOf("23", "42"),
|
||||
lastSeen = Random.nextLong()
|
||||
)
|
||||
filesCache.insert(file1)
|
||||
filesCache.insert(file2)
|
||||
assertThat(filesCache.getByUri(uri1), equalTo(file1))
|
||||
assertThat(filesCache.getByUri(uri2), equalTo(file2))
|
||||
|
||||
assertThat(filesCache.getByUri(Uri.parse("doesntExist")), equalTo(null))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package org.calyxos.backup.storage.db
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.calyxos.backup.storage.toStoredUri
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
internal class UriStoreTest {
|
||||
|
||||
private lateinit var uriStore: UriStore
|
||||
private lateinit var db: Db
|
||||
|
||||
private val uri1 = Uri.parse("content://foo/bar/1").toStoredUri()
|
||||
private val uri2 = Uri.parse("content://foo/bar/2").toStoredUri()
|
||||
private val uri3 = Uri.parse("content://foo/bar/3").toStoredUri()
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, Db::class.java).build()
|
||||
uriStore = db.getUriStore()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertAndGet() {
|
||||
uriStore.addStoredUri(uri1)
|
||||
assertThat(uriStore.getStoredUris(), equalTo(listOf(uri1)))
|
||||
uriStore.addStoredUri(uri2)
|
||||
uriStore.addStoredUri(uri3)
|
||||
assertThat(uriStore.getStoredUris(), equalTo(listOf(uri1, uri2, uri3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertAndRemove() {
|
||||
uriStore.addStoredUri(uri1)
|
||||
assertThat(uriStore.getStoredUris(), equalTo(listOf(uri1)))
|
||||
uriStore.removeStoredUri(uri1)
|
||||
assertThat(uriStore.getStoredUris(), equalTo(emptyList()))
|
||||
uriStore.addStoredUri(uri2)
|
||||
uriStore.addStoredUri(uri3)
|
||||
assertThat(uriStore.getStoredUris(), equalTo(listOf(uri2, uri3)))
|
||||
uriStore.removeStoredUri(uri3)
|
||||
assertThat(uriStore.getStoredUris(), equalTo(listOf(uri2)))
|
||||
}
|
||||
|
||||
}
|
34
storage/lib/src/main/AndroidManifest.xml
Normal file
34
storage/lib/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.calyxos.backup.storage">
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<!-- Needed to backup original media files e.g. without stripped EXIF metadata -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
|
||||
<!--
|
||||
Needed to restore owning app of media files
|
||||
http://aosp.opersys.com/xref/android-11.0.0_r17/xref/packages/providers/MediaProvider/src/com/android/providers/media/util/PermissionUtils.java#88
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.BACKUP"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!-- Needed to schedule the periodic backup job -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Needed to run a periodic backup service that doesn't get killed -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,5 @@
|
|||
package org.calyxos.backup.storage
|
||||
|
||||
internal fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
|
||||
|
||||
internal fun String.toByteArrayFromHex() = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
@ -0,0 +1,38 @@
|
|||
package org.calyxos.backup.storage
|
||||
|
||||
import android.util.Log
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.toDuration
|
||||
|
||||
/**
|
||||
* We don't use [kotlin.time.measureTime] or [kotlin.time.measureTimedValue],
|
||||
* because those are not available in the Kotlin version shipped with Android 11.
|
||||
* So when building with AOSP 11, things will blow up.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalContracts::class, ExperimentalTime::class)
|
||||
internal inline fun measure(block: () -> Unit): Duration {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
block()
|
||||
return (System.currentTimeMillis() - start).toDuration(DurationUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class, ExperimentalTime::class)
|
||||
internal inline fun <T> measure(text: String, block: () -> T): T {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
val result = block()
|
||||
val duration = (System.currentTimeMillis() - start).toDuration(DurationUnit.MILLISECONDS)
|
||||
Log.e("Time", "$text took $duration")
|
||||
return result
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package org.calyxos.backup.storage
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import org.calyxos.backup.storage.api.MediaType
|
||||
import org.calyxos.backup.storage.api.mediaItems
|
||||
import org.calyxos.backup.storage.backup.BackupMediaFile
|
||||
import org.calyxos.backup.storage.db.StoredUri
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
internal fun Uri.toStoredUri(): StoredUri = StoredUri(this)
|
||||
|
||||
internal fun Uri.getDocumentPath(): String? {
|
||||
return lastPathSegment?.split(':')?.getOrNull(1)
|
||||
}
|
||||
|
||||
internal fun Uri.getVolume(): String? {
|
||||
val volume = lastPathSegment?.split(':')?.getOrNull(0)
|
||||
return if (volume == "primary") MediaStore.VOLUME_EXTERNAL_PRIMARY else volume
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
public fun Uri.openInputStream(contentResolver: ContentResolver): InputStream {
|
||||
return try {
|
||||
contentResolver.openInputStream(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// This is necessary, because contrary to the documentation, files that have been deleted
|
||||
// after we retrieved their Uri, will throw an IllegalArgumentException
|
||||
throw IOException(e)
|
||||
} ?: throw IOException("Stream for $this returned null")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
public fun Uri.openOutputStream(contentResolver: ContentResolver): OutputStream {
|
||||
return try {
|
||||
contentResolver.openOutputStream(this, "wt")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// This is necessary, because contrary to the documentation, files that have been deleted
|
||||
// after we retrieved their Uri, will throw an IllegalArgumentException
|
||||
throw IOException(e)
|
||||
} ?: throw IOException("Stream for $this returned null")
|
||||
}
|
||||
|
||||
internal fun Uri.getMediaType(): MediaType? {
|
||||
val str = toString()
|
||||
for (item in mediaItems) {
|
||||
if (str.startsWith(item.contentUri.toString())) return item
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun Uri.getBackupMediaType(): BackupMediaFile.MediaType? {
|
||||
return getMediaType()?.backupType
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
package org.calyxos.backup.storage.api
|
||||
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.calyxos.backup.storage.R
|
||||
import org.calyxos.backup.storage.backup.BackupMediaFile
|
||||
import org.calyxos.backup.storage.getDocumentPath
|
||||
|
||||
// hidden in DocumentsContract
|
||||
public const val EXTERNAL_STORAGE_PROVIDER_AUTHORITY: String =
|
||||
"com.android.externalstorage.documents"
|
||||
|
||||
public val mediaItems: List<MediaType> = listOf(
|
||||
MediaType.Images,
|
||||
MediaType.Video,
|
||||
MediaType.Audio,
|
||||
MediaType.Downloads
|
||||
)
|
||||
|
||||
public val mediaUris: List<Uri> = listOf(
|
||||
MediaType.Images.contentUri,
|
||||
MediaType.Video.contentUri,
|
||||
MediaType.Audio.contentUri,
|
||||
MediaType.Downloads.contentUri
|
||||
)
|
||||
|
||||
public sealed class BackupContentType(
|
||||
@DrawableRes
|
||||
public val drawableRes: Int
|
||||
) {
|
||||
public object Custom : BackupContentType(R.drawable.ic_folder) {
|
||||
public fun getName(uri: Uri): String {
|
||||
val path = uri.getDocumentPath()!!
|
||||
return if (path.isBlank()) "/" else path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MediaType(
|
||||
public val contentUri: Uri,
|
||||
@StringRes
|
||||
public val nameRes: Int,
|
||||
@DrawableRes
|
||||
drawableRes: Int,
|
||||
public val backupType: BackupMediaFile.MediaType,
|
||||
) : BackupContentType(drawableRes) {
|
||||
public object Images : MediaType(
|
||||
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
nameRes = R.string.content_images,
|
||||
drawableRes = R.drawable.ic_photo_library,
|
||||
backupType = BackupMediaFile.MediaType.IMAGES,
|
||||
)
|
||||
|
||||
public object Video : MediaType(
|
||||
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||
nameRes = R.string.content_videos,
|
||||
drawableRes = R.drawable.ic_video_library,
|
||||
backupType = BackupMediaFile.MediaType.VIDEO,
|
||||
)
|
||||
|
||||
public object Audio : MediaType(
|
||||
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
nameRes = R.string.content_audio,
|
||||
drawableRes = R.drawable.ic_music_library,
|
||||
backupType = BackupMediaFile.MediaType.AUDIO,
|
||||
)
|
||||
|
||||
public object Downloads : MediaType(
|
||||
contentUri = MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
nameRes = R.string.content_downloads,
|
||||
drawableRes = R.drawable.ic_download_library,
|
||||
backupType = BackupMediaFile.MediaType.DOWNLOADS,
|
||||
)
|
||||
|
||||
internal companion object {
|
||||
fun fromBackupMediaType(type: BackupMediaFile.MediaType): MediaType = when (type) {
|
||||
BackupMediaFile.MediaType.IMAGES -> Images
|
||||
BackupMediaFile.MediaType.VIDEO -> Video
|
||||
BackupMediaFile.MediaType.AUDIO -> Audio
|
||||
BackupMediaFile.MediaType.DOWNLOADS -> Downloads
|
||||
else -> throw AssertionError("Unrecognized media type: $type")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package org.calyxos.backup.storage.api
|
||||
|
||||
import android.provider.MediaStore
|
||||
|
||||
public interface BackupFile {
|
||||
public val path: String
|
||||
|
||||
/**
|
||||
* empty string for [MediaStore.VOLUME_EXTERNAL_PRIMARY]
|
||||
*/
|
||||
public val volume: String
|
||||
public val size: Long
|
||||
public val lastModified: Long?
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package org.calyxos.backup.storage.api
|
||||
|
||||
public interface BackupObserver {
|
||||
|
||||
public suspend fun onStartScanning()
|
||||
|
||||
/**
|
||||
* Called after scanning files when starting the backup.
|
||||
*
|
||||
* @param totalSize the total size of all files to be backed up.
|
||||
* @param numFiles the number of all files to be backed up.
|
||||
* The sum of [numSmallFiles] and [numLargeFiles].
|
||||
* @param numSmallFiles the number of small files to be backed up.
|
||||
* @param numLargeFiles the number of large files to be backed up.
|
||||
*/
|
||||
public suspend fun onBackupStart(
|
||||
totalSize: Long,
|
||||
numFiles: Int,
|
||||
numSmallFiles: Int,
|
||||
numLargeFiles: Int,
|
||||
)
|
||||
|
||||
public suspend fun onFileBackedUp(
|
||||
file: BackupFile,
|
||||
wasUploaded: Boolean,
|
||||
reusedChunks: Int,
|
||||
bytesWritten: Long,
|
||||
tag: String,
|
||||
)
|
||||
|
||||
public suspend fun onFileBackupError(
|
||||
file: BackupFile,
|
||||
tag: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* If backupDuration is null, the overall backup failed.
|
||||
*/
|
||||
public suspend fun onBackupComplete(backupDuration: Long?)
|
||||
|
||||
public suspend fun onPruneStartScanning()
|
||||
|
||||
public suspend fun onPruneStart(snapshotsToDelete: List<Long>)
|
||||
|
||||
public suspend fun onPruneSnapshot(snapshot: Long, numChunksToDelete: Int, size: Long)
|
||||
|
||||
/**
|
||||
* If snapshot is null, the overall operation failed.
|
||||
*/
|
||||
public suspend fun onPruneError(snapshot: Long?, e: Exception)
|
||||
|
||||
public suspend fun onPruneComplete(pruneDuration: Long)
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue