Add storage library (and demo app)

and use for periodic files backup
This commit is contained in:
Torsten Grote 2021-01-19 09:14:52 -03:00 committed by Chirayu Desai
parent 1cd3a3a0e6
commit 6c633b70c3
179 changed files with 9668 additions and 182 deletions

8
.gitignore vendored
View file

@ -6,7 +6,7 @@ hs_err_pid*
## Intellij ## Intellij
out/ out/
lib/ /lib/
.idea/* .idea/*
!.idea/runConfigurations* !.idea/runConfigurations*
!.idea/inspectionProfiles* !.idea/inspectionProfiles*
@ -33,7 +33,8 @@ local.properties
## NetBeans ## NetBeans
**/nbproject/private/ **/nbproject/private/
build/ /build/
/app/build/
nbbuild/ nbbuild/
dist/ dist/
nbdist/ nbdist/
@ -50,6 +51,3 @@ gradle-app.setting
## Android ## Android
gen/ gen/
## Prebuilt
Backup.apk

View 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>

View file

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <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" /> <module name="seedvault.app" />
<option name="TESTING_TYPE" value="0" /> <option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" /> <option name="METHOD_NAME" value="" />

View file

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <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" /> <module name="seedvault.app" />
<useClassPathOnly /> <useClassPathOnly />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" /> <option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />

View 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>

View 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>

View file

@ -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>

View file

@ -31,9 +31,16 @@ android_app {
"androidx.lifecycle_lifecycle-livedata-ktx", "androidx.lifecycle_lifecycle-livedata-ktx",
"androidx-constraintlayout_constraintlayout", "androidx-constraintlayout_constraintlayout",
"com.google.android.material_material", "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-core", // did not manage to add this as transitive dependency
"seedvault-lib-koin-android", "seedvault-lib-koin-android",
"seedvault-lib-koin-androidx-viewmodel", "seedvault-lib-koin-androidx-viewmodel",
// bip39
"seedvault-lib-novacrypto-bip39", "seedvault-lib-novacrypto-bip39",
], ],
manifest: "app/src/main/AndroidManifest.xml", manifest: "app/src/main/AndroidManifest.xml",

View file

