Backup & restore
This commit is contained in:
parent
c7fb8bf57a
commit
eaf4701500
@ -109,6 +109,7 @@ dependencies {
|
|||||||
implementation(project(":accounts"))
|
implementation(project(":accounts"))
|
||||||
implementation(project(":applications"))
|
implementation(project(":applications"))
|
||||||
implementation(project(":appshortcuts"))
|
implementation(project(":appshortcuts"))
|
||||||
|
implementation(project(":backup"))
|
||||||
implementation(project(":badges"))
|
implementation(project(":badges"))
|
||||||
implementation(project(":base"))
|
implementation(project(":base"))
|
||||||
implementation(project(":calculator"))
|
implementation(project(":calculator"))
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import coil.decode.SvgDecoder
|
|||||||
import de.mm20.launcher2.accounts.accountsModule
|
import de.mm20.launcher2.accounts.accountsModule
|
||||||
import de.mm20.launcher2.applications.applicationsModule
|
import de.mm20.launcher2.applications.applicationsModule
|
||||||
import de.mm20.launcher2.appshortcuts.appShortcutsModule
|
import de.mm20.launcher2.appshortcuts.appShortcutsModule
|
||||||
|
import de.mm20.launcher2.backup.backupModule
|
||||||
import de.mm20.launcher2.badges.badgesModule
|
import de.mm20.launcher2.badges.badgesModule
|
||||||
import de.mm20.launcher2.calculator.calculatorModule
|
import de.mm20.launcher2.calculator.calculatorModule
|
||||||
import de.mm20.launcher2.calendar.calendarModule
|
import de.mm20.launcher2.calendar.calendarModule
|
||||||
@ -53,6 +54,7 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
|
|||||||
applicationsModule,
|
applicationsModule,
|
||||||
appShortcutsModule,
|
appShortcutsModule,
|
||||||
calculatorModule,
|
calculatorModule,
|
||||||
|
backupModule,
|
||||||
badgesModule,
|
badgesModule,
|
||||||
calendarModule,
|
calendarModule,
|
||||||
contactsModule,
|
contactsModule,
|
||||||
|
|||||||
1
backup/.gitignore
vendored
Normal file
1
backup/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
49
backup/build.gradle.kts
Normal file
49
backup/build.gradle.kts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("kotlin-android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = sdk.versions.minSdk.get().toInt()
|
||||||
|
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
namespace = "de.mm20.launcher2.backup"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.bundles.kotlin)
|
||||||
|
implementation(libs.androidx.core)
|
||||||
|
|
||||||
|
implementation(libs.koin.android)
|
||||||
|
|
||||||
|
implementation(project(":favorites"))
|
||||||
|
implementation(project(":search"))
|
||||||
|
implementation(project(":widgets"))
|
||||||
|
implementation(project(":preferences"))
|
||||||
|
implementation(project(":ktx"))
|
||||||
|
|
||||||
|
}
|
||||||
0
backup/consumer-rules.pro
Normal file
0
backup/consumer-rules.pro
Normal file
21
backup/proguard-rules.pro
vendored
Normal file
21
backup/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# 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
|
||||||
5
backup/src/main/AndroidManifest.xml
Normal file
5
backup/src/main/AndroidManifest.xml
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
backup/src/main/java/de/mm20/launcher2/backup/BackupManager.kt
Normal file
199
backup/src/main/java/de/mm20/launcher2/backup/BackupManager.kt
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backup/src/main/java/de/mm20/launcher2/backup/Module.kt
Normal file
8
backup/src/main/java/de/mm20/launcher2/backup/Module.kt
Normal file
@ -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()) }
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ buildscript {
|
|||||||
dependencies {
|
dependencies {
|
||||||
classpath("com.android.tools.build:gradle:7.2.0")
|
classpath("com.android.tools.build:gradle:7.2.0")
|
||||||
classpath(libs.kotlin.gradle)
|
classpath(libs.kotlin.gradle)
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21")
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun iconDao(): IconDao
|
abstract fun iconDao(): IconDao
|
||||||
abstract fun widgetDao(): WidgetDao
|
abstract fun widgetDao(): WidgetDao
|
||||||
abstract fun currencyDao(): CurrencyDao
|
abstract fun currencyDao(): CurrencyDao
|
||||||
|
abstract fun backupDao(): BackupRestoreDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private var _instance: AppDatabase? = null
|
private var _instance: AppDatabase? = null
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
package de.mm20.launcher2.database
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import de.mm20.launcher2.database.entities.FavoritesItemEntity
|
||||||
|
import de.mm20.launcher2.database.entities.WebsearchEntity
|
||||||
|
import de.mm20.launcher2.database.entities.WidgetEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BackupRestoreDao {
|
||||||
|
|
||||||
|
@Query("DELETE FROM Searchable")
|
||||||
|
suspend fun wipeFavorites()
|
||||||
|
|
||||||
|
@Query("SELECT * FROM Searchable LIMIT :limit OFFSET :offset")
|
||||||
|
suspend fun exportFavorites(limit: Int, offset: Int): List<FavoritesItemEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun importFavorites(items: List<FavoritesItemEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM Widget")
|
||||||
|
suspend fun wipeWidgets()
|
||||||
|
|
||||||
|
@Query("SELECT * FROM Widget LIMIT :limit OFFSET :offset")
|
||||||
|
suspend fun exportWidgets(limit: Int, offset: Int): List<WidgetEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun importWidgets(items: List<WidgetEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM Websearch")
|
||||||
|
suspend fun wipeWebsearches()
|
||||||
|
|
||||||
|
@Query("SELECT * FROM Websearch LIMIT :limit OFFSET :offset")
|
||||||
|
suspend fun exportWebsearches(limit: Int, offset: Int): List<WebsearchEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun importWebsearches(items: List<WebsearchEntity>)
|
||||||
|
}
|
||||||
@ -54,5 +54,6 @@ dependencies {
|
|||||||
implementation(project(":websites"))
|
implementation(project(":websites"))
|
||||||
implementation(project(":wikipedia"))
|
implementation(project(":wikipedia"))
|
||||||
implementation(project(":badges"))
|
implementation(project(":badges"))
|
||||||
|
implementation(project(":crashreporter"))
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,33 +1,23 @@
|
|||||||
package de.mm20.launcher2.favorites
|
package de.mm20.launcher2.favorites
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import de.mm20.launcher2.appshortcuts.AppShortcutDeserializer
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
import de.mm20.launcher2.appshortcuts.AppShortcutSerializer
|
|
||||||
import de.mm20.launcher2.calendar.CalendarEventDeserializer
|
|
||||||
import de.mm20.launcher2.calendar.CalendarEventSerializer
|
|
||||||
import de.mm20.launcher2.contacts.ContactDeserializer
|
|
||||||
import de.mm20.launcher2.contacts.ContactSerializer
|
|
||||||
import de.mm20.launcher2.database.AppDatabase
|
import de.mm20.launcher2.database.AppDatabase
|
||||||
import de.mm20.launcher2.database.entities.FavoritesItemEntity
|
import de.mm20.launcher2.database.entities.FavoritesItemEntity
|
||||||
import de.mm20.launcher2.files.*
|
|
||||||
import de.mm20.launcher2.ktx.ceilToInt
|
import de.mm20.launcher2.ktx.ceilToInt
|
||||||
import de.mm20.launcher2.search.NullDeserializer
|
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||||
import de.mm20.launcher2.search.NullSerializer
|
|
||||||
import de.mm20.launcher2.search.SearchableDeserializer
|
import de.mm20.launcher2.search.SearchableDeserializer
|
||||||
import de.mm20.launcher2.search.SearchableSerializer
|
import de.mm20.launcher2.search.data.CalendarEvent
|
||||||
import de.mm20.launcher2.search.data.*
|
import de.mm20.launcher2.search.data.Searchable
|
||||||
import de.mm20.launcher2.websites.WebsiteDeserializer
|
|
||||||
import de.mm20.launcher2.websites.WebsiteSerializer
|
|
||||||
import de.mm20.launcher2.wikipedia.WikipediaDeserializer
|
|
||||||
import de.mm20.launcher2.wikipedia.WikipediaSerializer
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import java.io.File
|
||||||
import org.koin.core.parameter.parametersOf
|
|
||||||
|
|
||||||
interface FavoritesRepository {
|
interface FavoritesRepository {
|
||||||
fun getFavorites(
|
fun getFavorites(
|
||||||
@ -47,6 +37,9 @@ interface FavoritesRepository {
|
|||||||
suspend fun getAllFavoriteItems(): List<FavoritesItem>
|
suspend fun getAllFavoriteItems(): List<FavoritesItem>
|
||||||
fun saveFavorites(favorites: List<FavoritesItem>)
|
fun saveFavorites(favorites: List<FavoritesItem>)
|
||||||
fun getHiddenItems(): Flow<List<Searchable>>
|
fun getHiddenItems(): Flow<List<Searchable>>
|
||||||
|
|
||||||
|
suspend fun export(toDir: File)
|
||||||
|
suspend fun import(fromDir: File)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class FavoritesRepositoryImpl(
|
internal class FavoritesRepositoryImpl(
|
||||||
@ -193,7 +186,8 @@ internal class FavoritesRepositoryImpl(
|
|||||||
|
|
||||||
|
|
||||||
private fun fromDatabaseEntity(entity: FavoritesItemEntity): FavoritesItem {
|
private fun fromDatabaseEntity(entity: FavoritesItemEntity): FavoritesItem {
|
||||||
val deserializer: SearchableDeserializer = getDeserializer(context, entity.serializedSearchable)
|
val deserializer: SearchableDeserializer =
|
||||||
|
getDeserializer(context, entity.serializedSearchable)
|
||||||
return FavoritesItem(
|
return FavoritesItem(
|
||||||
key = entity.key,
|
key = entity.key,
|
||||||
searchable = deserializer.deserialize(entity.serializedSearchable.substringAfter("#")),
|
searchable = deserializer.deserialize(entity.serializedSearchable.substringAfter("#")),
|
||||||
@ -202,4 +196,61 @@ internal class FavoritesRepositoryImpl(
|
|||||||
hidden = entity.hidden
|
hidden = entity.hidden
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
|
||||||
|
val dao = database.backupDao()
|
||||||
|
var page = 0
|
||||||
|
do {
|
||||||
|
val favorites = dao.exportFavorites(limit = 100, offset = page * 100)
|
||||||
|
val jsonArray = JSONArray()
|
||||||
|
for (fav in favorites) {
|
||||||
|
jsonArray.put(
|
||||||
|
jsonObjectOf(
|
||||||
|
"key" to fav.key,
|
||||||
|
"hidden" to fav.hidden,
|
||||||
|
"launchCount" to fav.launchCount,
|
||||||
|
"pinPosition" to fav.pinPosition,
|
||||||
|
"searchable" to fav.serializedSearchable
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(toDir, "favorites.${page.toString().padStart(4, '0')}")
|
||||||
|
file.bufferedWriter().use {
|
||||||
|
it.write(jsonArray.toString())
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
} while (favorites.size == 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
|
||||||
|
val dao = database.backupDao()
|
||||||
|
dao.wipeFavorites()
|
||||||
|
|
||||||
|
val files = fromDir.listFiles { _, name -> name.startsWith("favorites.") } ?: return@withContext
|
||||||
|
|
||||||
|
for (file in files) {
|
||||||
|
val favorites = mutableListOf<FavoritesItemEntity>()
|
||||||
|
try {
|
||||||
|
val jsonArray = JSONArray(file.inputStream().reader().readText())
|
||||||
|
|
||||||
|
for (i in 0 until jsonArray.length()) {
|
||||||
|
val json = jsonArray.getJSONObject(i)
|
||||||
|
val entity = FavoritesItemEntity(
|
||||||
|
key = json.getString("key"),
|
||||||
|
serializedSearchable = json.getString("searchable"),
|
||||||
|
launchCount = json.getInt("launchCount"),
|
||||||
|
hidden = json.getBoolean("hidden"),
|
||||||
|
pinPosition = json.getInt("pinPosition")
|
||||||
|
)
|
||||||
|
favorites.add(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.importFavorites(favorites)
|
||||||
|
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
CrashReporter.logException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -514,6 +514,12 @@
|
|||||||
<string name="preference_clockwidget_battery_part_summary">Show the current battery level when the battery is low or charging</string>
|
<string name="preference_clockwidget_battery_part_summary">Show the current battery level when the battery is low or charging</string>
|
||||||
<string name="preference_clockwidget_alarm_part">Alarms</string>
|
<string name="preference_clockwidget_alarm_part">Alarms</string>
|
||||||
<string name="preference_clockwidget_alarm_part_summary">Show alarms that will ring within the next 15 minutes</string>
|
<string name="preference_clockwidget_alarm_part_summary">Show alarms that will ring within the next 15 minutes</string>
|
||||||
|
<string name="preference_screen_backup">Backup & Restore</string>
|
||||||
|
<string name="preference_screen_backup_summary">Export and import launcher data</string>
|
||||||
|
<string name="preference_backup">Backup</string>
|
||||||
|
<string name="preference_backup_summary">Export preferences and launcher data</string>
|
||||||
|
<string name="preference_restore">Restore</string>
|
||||||
|
<string name="preference_restore_summary">Import a previously created backup</string>
|
||||||
<string name="preference_crash_reporter">Crash reporter</string>
|
<string name="preference_crash_reporter">Crash reporter</string>
|
||||||
<string name="preference_crash_reporter_summary">Error and crash reports</string>
|
<string name="preference_crash_reporter_summary">Error and crash reports</string>
|
||||||
<string name="preference_export_log">Export log file</string>
|
<string name="preference_export_log">Export log file</string>
|
||||||
@ -587,4 +593,24 @@
|
|||||||
<item quantity="one">Full in %1$s minute</item>
|
<item quantity="one">Full in %1$s minute</item>
|
||||||
<item quantity="other">Full in %1$s minutes</item>
|
<item quantity="other">Full in %1$s minutes</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="backup_select_components">Select what to backup:</string>
|
||||||
|
<string name="backup_not_included">Not included:</string>
|
||||||
|
<string name="backup_component_favorites">Favorites & hidden apps</string>
|
||||||
|
<string name="backup_component_settings">Settings</string>
|
||||||
|
<string name="backup_component_websearches">Web search shortcuts</string>
|
||||||
|
<string name="backup_component_widgets">Built-in widgets</string>
|
||||||
|
<string name="backup_component_accounts">Connected accounts</string>
|
||||||
|
<string name="backup_component_3rdparty_widgets">3rd party app widgets</string>
|
||||||
|
<string name="backup_complete">The backup has been completed.</string>
|
||||||
|
|
||||||
|
<string name="restore_invalid_file">The selected file does not appear to be a backup. Are you sure you selected the right file?</string>
|
||||||
|
<!-- %1$s: app name -->
|
||||||
|
<string name="restore_incompatible_file">This backup has been created with a different version of %1$s and cannot be restored with this version.</string>
|
||||||
|
<!-- %1$s: app name -->
|
||||||
|
<string name="restore_different_minor_version">This backup has been created with a different version of %1$s. Some data might not be restored correctly.</string>
|
||||||
|
<!-- %1$s: date and time of backup creation, %s$s: name of the device this backup has been created on, %s$s app name and version of creating app-->
|
||||||
|
<string name="restore_meta">Created %1$s on %2$s with %3$s.</string>
|
||||||
|
<string name="restore_select_components">Select what to restore. Existing data will be overwritten!</string>
|
||||||
|
<string name="restore_complete">The backup has been restored.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -2,6 +2,7 @@ package de.mm20.launcher2.preferences
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.datastore.core.DataMigration
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.dataStore
|
import androidx.datastore.dataStore
|
||||||
@ -14,14 +15,7 @@ internal val Context.dataStore: LauncherDataStore by dataStore(
|
|||||||
fileName = "settings.pb",
|
fileName = "settings.pb",
|
||||||
serializer = SettingsSerializer,
|
serializer = SettingsSerializer,
|
||||||
produceMigrations = {
|
produceMigrations = {
|
||||||
listOf(
|
getMigrations(it)
|
||||||
FactorySettingsMigration(it),
|
|
||||||
Migration_1_2(),
|
|
||||||
Migration_2_3(),
|
|
||||||
Migration_3_4(),
|
|
||||||
Migration_4_5(),
|
|
||||||
Migration_5_6(),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
corruptionHandler = ReplaceFileCorruptionHandler {
|
corruptionHandler = ReplaceFileCorruptionHandler {
|
||||||
CrashReporter.logException(it)
|
CrashReporter.logException(it)
|
||||||
@ -31,3 +25,14 @@ internal val Context.dataStore: LauncherDataStore by dataStore(
|
|||||||
)
|
)
|
||||||
|
|
||||||
internal const val SchemaVersion = 6
|
internal const val SchemaVersion = 6
|
||||||
|
|
||||||
|
internal fun getMigrations(context: Context): List<DataMigration<Settings>> {
|
||||||
|
return listOf(
|
||||||
|
FactorySettingsMigration(context),
|
||||||
|
Migration_1_2(),
|
||||||
|
Migration_2_3(),
|
||||||
|
Migration_3_4(),
|
||||||
|
Migration_4_5(),
|
||||||
|
Migration_5_6(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package de.mm20.launcher2.preferences
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStoreFactory
|
||||||
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
suspend fun LauncherDataStore.export(toDir: File) {
|
||||||
|
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
val backupDataStore = DataStoreFactory.create(
|
||||||
|
serializer = SettingsSerializer,
|
||||||
|
produceFile = {
|
||||||
|
File(toDir, "settings")
|
||||||
|
},
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
val settings = this.data.firstOrNull() ?: return
|
||||||
|
backupDataStore.updateData {
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun LauncherDataStore.import(context: Context, fromDir: File) {
|
||||||
|
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
val backupDataStore = DataStoreFactory.create(
|
||||||
|
serializer = SettingsSerializer,
|
||||||
|
migrations = getMigrations(context),
|
||||||
|
corruptionHandler = ReplaceFileCorruptionHandler {
|
||||||
|
CrashReporter.logException(it)
|
||||||
|
Settings.getDefaultInstance()
|
||||||
|
},
|
||||||
|
produceFile = {
|
||||||
|
File(fromDir, "settings")
|
||||||
|
},
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
val settings = backupDataStore.data.firstOrNull() ?: return
|
||||||
|
|
||||||
|
this.updateData {
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
@ -11,6 +11,9 @@ import coil.request.ImageRequest
|
|||||||
import coil.size.Scale
|
import coil.size.Scale
|
||||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
import de.mm20.launcher2.database.AppDatabase
|
import de.mm20.launcher2.database.AppDatabase
|
||||||
|
import de.mm20.launcher2.database.entities.WebsearchEntity
|
||||||
|
import de.mm20.launcher2.database.entities.WidgetEntity
|
||||||
|
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||||
import de.mm20.launcher2.preferences.LauncherDataStore
|
import de.mm20.launcher2.preferences.LauncherDataStore
|
||||||
import de.mm20.launcher2.search.data.Websearch
|
import de.mm20.launcher2.search.data.Websearch
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@ -20,6 +23,8 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
@ -40,6 +45,9 @@ interface WebsearchRepository {
|
|||||||
|
|
||||||
suspend fun importWebsearch(url: String, iconSize: Int): Websearch?
|
suspend fun importWebsearch(url: String, iconSize: Int): Websearch?
|
||||||
suspend fun createIcon(uri: Uri, size: Int): String?
|
suspend fun createIcon(uri: Uri, size: Int): String?
|
||||||
|
|
||||||
|
suspend fun export(toDir: File)
|
||||||
|
suspend fun import(fromDir: File)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class WebsearchRepositoryImpl(
|
internal class WebsearchRepositoryImpl(
|
||||||
@ -208,4 +216,89 @@ internal class WebsearchRepositoryImpl(
|
|||||||
out.close()
|
out.close()
|
||||||
return@withContext file.absolutePath
|
return@withContext file.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
|
||||||
|
val dao = database.backupDao()
|
||||||
|
var page = 0
|
||||||
|
var iconCounter = 0
|
||||||
|
do {
|
||||||
|
val websearches = dao.exportWebsearches(limit = 100, offset = page * 100)
|
||||||
|
val jsonArray = JSONArray()
|
||||||
|
for (websearch in websearches) {
|
||||||
|
var icon = websearch.icon
|
||||||
|
if (icon != null) {
|
||||||
|
val fileName = "asset.websearch.${iconCounter.toString().padStart(4, '0')}"
|
||||||
|
val iconAssetFile = File(toDir, fileName)
|
||||||
|
File(icon).inputStream().use { inStream ->
|
||||||
|
iconAssetFile.outputStream().use { outStream ->
|
||||||
|
inStream.copyTo(outStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
icon = fileName
|
||||||
|
|
||||||
|
iconCounter++
|
||||||
|
}
|
||||||
|
jsonArray.put(
|
||||||
|
jsonObjectOf(
|
||||||
|
"color" to websearch.color,
|
||||||
|
"label" to websearch.label,
|
||||||
|
"template" to websearch.urlTemplate,
|
||||||
|
"icon" to icon,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(toDir, "websearches.${page.toString().padStart(4, '0')}")
|
||||||
|
file.bufferedWriter().use {
|
||||||
|
it.write(jsonArray.toString())
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
} while (websearches.size == 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
|
||||||
|
val dao = database.backupDao()
|
||||||
|
dao.wipeWebsearches()
|
||||||
|
|
||||||
|
val files = fromDir.listFiles { _, name -> name.startsWith("websearches.") } ?: return@withContext
|
||||||
|
|
||||||
|
for (file in files) {
|
||||||
|
val websearches = mutableListOf<WebsearchEntity>()
|
||||||
|
try {
|
||||||
|
val jsonArray = JSONArray(file.inputStream().reader().readText())
|
||||||
|
|
||||||
|
for (i in 0 until jsonArray.length()) {
|
||||||
|
val json = jsonArray.getJSONObject(i)
|
||||||
|
|
||||||
|
val icon = json.optString("icon").takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
var iconFile: File? = null
|
||||||
|
|
||||||
|
if (icon != null) {
|
||||||
|
val asset = File(fromDir, icon)
|
||||||
|
iconFile = File(context.filesDir, icon)
|
||||||
|
asset.inputStream().use { inStream ->
|
||||||
|
iconFile.outputStream().use { outStream ->
|
||||||
|
inStream.copyTo(outStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val entity = WebsearchEntity(
|
||||||
|
urlTemplate = json.getString("template"),
|
||||||
|
color = json.optInt("color", 0),
|
||||||
|
label = json.getString("label"),
|
||||||
|
icon = iconFile?.absolutePath,
|
||||||
|
id = null
|
||||||
|
)
|
||||||
|
websearches.add(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.importWebsearches(websearches)
|
||||||
|
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
CrashReporter.logException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -390,3 +390,4 @@ include(":notifications")
|
|||||||
include(":accounts")
|
include(":accounts")
|
||||||
include(":appshortcuts")
|
include(":appshortcuts")
|
||||||
include(":material-color-utilities")
|
include(":material-color-utilities")
|
||||||
|
include(":backup")
|
||||||
|
|||||||
@ -139,5 +139,5 @@ dependencies {
|
|||||||
implementation(project(":ms-services"))
|
implementation(project(":ms-services"))
|
||||||
implementation(project(":owncloud"))
|
implementation(project(":owncloud"))
|
||||||
implementation(project(":accounts"))
|
implementation(project(":accounts"))
|
||||||
|
implementation(project(":backup"))
|
||||||
}
|
}
|
||||||
@ -0,0 +1,214 @@
|
|||||||
|
package de.mm20.launcher2.ui.common
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import de.mm20.launcher2.backup.BackupCompatibility
|
||||||
|
import de.mm20.launcher2.backup.BackupComponent
|
||||||
|
import de.mm20.launcher2.ui.R
|
||||||
|
import de.mm20.launcher2.ui.component.BottomSheetDialog
|
||||||
|
import de.mm20.launcher2.ui.component.LargeMessage
|
||||||
|
import de.mm20.launcher2.ui.component.SmallMessage
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RestoreBackupSheet(
|
||||||
|
uri: Uri,
|
||||||
|
onDismissRequest: () -> Unit
|
||||||
|
) {
|
||||||
|
val viewModel: RestoreBackupSheetVM = viewModel()
|
||||||
|
|
||||||
|
LaunchedEffect(uri) {
|
||||||
|
viewModel.setInputUri(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
val state by viewModel.state.observeAsState(RestoreBackupState.Parsing)
|
||||||
|
val selectedComponents by viewModel.selectedComponents.observeAsState(emptySet())
|
||||||
|
val compatibility by viewModel.compatibility.observeAsState(null)
|
||||||
|
|
||||||
|
BottomSheetDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.preference_restore),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
|
||||||
|
if (state == RestoreBackupState.Ready && compatibility != BackupCompatibility.Incompatible) {
|
||||||
|
Button(
|
||||||
|
enabled = selectedComponents.isNotEmpty(),
|
||||||
|
onClick = { viewModel.restore() }) {
|
||||||
|
Text(stringResource(R.string.preference_restore))
|
||||||
|
}
|
||||||
|
} else if (state == RestoreBackupState.InvalidFile || state == RestoreBackupState.Restored) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDismissRequest
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
RestoreBackupState.Parsing -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RestoreBackupState.InvalidFile -> {
|
||||||
|
LargeMessage(
|
||||||
|
modifier = Modifier.aspectRatio(1f),
|
||||||
|
icon = Icons.Rounded.ErrorOutline,
|
||||||
|
text = stringResource(id = R.string.restore_invalid_file)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RestoreBackupState.Ready -> {
|
||||||
|
val metadata by viewModel.metadata.observeAsState(null)
|
||||||
|
|
||||||
|
if (metadata != null) {
|
||||||
|
Column {
|
||||||
|
SmallMessage(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
icon = Icons.Rounded.Info,
|
||||||
|
text = stringResource(
|
||||||
|
R.string.restore_meta,
|
||||||
|
DateUtils.formatDateTime(
|
||||||
|
LocalContext.current,
|
||||||
|
metadata!!.timestamp,
|
||||||
|
DateUtils.FORMAT_ABBREV_ALL or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR
|
||||||
|
),
|
||||||
|
metadata!!.deviceName,
|
||||||
|
stringResource(R.string.app_name) + " " + metadata!!.appVersionName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (compatibility == BackupCompatibility.Incompatible) {
|
||||||
|
LargeMessage(
|
||||||
|
modifier = Modifier.aspectRatio(1f),
|
||||||
|
icon = Icons.Rounded.ErrorOutline,
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.restore_incompatible_file,
|
||||||
|
stringResource(R.string.app_name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (compatibility == BackupCompatibility.PartiallyCompatible) {
|
||||||
|
SmallMessage(
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
icon = Icons.Rounded.Warning,
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.restore_different_minor_version,
|
||||||
|
stringResource(R.string.app_name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.restore_select_components),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
val components by viewModel.availableComponents.observeAsState(emptyList())
|
||||||
|
for (component in components) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
viewModel.toggleComponent(
|
||||||
|
component
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (component) {
|
||||||
|
BackupComponent.Favorites -> Icons.Rounded.Star
|
||||||
|
BackupComponent.Settings -> Icons.Rounded.Settings
|
||||||
|
BackupComponent.Websearches -> Icons.Rounded.TravelExplore
|
||||||
|
BackupComponent.Widgets -> Icons.Rounded.Widgets
|
||||||
|
},
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
when (component) {
|
||||||
|
BackupComponent.Favorites -> R.string.backup_component_favorites
|
||||||
|
BackupComponent.Settings -> R.string.backup_component_settings
|
||||||
|
BackupComponent.Websearches -> R.string.backup_component_websearches
|
||||||
|
BackupComponent.Widgets -> R.string.backup_component_widgets
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = selectedComponents.contains(
|
||||||
|
component
|
||||||
|
),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.toggleComponent(component)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RestoreBackupState.Restoring -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RestoreBackupState.Restored -> {
|
||||||
|
LargeMessage(
|
||||||
|
modifier = Modifier.aspectRatio(1f),
|
||||||
|
icon = Icons.Rounded.CheckCircleOutline,
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.restore_complete
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
package de.mm20.launcher2.ui.common
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.mm20.launcher2.backup.BackupCompatibility
|
||||||
|
import de.mm20.launcher2.backup.BackupComponent
|
||||||
|
import de.mm20.launcher2.backup.BackupManager
|
||||||
|
import de.mm20.launcher2.backup.BackupMetadata
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
class RestoreBackupSheetVM : ViewModel(), KoinComponent {
|
||||||
|
|
||||||
|
private val backupManager: BackupManager by inject()
|
||||||
|
|
||||||
|
private var restoreUri: Uri? = null
|
||||||
|
|
||||||
|
val state = MutableLiveData(RestoreBackupState.Parsing)
|
||||||
|
val metadata = MutableLiveData<BackupMetadata?>(null)
|
||||||
|
val compatibility = MutableLiveData<BackupCompatibility?>(null)
|
||||||
|
val selectedComponents = MutableLiveData(setOf<BackupComponent>())
|
||||||
|
|
||||||
|
val availableComponents = MutableLiveData(emptyList<BackupComponent>())
|
||||||
|
|
||||||
|
fun setInputUri(uri: Uri) {
|
||||||
|
restoreUri = uri
|
||||||
|
state.value = RestoreBackupState.Parsing
|
||||||
|
viewModelScope.launch {
|
||||||
|
val metadata = backupManager.readBackupMeta(uri)
|
||||||
|
if (metadata == null) {
|
||||||
|
state.value = RestoreBackupState.InvalidFile
|
||||||
|
availableComponents.value = emptyList()
|
||||||
|
} else {
|
||||||
|
state.value = RestoreBackupState.Ready
|
||||||
|
compatibility.value = backupManager.checkCompatibility(metadata)
|
||||||
|
availableComponents.value = metadata.components.toList().sortedBy { it.ordinal }
|
||||||
|
}
|
||||||
|
selectedComponents.value = metadata?.components ?: emptySet()
|
||||||
|
this@RestoreBackupSheetVM.metadata.value = metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleComponent(component: BackupComponent) {
|
||||||
|
val components = selectedComponents.value ?: emptySet()
|
||||||
|
if (components.contains(component)) {
|
||||||
|
selectedComponents.value = components - component
|
||||||
|
} else {
|
||||||
|
selectedComponents.value = components + component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restore() {
|
||||||
|
val components = selectedComponents.value ?: return
|
||||||
|
val uri = restoreUri ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.value = RestoreBackupState.Restoring
|
||||||
|
backupManager.restore(uri, components)
|
||||||
|
state.value = RestoreBackupState.Restored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class RestoreBackupState {
|
||||||
|
Parsing,
|
||||||
|
InvalidFile,
|
||||||
|
Ready,
|
||||||
|
Restoring,
|
||||||
|
Restored,
|
||||||
|
}
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
package de.mm20.launcher2.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import de.mm20.launcher2.ui.ktx.toDp
|
||||||
|
import de.mm20.launcher2.ui.ktx.toPixels
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BottomSheetDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
confirmButton: @Composable (() -> Unit)? = null,
|
||||||
|
dismissButton: @Composable (() -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
val swipeState = remember {
|
||||||
|
SwipeableState(
|
||||||
|
initialValue = SwipeState.Dismiss,
|
||||||
|
confirmStateChange = {
|
||||||
|
if (it == SwipeState.Dismiss) onDismissRequest()
|
||||||
|
return@SwipeableState true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val nestedScrollConnection = remember {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
|
||||||
|
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||||
|
if (available.y > 0) {
|
||||||
|
return super.onPreScroll(available, source)
|
||||||
|
}
|
||||||
|
val c = swipeState.performDrag(available.y)
|
||||||
|
return Offset(available.x, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource
|
||||||
|
): Offset {
|
||||||
|
if (available.y < 0) {
|
||||||
|
return super.onPreScroll(available, source)
|
||||||
|
}
|
||||||
|
val c = swipeState.performDrag(available.y)
|
||||||
|
return Offset(available.x, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||||
|
if (available.y > 0) {
|
||||||
|
return super.onPreFling(available)
|
||||||
|
}
|
||||||
|
swipeState.performFling(available.y)
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onPostFling(
|
||||||
|
consumed: Velocity,
|
||||||
|
available: Velocity
|
||||||
|
): Velocity {
|
||||||
|
if (available.y < 0) {
|
||||||
|
return super.onPreFling(available)
|
||||||
|
}
|
||||||
|
swipeState.performFling(available.y)
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnClickOutside = true
|
||||||
|
),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
propagateMinConstraints = true,
|
||||||
|
contentAlignment = Alignment.BottomCenter
|
||||||
|
) {
|
||||||
|
val maxHeightPx = maxHeight.toPixels()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight(Alignment.Bottom)
|
||||||
|
.clipToBounds(),
|
||||||
|
verticalArrangement = Arrangement.Bottom
|
||||||
|
) {
|
||||||
|
var height by remember {
|
||||||
|
mutableStateOf(maxHeightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(null) {
|
||||||
|
swipeState.animateTo(SwipeState.Peek)
|
||||||
|
}
|
||||||
|
|
||||||
|
val heightDp = height.toDp()
|
||||||
|
val peekHeight = (height - maxHeightPx / 2).coerceAtLeast(0f)
|
||||||
|
val anchors = mutableMapOf(
|
||||||
|
peekHeight to SwipeState.Peek,
|
||||||
|
height to SwipeState.Dismiss,
|
||||||
|
).also {
|
||||||
|
if (peekHeight > 0f) {
|
||||||
|
it[0f] = SwipeState.Full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.nestedScroll(nestedScrollConnection)
|
||||||
|
.swipeable(
|
||||||
|
swipeState,
|
||||||
|
anchors = anchors,
|
||||||
|
orientation = Orientation.Vertical,
|
||||||
|
thresholds = { _, to ->
|
||||||
|
if (to == SwipeState.Dismiss) {
|
||||||
|
FixedThreshold(heightDp - 48.dp)
|
||||||
|
} else {
|
||||||
|
FractionalThreshold(0.5f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resistance = null
|
||||||
|
)
|
||||||
|
//.animateContentSize()
|
||||||
|
.onSizeChanged {
|
||||||
|
height = it.height.toFloat()
|
||||||
|
}
|
||||||
|
.offset { IntOffset(0, swipeState.offset.value.roundToInt()) }
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f, false),
|
||||||
|
shape = MaterialTheme.shapes.large.copy(
|
||||||
|
bottomStart = CornerSize(0),
|
||||||
|
bottomEnd = CornerSize(0),
|
||||||
|
),
|
||||||
|
shadowElevation = 16.dp,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = title
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
propagateMinConstraints = true,
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val elevation by animateDpAsState(if (swipeState.offset.value == 0f) 0.dp else 1.dp)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentHeight()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
tonalElevation = elevation,
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (confirmButton != null || dismissButton != null) {
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
if (dismissButton != null) {
|
||||||
|
dismissButton()
|
||||||
|
}
|
||||||
|
if (confirmButton != null && dismissButton != null) {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
if (confirmButton != null) {
|
||||||
|
confirmButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class SwipeState {
|
||||||
|
Full, Peek, Dismiss
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package de.mm20.launcher2.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LargeMessage(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: ImageVector,
|
||||||
|
text: String
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
.size(64.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package de.mm20.launcher2.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SmallMessage(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: ImageVector,
|
||||||
|
text: String,
|
||||||
|
color: Color = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = color
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(imageVector = icon, contentDescription = null)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package de.mm20.launcher2.ui.ktx
|
package de.mm20.launcher2.ui.ktx
|
||||||
|
|
||||||
import androidx.compose. animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.AnimationVector
|
import androidx.compose.animation.core.AnimationVector
|
||||||
import androidx.compose.animation.core.TwoWayConverter
|
import androidx.compose.animation.core.TwoWayConverter
|
||||||
import androidx.compose.animation.core.VectorConverter
|
import androidx.compose.animation.core.VectorConverter
|
||||||
|
|||||||
@ -60,6 +60,7 @@ fun HiddenItemsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val swipeState =
|
val swipeState =
|
||||||
rememberSwipeableState(initialValue = SwipeState.Default) {
|
rememberSwipeableState(initialValue = SwipeState.Default) {
|
||||||
if (it == SwipeState.Dismiss) onDismiss()
|
if (it == SwipeState.Dismiss) onDismiss()
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import de.mm20.launcher2.ui.locals.LocalNavController
|
|||||||
import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen
|
import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen
|
||||||
import de.mm20.launcher2.ui.settings.accounts.AccountsSettingsScreen
|
import de.mm20.launcher2.ui.settings.accounts.AccountsSettingsScreen
|
||||||
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
|
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
|
||||||
|
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
|
||||||
import de.mm20.launcher2.ui.settings.badges.BadgeSettingsScreen
|
import de.mm20.launcher2.ui.settings.badges.BadgeSettingsScreen
|
||||||
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
|
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
|
||||||
import de.mm20.launcher2.ui.settings.calendarwidget.CalendarWidgetSettingsScreen
|
import de.mm20.launcher2.ui.settings.calendarwidget.CalendarWidgetSettingsScreen
|
||||||
@ -149,6 +150,9 @@ class SettingsActivity : BaseActivity() {
|
|||||||
composable("settings/debug") {
|
composable("settings/debug") {
|
||||||
DebugSettingsScreen()
|
DebugSettingsScreen()
|
||||||
}
|
}
|
||||||
|
composable("settings/backup") {
|
||||||
|
BackupSettingsScreen()
|
||||||
|
}
|
||||||
composable("settings/debug/crashreporter") {
|
composable("settings/debug/crashreporter") {
|
||||||
CrashReporterScreen()
|
CrashReporterScreen()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
package de.mm20.launcher2.ui.settings.backup
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import de.mm20.launcher2.ui.R
|
||||||
|
import de.mm20.launcher2.ui.common.RestoreBackupSheet
|
||||||
|
import de.mm20.launcher2.ui.component.preferences.Preference
|
||||||
|
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
|
||||||
|
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackupSettingsScreen() {
|
||||||
|
val viewModel: BackupSettingsScreenVM = viewModel()
|
||||||
|
|
||||||
|
val restoreUri by viewModel.restoreUri.observeAsState()
|
||||||
|
|
||||||
|
val showBackupSheet by viewModel.showBackupSheet.observeAsState(false)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val restoreLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
onResult = {
|
||||||
|
viewModel.setRestoreUri(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PreferenceScreen(stringResource(R.string.preference_screen_backup)) {
|
||||||
|
item {
|
||||||
|
PreferenceCategory {
|
||||||
|
Preference(
|
||||||
|
title = stringResource(id = R.string.preference_backup),
|
||||||
|
summary = stringResource(id = R.string.preference_backup_summary),
|
||||||
|
onClick = {
|
||||||
|
viewModel.setShowBackupSheet(true)
|
||||||
|
})
|
||||||
|
Preference(
|
||||||
|
title = stringResource(id = R.string.preference_restore),
|
||||||
|
summary = stringResource(id = R.string.preference_restore_summary),
|
||||||
|
onClick = {
|
||||||
|
restoreLauncher.launch(arrayOf("*/*"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = restoreUri
|
||||||
|
|
||||||
|
if (uri != null) {
|
||||||
|
RestoreBackupSheet(uri = uri, onDismissRequest = { viewModel.setRestoreUri(null) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if(showBackupSheet) {
|
||||||
|
CreateBackupSheet(onDismissRequest = {
|
||||||
|
viewModel.setShowBackupSheet(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package de.mm20.launcher2.ui.settings.backup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.mm20.launcher2.backup.BackupManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class BackupSettingsScreenVM : ViewModel(), KoinComponent {
|
||||||
|
|
||||||
|
val showBackupSheet = MutableLiveData(false)
|
||||||
|
|
||||||
|
val restoreUri = MutableLiveData<Uri?>(null)
|
||||||
|
|
||||||
|
fun setShowBackupSheet(show: Boolean) {
|
||||||
|
showBackupSheet.value = show
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRestoreUri(uri: Uri?) {
|
||||||
|
restoreUri.value = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,184 @@
|
|||||||
|
package de.mm20.launcher2.ui.settings.backup
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import de.mm20.launcher2.backup.BackupComponent
|
||||||
|
import de.mm20.launcher2.ui.R
|
||||||
|
import de.mm20.launcher2.ui.component.BottomSheetDialog
|
||||||
|
import de.mm20.launcher2.ui.component.LargeMessage
|
||||||
|
import de.mm20.launcher2.ui.component.SmallMessage
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CreateBackupSheet(
|
||||||
|
onDismissRequest: () -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
val viewModel: CreateBackupSheetVM = viewModel()
|
||||||
|
|
||||||
|
LaunchedEffect(null) {
|
||||||
|
viewModel.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
val components by viewModel.selectedComponents.observeAsState(emptySet())
|
||||||
|
val state by viewModel.state.observeAsState(CreateBackupState.Ready)
|
||||||
|
|
||||||
|
|
||||||
|
val backupLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/vendor.de.mm20.launcher2.backup"),
|
||||||
|
onResult = {
|
||||||
|
if (it != null) viewModel.createBackup(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
BottomSheetDialog(onDismissRequest = onDismissRequest,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.preference_backup),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (state == CreateBackupState.Ready) {
|
||||||
|
Button(
|
||||||
|
enabled = components.isNotEmpty(),
|
||||||
|
onClick = {
|
||||||
|
val fileName = "${
|
||||||
|
ZonedDateTime.now().format(
|
||||||
|
DateTimeFormatter.ISO_INSTANT
|
||||||
|
)
|
||||||
|
}.kvaesitso"
|
||||||
|
backupLauncher.launch(fileName)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.preference_backup))
|
||||||
|
}
|
||||||
|
} else if (state == CreateBackupState.BackedUp) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDismissRequest
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
CreateBackupState.Ready -> {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.backup_select_components),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
BackupableComponent(
|
||||||
|
title = stringResource(R.string.backup_component_settings),
|
||||||
|
icon = Icons.Rounded.Settings,
|
||||||
|
checked = components.contains(BackupComponent.Settings),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.toggleComponent(BackupComponent.Settings)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
BackupableComponent(
|
||||||
|
title = stringResource(R.string.backup_component_favorites),
|
||||||
|
icon = Icons.Rounded.Star,
|
||||||
|
checked = components.contains(BackupComponent.Favorites),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.toggleComponent(BackupComponent.Favorites)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
BackupableComponent(
|
||||||
|
title = stringResource(R.string.backup_component_widgets),
|
||||||
|
icon = Icons.Rounded.Widgets,
|
||||||
|
checked = components.contains(BackupComponent.Widgets),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.toggleComponent(BackupComponent.Widgets)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
BackupableComponent(
|
||||||
|
title = stringResource(R.string.backup_component_websearches),
|
||||||
|
icon = Icons.Rounded.TravelExplore,
|
||||||
|
checked = components.contains(BackupComponent.Websearches),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.toggleComponent(BackupComponent.Websearches)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SmallMessage(
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
icon = Icons.Rounded.Warning,
|
||||||
|
text = "Connected accounts and 3rd party app widgets will not be backed up."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CreateBackupState.BackingUp -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CreateBackupState.BackedUp -> {
|
||||||
|
LargeMessage(
|
||||||
|
modifier = Modifier.aspectRatio(1f),
|
||||||
|
icon = Icons.Rounded.CheckCircleOutline,
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.backup_complete
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BackupableComponent(
|
||||||
|
title: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
onCheckedChange(!checked)
|
||||||
|
}
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package de.mm20.launcher2.ui.settings.backup
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.mm20.launcher2.backup.BackupComponent
|
||||||
|
import de.mm20.launcher2.backup.BackupManager
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
class CreateBackupSheetVM : ViewModel(), KoinComponent {
|
||||||
|
|
||||||
|
private val backupManager: BackupManager by inject()
|
||||||
|
|
||||||
|
val state = MutableLiveData(CreateBackupState.Ready)
|
||||||
|
|
||||||
|
val selectedComponents = MutableLiveData(BackupComponent.values().toSet())
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
state.value = CreateBackupState.Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createBackup(uri: Uri) {
|
||||||
|
val components = selectedComponents.value ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.value = CreateBackupState.BackingUp
|
||||||
|
backupManager.backup(uri, components)
|
||||||
|
state.value = CreateBackupState.BackedUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleComponent(component: BackupComponent) {
|
||||||
|
val components = selectedComponents.value ?: emptySet()
|
||||||
|
if (components.contains(component)) {
|
||||||
|
selectedComponents.value = components - component
|
||||||
|
} else {
|
||||||
|
selectedComponents.value = components + component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CreateBackupState {
|
||||||
|
Ready,
|
||||||
|
BackingUp,
|
||||||
|
BackedUp,
|
||||||
|
}
|
||||||
@ -62,6 +62,14 @@ fun MainSettingsScreen() {
|
|||||||
navController?.navigate("settings/accounts")
|
navController?.navigate("settings/accounts")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
Preference(
|
||||||
|
icon = Icons.Rounded.SettingsBackupRestore,
|
||||||
|
title = stringResource(id = R.string.preference_screen_backup),
|
||||||
|
summary = stringResource(id = R.string.preference_screen_backup_summary),
|
||||||
|
onClick = {
|
||||||
|
navController?.navigate("settings/backup")
|
||||||
|
}
|
||||||
|
)
|
||||||
Preference(
|
Preference(
|
||||||
icon = Icons.Rounded.BugReport,
|
icon = Icons.Rounded.BugReport,
|
||||||
title = stringResource(id = R.string.preference_screen_debug),
|
title = stringResource(id = R.string.preference_screen_debug),
|
||||||
|
|||||||
@ -52,5 +52,6 @@ dependencies {
|
|||||||
implementation(project(":base"))
|
implementation(project(":base"))
|
||||||
implementation(project(":preferences"))
|
implementation(project(":preferences"))
|
||||||
implementation(project(":database"))
|
implementation(project(":database"))
|
||||||
|
implementation(project(":crashreporter"))
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,10 +1,16 @@
|
|||||||
package de.mm20.launcher2.widgets
|
package de.mm20.launcher2.widgets
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
import de.mm20.launcher2.database.AppDatabase
|
import de.mm20.launcher2.database.AppDatabase
|
||||||
|
import de.mm20.launcher2.database.entities.WidgetEntity
|
||||||
|
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
interface WidgetRepository {
|
interface WidgetRepository {
|
||||||
fun getWidgets(): Flow<List<Widget>>
|
fun getWidgets(): Flow<List<Widget>>
|
||||||
@ -17,6 +23,9 @@ interface WidgetRepository {
|
|||||||
fun isMusicWidgetEnabled(): Flow<Boolean>
|
fun isMusicWidgetEnabled(): Flow<Boolean>
|
||||||
fun isCalendarWidgetEnabled(): Flow<Boolean>
|
fun isCalendarWidgetEnabled(): Flow<Boolean>
|
||||||
fun isFavoritesWidgetEnabled(): Flow<Boolean>
|
fun isFavoritesWidgetEnabled(): Flow<Boolean>
|
||||||
|
|
||||||
|
suspend fun export(toDir: File)
|
||||||
|
suspend fun import(fromDir: File)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class WidgetRepositoryImpl(
|
internal class WidgetRepositoryImpl(
|
||||||
@ -96,4 +105,57 @@ internal class WidgetRepositoryImpl(
|
|||||||
return database.widgetDao().exists("internal", "favorites")
|
return database.widgetDao().exists("internal", "favorites")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
|
||||||
|
val dao = database.backupDao()
|
||||||
|
var page = 0
|
||||||
|
do {
|
||||||
|
val widgets = dao.exportWidgets(limit = 100, offset = page * 100)
|
||||||
|
val jsonArray = JSONArray()
|
||||||
|
for (widget in widgets) {
|
||||||
|
if (widget.type != WidgetType.INTERNAL.value) continue
|
||||||
|
jsonArray.put(
|
||||||
|
jsonObjectOf(
|
||||||
|
"data" to widget.data,
|
||||||
|
"position" to widget.position,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(toDir, "widgets.${page.toString().padStart(4, '0')}")
|
||||||
|
file.bufferedWriter().use {
|
||||||
|
it.write(jsonArray.toString())
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
} while (widgets.size == 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
|
||||||
|
val dao = database.backupDao()
|
||||||
|
dao.wipeWidgets()
|
||||||
|
|
||||||
|
val files = fromDir.listFiles { _, name -> name.startsWith("widgets.") } ?: return@withContext
|
||||||
|
|
||||||
|
for (file in files) {
|
||||||
|
val widgets = mutableListOf<WidgetEntity>()
|
||||||
|
try {
|
||||||
|
val jsonArray = JSONArray(file.inputStream().reader().readText())
|
||||||
|
|
||||||
|
for (i in 0 until jsonArray.length()) {
|
||||||
|
val json = jsonArray.getJSONObject(i)
|
||||||
|
val entity = WidgetEntity(
|
||||||
|
type = WidgetType.INTERNAL.value,
|
||||||
|
position = json.getInt("position"),
|
||||||
|
data = json.getString("data"),
|
||||||
|
height = -1,
|
||||||
|
)
|
||||||
|
widgets.add(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.importWidgets(widgets)
|
||||||
|
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
CrashReporter.logException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user