Backup & restore

This commit is contained in:
MM20
2022-06-09 19:26:45 +02:00
parent c7fb8bf57a
commit eaf4701500
37 changed files with 1660 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="de.mm20.launcher2.backup">
</manifest>
@@ -0,0 +1,14 @@
package de.mm20.launcher2.backup
enum class BackupComponent(val value: String) {
Settings("settings"),
Favorites("favorites"),
Widgets("widgets"),
Websearches("websearches");
companion object {
fun fromValue(value: String): BackupComponent? {
return values().firstOrNull { it.value == value }
}
}
}
@@ -0,0 +1,199 @@
package de.mm20.launcher2.backup
import android.content.Context
import android.net.Uri
import android.os.Build
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.export
import de.mm20.launcher2.preferences.import
import de.mm20.launcher2.search.WebsearchRepository
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.*
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class BackupManager(
private val context: Context,
private val dataStore: LauncherDataStore,
private val favoritesRepository: FavoritesRepository,
private val widgetRepository: WidgetRepository,
private val websearchRepository: WebsearchRepository,
) {
private val scope = CoroutineScope(Dispatchers.Default + Job())
/**
* Create a backup
* @return Uri to the created backup archive
*/
suspend fun backup(
uri: Uri,
include: Set<BackupComponent> = BackupComponent.values().toSet()
) {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val meta = BackupMetadata(
appVersionName = packageInfo.versionName,
timestamp = System.currentTimeMillis(),
deviceName = Build.MODEL,
components = include,
format = BackupFormat,
)
withContext(Dispatchers.IO) {
val outputStream = context.contentResolver.openOutputStream(uri) ?: return@withContext null
val backupDir = File(context.externalCacheDir, "backup")
if (backupDir.exists()) {
backupDir.deleteRecursively()
}
backupDir.mkdirs()
val metaFile = File(backupDir, "meta")
meta.writeToFile(metaFile)
if (include.contains(BackupComponent.Settings)) {
dataStore.export(backupDir)
}
if (include.contains(BackupComponent.Favorites)) {
favoritesRepository.export(backupDir)
}
if (include.contains(BackupComponent.Widgets)) {
widgetRepository.export(backupDir)
}
if (include.contains(BackupComponent.Websearches)) {
websearchRepository.export(backupDir)
}
createArchive(backupDir, outputStream)
outputStream.close()
}
}
suspend fun restore(
uri: Uri,
include: Set<BackupComponent> = BackupComponent.values().toSet()
) {
val job = scope.launch {
withContext(Dispatchers.IO) {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext
val restoreDir = File(context.cacheDir, "restore")
if (restoreDir.exists()) {
restoreDir.deleteRecursively()
}
restoreDir.mkdirs()
extractArchive(inputStream, restoreDir)
inputStream.close()
if (include.contains(BackupComponent.Settings)) {
dataStore.import(context, restoreDir)
}
if (include.contains(BackupComponent.Favorites)) {
favoritesRepository.import(restoreDir)
}
if (include.contains(BackupComponent.Widgets)) {
widgetRepository.import(restoreDir)
}
if (include.contains(BackupComponent.Websearches)) {
websearchRepository.import(restoreDir)
}
}
}
job.join()
}
suspend fun readBackupMeta(uri: Uri): BackupMetadata? {
return withContext(Dispatchers.IO) {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null
val zipStream = ZipInputStream(inputStream)
var entry = zipStream.nextEntry
while(entry != null) {
if (entry.name == "meta") {
val metadata = BackupMetadata.fromInputStream(zipStream)
zipStream.close()
return@withContext metadata
}
zipStream.closeEntry()
entry = zipStream.nextEntry
}
return@withContext null
}
}
private suspend fun createArchive(dir: File, outputStream: OutputStream) = withContext(Dispatchers.IO){
val zipStream = ZipOutputStream(outputStream)
val fileList = dir.listFiles()
for (file in fileList) {
zipStream.putNextEntry(ZipEntry(file.name))
file.inputStream().use {
it.copyTo(zipStream)
}
zipStream.closeEntry()
}
zipStream.close()
}
private suspend fun extractArchive(inputStream: InputStream, outDir: File) = withContext(Dispatchers.IO) {
val zipStream = ZipInputStream(inputStream)
var entry = zipStream.nextEntry
while(entry != null) {
val file = File(outDir, entry.name)
file.outputStream().use {
zipStream.copyTo(it)
}
zipStream.closeEntry()
entry = zipStream.nextEntry
}
}
fun checkCompatibility(meta: BackupMetadata): BackupCompatibility {
val format = meta.format.split(".")
val x = format.getOrNull(0)?.toIntOrNull() ?: return BackupCompatibility.Incompatible
val y = format.getOrNull(1)?.toIntOrNull() ?: return BackupCompatibility.Incompatible
if (x != BackupFormatMajor) return BackupCompatibility.Incompatible
if (y != BackupFormatMinor) return BackupCompatibility.PartiallyCompatible
return BackupCompatibility.Compatible
}
companion object {
private const val BackupFormatMajor = 1
private const val BackupFormatMinor = 0
internal const val BackupFormat = "$BackupFormatMajor.$BackupFormatMinor"
}
}
enum class BackupCompatibility {
/**
* Fully compatible, can be fully restored
*/
Compatible,
/**
* Incompatible, cannot be restored
*/
Incompatible,
/**
* Compatible but has been created on a different version and parts of the backup use a different format
* or were not supported / are not supported anymore so parts of the backup might not be restored.
*/
PartiallyCompatible
}
@@ -0,0 +1,64 @@
package de.mm20.launcher2.backup
import de.mm20.launcher2.ktx.jsonObjectOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.File
import java.io.InputStream
data class BackupMetadata(
val deviceName: String,
val timestamp: Long,
val appVersionName: String,
/**
* Backup schema version in format x.y.
*/
val format: String,
val components: Set<BackupComponent>,
) {
internal suspend fun writeToFile(file: File) {
val json = jsonObjectOf(
"device" to deviceName,
"timestamp" to timestamp,
"format" to format,
"versionName" to appVersionName,
"components" to JSONArray(components.map { it.value })
)
withContext(Dispatchers.IO) {
file.outputStream().bufferedWriter().use {
it.write(json.toString())
}
}
}
companion object {
internal suspend fun fromInputStream(inputStream: InputStream): BackupMetadata? {
return withContext(Dispatchers.IO) {
val text = inputStream.reader().readText()
try {
val json = JSONObject(text)
return@withContext BackupMetadata(
deviceName = json.optString("device"),
timestamp = json.optLong("timestamp"),
format = json.optString("format"),
appVersionName = json.optString("versionName"),
components = json.getJSONArray("components").let {
val set = mutableSetOf<BackupComponent>()
for (i in 0 until it.length()) {
val component = BackupComponent.fromValue(it.getString(i))
if (component != null) set.add(component)
}
set
}
)
} catch (e: JSONException) {
return@withContext null
}
}
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.backup
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val backupModule = module {
single<BackupManager> { BackupManager(androidContext(), get(), get(), get(), get()) }
}