@ -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.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.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.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. * `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX.
## Contributing ## Contributing

View file

@ -16,13 +16,12 @@ def gitDescribe = { ->
} }
android { android {
compileSdkVersion rootProject.ext.compileSdkVersion
compileSdkVersion 30 buildToolsVersion rootProject.ext.buildToolsVersion
buildToolsVersion '30.0.2'
defaultConfig { defaultConfig {
minSdkVersion 29 // leave at 29 for robolectric tests minSdkVersion 29 // leave at 29 for robolectric tests
targetSdkVersion 30 targetSdkVersion rootProject.ext.targetSdkVersion
versionNameSuffix "-$gitDescribe" versionNameSuffix "-$gitDescribe"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments disableAnalytics: 'true' testInstrumentationRunnerArguments disableAnalytics: 'true'
@ -44,6 +43,7 @@ android {
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
languageVersion = "1.3"
} }
testOptions { testOptions {
unitTests.all { unitTests.all {
@ -81,7 +81,72 @@ android {
buildTypes.debug.signingConfig = signingConfigs.aosp 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 { ktlint {
version = "0.36.0" // https://github.com/pinterest/ktlint/issues/764 version = "0.36.0" // https://github.com/pinterest/ktlint/issues/764

View file

@ -113,5 +113,18 @@
</intent-filter> </intent-filter>
</receiver> </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> </application>
</manifest> </manifest>

View file

@ -17,8 +17,10 @@ import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.settings.AppListRetriever import com.stevesoltys.seedvault.settings.AppListRetriever
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.SettingsViewModel import com.stevesoltys.seedvault.settings.SettingsViewModel
import com.stevesoltys.seedvault.storage.storageModule
import com.stevesoltys.seedvault.transport.backup.backupModule import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.transport.restore.restoreModule 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.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
@ -43,11 +45,12 @@ open class App : Application() {
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
factory { AppListRetriever(this@App, get(), get(), get()) } 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 { RecoveryCodeViewModel(this@App, get(), get(), get()) }
viewModel { BackupStorageViewModel(this@App, get(), get(), get()) } viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) } viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
viewModel { FileSelectionViewModel(this@App, get()) }
} }
override fun onCreate() { override fun onCreate() {
@ -85,6 +88,7 @@ open class App : Application() {
backupModule, backupModule,
restoreModule, restoreModule,
installModule, installModule,
storageModule,
appModule appModule
) )
) )

View file

@ -36,7 +36,7 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View {
setHasOptionsMenu(true) setHasOptionsMenu(true)
val v: View = inflater.inflate(R.layout.fragment_app_status, container, false) val v: View = inflater.inflate(R.layout.fragment_app_status, container, false)

View file

@ -1,12 +1,11 @@
package com.stevesoltys.seedvault.settings package com.stevesoltys.seedvault.settings
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.content.Context.BACKUP_SERVICE // ktlint-disable no-unused-imports
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.RemoteException import android.os.RemoteException
import android.provider.Settings 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.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
@ -38,6 +37,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var apkBackup: TwoStatePreference private lateinit var apkBackup: TwoStatePreference
private lateinit var backupLocation: Preference private lateinit var backupLocation: Preference
private lateinit var backupStatus: Preference private lateinit var backupStatus: Preference
private lateinit var backupStorage: TwoStatePreference
private lateinit var backupRecoveryCode: Preference
private var menuBackupNow: MenuItem? = null private var menuBackupNow: MenuItem? = null
private var menuRestore: MenuItem? = null private var menuRestore: MenuItem? = null
@ -102,6 +103,19 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@OnPreferenceChangeListener false return@OnPreferenceChangeListener false
} }
backupStatus = findPreference("backup_status")!! 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -110,6 +124,11 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel.lastBackupTime.observe(viewLifecycleOwner, Observer { time -> viewModel.lastBackupTime.observe(viewLifecycleOwner, Observer { time ->
setAppBackupStatusSummary(time) setAppBackupStatusSummary(time)
}) })
val backupFiles: Preference = findPreference("backup_files")!!
viewModel.filesSummary.observe(viewLifecycleOwner, Observer { summary ->
backupFiles.summary = summary
})
} }
override fun onStart() { override fun onStart() {
@ -190,4 +209,40 @@ class SettingsFragment : PreferenceFragmentCompat() {
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup) 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()
}
} }

View file

@ -1,6 +1,7 @@
package com.stevesoltys.seedvault.settings package com.stevesoltys.seedvault.settings
import android.app.Application import android.app.Application
import android.app.job.JobInfo.NETWORK_TYPE_UNMETERED
import android.database.ContentObserver import android.database.ContentObserver
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
@ -22,11 +23,15 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.transport.requestBackup import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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 TAG = "SettingsViewModel"
private const val USER_FULL_DATA_BACKUP_AWARE = "user_full_data_backup_aware" private const val USER_FULL_DATA_BACKUP_AWARE = "user_full_data_backup_aware"
@ -37,7 +42,8 @@ internal class SettingsViewModel(
keyManager: KeyManager, keyManager: KeyManager,
private val notificationManager: BackupNotificationManager, private val notificationManager: BackupNotificationManager,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val appListRetriever: AppListRetriever private val appListRetriever: AppListRetriever,
private val storageBackup: StorageBackup
) : RequireProvisioningViewModel(app, settingsManager, keyManager) { ) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
private val contentResolver = app.contentResolver private val contentResolver = app.contentResolver
@ -59,6 +65,9 @@ internal class SettingsViewModel(
private val mAppEditMode = MutableLiveData<Boolean>() private val mAppEditMode = MutableLiveData<Boolean>()
internal val appEditMode: LiveData<Boolean> = mAppEditMode internal val appEditMode: LiveData<Boolean> = mAppEditMode
private val _filesSummary = MutableLiveData<String>()
internal val filesSummary: LiveData<String> = _filesSummary
private val storageObserver = object : ContentObserver(null) { private val storageObserver = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) { override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) {
onStorageLocationChanged() onStorageLocationChanged()
@ -89,6 +98,7 @@ internal class SettingsViewModel(
metadataManager.getLastBackupTime() metadataManager.getLastBackupTime()
} }
onStorageLocationChanged() onStorageLocationChanged()
loadFilesSummary()
} }
override fun onStorageLocationChanged() { 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 // maybe replace the check below with one that checks if our transport service is running
if (notificationManager.hasActiveBackupNotifications()) { if (notificationManager.hasActiveBackupNotifications()) {
Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show() Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show()
} else { } else viewModelScope.launch(Dispatchers.IO) {
Thread { requestBackup(app) }.start() requestBackup(app)
} }
} }
@ -156,6 +166,14 @@ internal class SettingsViewModel(
settingsManager.onAppBackupStatusChanged(status) 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. * 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)
}
} }

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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()) }
}

View file

@ -9,7 +9,7 @@ import com.stevesoltys.seedvault.ui.storage.StorageViewModel
abstract class RequireProvisioningViewModel( abstract class RequireProvisioningViewModel(
protected val app: Application, protected val app: Application,
protected val settingsManager: SettingsManager, protected val settingsManager: SettingsManager,
private val keyManager: KeyManager protected val keyManager: KeyManager
) : AndroidViewModel(app) { ) : AndroidViewModel(app) {
abstract val isRestoreOperation: Boolean abstract val isRestoreOperation: Boolean

View file

@ -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()
}
}

View file

@ -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() }
}
}

View file

@ -62,7 +62,7 @@ class RecoveryCodeInputFragment : Fragment() {
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View {
val v: View = inflater.inflate(R.layout.fragment_recovery_code_input, container, false) val v: View = inflater.inflate(R.layout.fragment_recovery_code_input, container, false)
introText = v.findViewById(R.id.introText) introText = v.findViewById(R.id.introText)
@ -93,6 +93,8 @@ class RecoveryCodeInputFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
activity?.setTitle(R.string.recovery_code_title)
if (viewModel.isRestore) { if (viewModel.isRestore) {
introText.setText(R.string.recovery_code_input_intro) introText.setText(R.string.recovery_code_input_intro)
backView.visibility = VISIBLE backView.visibility = VISIBLE

View 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>

View 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>

View file

@ -1,7 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal" android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -8,7 +8,8 @@
<string name="restore_backup_button">Restore backup</string> <string name="restore_backup_button">Restore backup</string>
<!-- Settings --> <!-- 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">Backup location</string>
<string name="settings_backup_location_none">None</string> <string name="settings_backup_location_none">None</string>
<string name="settings_backup_location_internal">Internal storage</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_status_summary">Last backup: %1$s</string>
<string name="settings_backup_exclude_apps">Exclude apps</string> <string name="settings_backup_exclude_apps">Exclude apps</string>
<string name="settings_backup_now">Backup now</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">Recovery code</string>
<string name="settings_backup_recovery_code_summary">Verify existing code or generate a new one</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_backup_title">Choose where to store backups</string>
<string name="storage_fragment_restore_title">Where to find your 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> <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>

View file

@ -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"> 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 <androidx.preference.SwitchPreferenceCompat
app:allowDividerBelow="true"
app:icon="@drawable/ic_cloud_upload" app:icon="@drawable/ic_cloud_upload"
app:key="backup" app:key="backup"
app:persistent="false" app:persistent="false"
app:title="@string/settings_backup" /> app:title="@string/settings_backup" />
<androidx.preference.Preference <androidx.preference.Preference
app:allowDividerAbove="true"
app:fragment="com.stevesoltys.seedvault.settings.AppStatusFragment" app:fragment="com.stevesoltys.seedvault.settings.AppStatusFragment"
app:icon="@drawable/ic_apps" app:icon="@drawable/ic_apps"
app:key="backup_status" app:key="backup_status"
app:title="@string/settings_backup_status_title" app:title="@string/settings_backup_status_title"
tools:summary="Last backup: Never" /> 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 <androidx.preference.SwitchPreferenceCompat
app:dependency="backup" app:dependency="backup"
app:key="auto_restore" app:key="auto_restore"
app:persistent="false" app:persistent="false"
app:summary="@string/settings_auto_restore_summary" 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 <androidx.preference.SwitchPreferenceCompat
app:defaultValue="true" app:defaultValue="true"
@ -36,18 +46,30 @@
app:summary="@string/settings_backup_apk_summary" app:summary="@string/settings_backup_apk_summary"
app:title="@string/settings_backup_apk_title" /> 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 <androidx.preference.Preference
app:dependency="backup" android:dependency="backup_storage"
app:fragment="com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeInputFragment" app:dependency="backup_storage"
app:icon="@drawable/ic_vpn_key" app:fragment="com.stevesoltys.seedvault.ui.files.FileSelectionFragment"
app:key="backup_recovery_code" app:icon="@drawable/ic_library_add"
app:summary="@string/settings_backup_recovery_code_summary" app:key="backup_files"
app:title="@string/settings_backup_recovery_code" /> app:summary="@string/settings_backup_files_summary"
app:title="@string/settings_backup_files_title" />
</PreferenceCategory>
<androidx.preference.Preference <androidx.preference.Preference
app:allowDividerAbove="true" app:allowDividerAbove="true"
app:allowDividerBelow="false" app:allowDividerBelow="false"
app:dependency="backup"
app:icon="@drawable/ic_info_outline" app:icon="@drawable/ic_info_outline"
app:selectable="false" app:selectable="false"
app:summary="@string/settings_info" /> app:summary="@string/settings_info" />

View file

@ -1,11 +1,13 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.TestApp import com.stevesoltys.seedvault.TestApp
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -27,6 +29,14 @@ internal class DocumentFileTest {
"content://com.android.externalstorage.documents/tree/" + "content://com.android.externalstorage.documents/tree/" +
"primary%3A/document/primary%3A.SeedVaultAndroidBackup" "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 parentFile: DocumentFile = DocumentFile.fromTreeUri(context, parentUri)!!
private val uri: Uri = Uri.parse( private val uri: Uri = Uri.parse(
"content://com.android.externalstorage.documents/tree/" + "content://com.android.externalstorage.documents/tree/" +

View file

@ -1,10 +1,10 @@
buildscript { buildscript {
// 1.3.21 Android 10 // 1.3.21 Android 10
// 1.3.61 Android 11 // 1.3.61 Android 11
// Check: // Check:
// https://android.googlesource.com/platform/external/kotlinc/+/refs/tags/android-11.0.0_r3/build.txt // 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 { repositories {
jcenter() jcenter()
@ -13,10 +13,20 @@ buildscript {
dependencies { dependencies {
//noinspection DifferentKotlinGradleVersion //noinspection DifferentKotlinGradleVersion
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 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 { allprojects {
repositories { repositories {
mavenCentral() mavenCentral()

View file

@ -50,16 +50,12 @@ def aospDeps = fileTree(include: [
dependencies { dependencies {
implementation aospDeps implementation aospDeps
//noinspection GradleDependency
testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
testImplementation 'junit:junit:4.13.1' testImplementation "junit:junit:$junit4_version"
def mockk_version = "1.10.2"
testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk:$mockk_version"
//noinspection GradleDependency
androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.ext:junit:1.1.2'
def espresso_version = "3.3.0"
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "io.mockk:mockk-android:$mockk_version" androidTestImplementation "io.mockk:mockk-android:$mockk_version"
} }

View file

@ -1,3 +1,5 @@
org.gradle.jvmargs=-Xmx1g org.gradle.jvmargs=-Xmx1g
org.gradle.configureondemand=true
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=false android.enableJetifier=false
kotlin.code.style=official

View file

@ -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 // To produce these binaries, in latest AOSP source tree, run
// $ m // $ m
def aospDeps = fileTree(include: [ ext.aosp_libs = fileTree(include: [
// For more information about this module: // For more information about this module:
// https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507 // 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, // 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 // out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
'android.jar', 'android.jar',
// out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art.release_intermediates/classes.jar // out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art.release_intermediates/classes.jar
'libcore.jar' 'libcore.jar',
], dir: 'libs') ], dir: "$projectDir/app/libs")
dependencies { ext.kotlin_libs = [
compileOnly aospDeps std: [
dependencies.create('org.jetbrains.kotlin:kotlin-stdlib') {
/** version { strictly "$aosp_kotlin_version" }
* Dependencies in AOSP },
* dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {
* We try to keep the dependencies in sync with what AOSP ships as Seedvault is meant to be built version { strictly "$aosp_kotlin_version" }
* 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. dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-common') {
*/ version { strictly "$aosp_kotlin_version" }
},
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') { ],
version { strictly "$kotlin_version" } coroutines: [
} dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-core') {
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') {
// https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#326 // https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#326
version { strictly '1.3.0' } 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 // https://android.googlesource.com/platform/prebuilts/tools/+/refs/tags/android-11.0.0_r3/common/m2/Android.bp#340
version { strictly '1.3.0' } 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 // 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' } 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 // 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' } 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 // 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 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 // 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' } 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 // 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' } 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 // 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' } version { strictly '2.0.0-beta7' }
} },
// https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-11.0.0_r3/current/androidx/Android.bp#708
implementation('com.google.android.material:material') { 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 // 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' } version { strictly '1.1.0-alpha05' }
} },
]
ext.lint_libs = [
exceptions: 'com.github.thirdegg:lint-rules:0.0.6-beta'
]
/** ext.storage_libs = [
* External Dependencies // 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') {
* If the dependencies below are updated, version { strictly "$room_version" }
* please make sure to update the prebuilt libraries and the Android.bp files },
* in the top-level `libs` folder to reflect that. // http://aosp.opersys.com/xref/android-11.0.0_r27/xref/external/protobuf/java/pom.xml#7
* You can copy these libraries from ~/.gradle/caches/modules-2 com_google_protobuf_javalite: dependencies.create('com.google.protobuf:protobuf-javalite') {
*/ version { strictly "$protobuf_version" }
},
def koin_version = '2.1.1' // later versions require newer kotlin version com_google_crypto_tink_android: dependencies.create('com.google.crypto.tink:tink-android') {
//noinspection GradleDependency version { strictly '1.5.0' }
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"
}

View file

@ -5,5 +5,6 @@
<permission name="android.permission.MANAGE_USB"/> <permission name="android.permission.MANAGE_USB"/>
<permission name="android.permission.INSTALL_PACKAGES"/> <permission name="android.permission.INSTALL_PACKAGES"/>
<permission name="android.permission.WRITE_SECURE_SETTINGS"/> <permission name="android.permission.WRITE_SECURE_SETTINGS"/>
<permission name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
</privapp-permissions> </privapp-permissions>
</permissions> </permissions>

View file

@ -1,2 +1,4 @@
include ':app' include ':app'
include ':contactsbackup' include ':contactsbackup'
include ':storage:lib'
include ':storage:demo'

1
storage/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
release.sh

2
storage/demo/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/build
/debug

57
storage/demo/build.gradle Normal file
View 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
View 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

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View file

@ -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!!!")
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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")
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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"
}
}

View file

@ -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()))
}
}

View file

@ -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())
}
}

View file

@ -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)
}

View file

@ -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"))
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View 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>

View 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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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" />

View 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>

View 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>

View 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>

View 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>

View 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" />

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Storage Backup Tester</string>
</resources>

View 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
View file

@ -0,0 +1,2 @@
build/*
!build/outputs/aar/lib-release.aar

15
storage/lib/Android.bp Normal file
View 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
View 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"
}

Binary file not shown.

View file

View file

@ -0,0 +1 @@
../build/outputs/aar/lib-release.aar

Binary file not shown.

23
storage/lib/proguard-rules.pro vendored Normal file
View 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.** {*;}

View 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')"
]
}
}

View file

@ -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]))
}
}

View file

@ -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))
}
}

View file

@ -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)))
}
}

View 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>

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}
}
}

View file

@ -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?
}

View file

@ -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