Backup & restore
This commit is contained in:
parent
c7fb8bf57a
commit
eaf4701500
@ -109,6 +109,7 @@ dependencies {
|
||||
implementation(project(":accounts"))
|
||||
implementation(project(":applications"))
|
||||
implementation(project(":appshortcuts"))
|
||||
implementation(project(":backup"))
|
||||
implementation(project(":badges"))
|
||||
implementation(project(":base"))
|
||||
implementation(project(":calculator"))
|
||||
|
||||
@ -7,6 +7,7 @@ import coil.decode.SvgDecoder
|
||||
import de.mm20.launcher2.accounts.accountsModule
|
||||
import de.mm20.launcher2.applications.applicationsModule
|
||||
import de.mm20.launcher2.appshortcuts.appShortcutsModule
|
||||
import de.mm20.launcher2.backup.backupModule
|
||||
import de.mm20.launcher2.badges.badgesModule
|
||||
import de.mm20.launcher2.calculator.calculatorModule
|
||||
import de.mm20.launcher2.calendar.calendarModule
|
||||
@ -53,6 +54,7 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
|
||||
applicationsModule,
|
||||
appShortcutsModule,
|
||||
calculatorModule,
|
||||
backupModule,
|
||||
badgesModule,
|
||||
calendarModule,
|
||||
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 {
|
||||
classpath("com.android.tools.build:gradle:7.2.0")
|
||||
classpath(libs.kotlin.gradle)
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21")
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun iconDao(): IconDao
|
||||
abstract fun widgetDao(): WidgetDao
|
||||
abstract fun currencyDao(): CurrencyDao
|
||||
abstract fun backupDao(): BackupRestoreDao
|
||||
|
||||
companion object {
|
||||
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(":wikipedia"))
|
||||
implementation(project(":badges"))
|
||||
implementation(project(":crashreporter"))
|
||||
|
||||
}
|
||||
@ -1,33 +1,23 @@
|
||||
package de.mm20.launcher2.favorites
|
||||
|
||||
import android.content.Context
|
||||
import de.mm20.launcher2.appshortcuts.AppShortcutDeserializer
|
||||
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.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.database.AppDatabase
|
||||
import de.mm20.launcher2.database.entities.FavoritesItemEntity
|
||||
import de.mm20.launcher2.files.*
|
||||
import de.mm20.launcher2.ktx.ceilToInt
|
||||
import de.mm20.launcher2.search.NullDeserializer
|
||||
import de.mm20.launcher2.search.NullSerializer
|
||||
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.data.*
|
||||
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 de.mm20.launcher2.search.data.CalendarEvent
|
||||
import de.mm20.launcher2.search.data.Searchable
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import java.io.File
|
||||
|
||||
interface FavoritesRepository {
|
||||
fun getFavorites(
|
||||
@ -47,6 +37,9 @@ interface FavoritesRepository {
|
||||
suspend fun getAllFavoriteItems(): List<FavoritesItem>
|
||||
fun saveFavorites(favorites: List<FavoritesItem>)
|
||||
fun getHiddenItems(): Flow<List<Searchable>>
|
||||
|
||||
suspend fun export(toDir: File)
|
||||
suspend fun import(fromDir: File)
|
||||
}
|
||||
|
||||
internal class FavoritesRepositoryImpl(
|
||||
@ -193,7 +186,8 @@ internal class FavoritesRepositoryImpl(
|
||||
|
||||
|
||||
private fun fromDatabaseEntity(entity: FavoritesItemEntity): FavoritesItem {
|
||||
val deserializer: SearchableDeserializer = getDeserializer(context, entity.serializedSearchable)
|
||||
val deserializer: SearchableDeserializer =
|
||||
getDeserializer(context, entity.serializedSearchable)
|
||||
return FavoritesItem(
|
||||
key = entity.key,
|
||||
searchable = deserializer.deserialize(entity.serializedSearchable.substringAfter("#")),
|
||||
@ -202,4 +196,61 @@ internal class FavoritesRepositoryImpl(
|
||||
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_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_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_summary">Error and crash reports</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="other">Full in %1$s minutes</item>
|
||||
</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>
|
||||
@ -2,6 +2,7 @@ package de.mm20.launcher2.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataMigration
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStore
|
||||
@ -14,14 +15,7 @@ internal val Context.dataStore: LauncherDataStore by dataStore(
|
||||
fileName = "settings.pb",
|
||||
serializer = SettingsSerializer,
|
||||
produceMigrations = {
|
||||
listOf(
|
||||
FactorySettingsMigration(it),
|
||||
Migration_1_2(),
|
||||
Migration_2_3(),
|
||||
Migration_3_4(),
|
||||
Migration_4_5(),
|
||||
Migration_5_6(),
|
||||
)
|
||||
getMigrations(it)
|
||||
},
|
||||
corruptionHandler = ReplaceFileCorruptionHandler {
|
||||
CrashReporter.logException(it)
|
||||
@ -30,4 +24,15 @@ 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 de.mm20.launcher2.crashreporter.CrashReporter
|
||||
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.search.data.Websearch
|
||||
import kotlinx.coroutines.*
|
||||
@ -20,6 +23,8 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.jsoup.Jsoup
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
@ -40,6 +45,9 @@ interface WebsearchRepository {
|
||||
|
||||
suspend fun importWebsearch(url: String, iconSize: Int): Websearch?
|
||||
suspend fun createIcon(uri: Uri, size: Int): String?
|
||||
|
||||
suspend fun export(toDir: File)
|
||||
suspend fun import(fromDir: File)
|
||||
}
|
||||
|
||||
internal class WebsearchRepositoryImpl(
|
||||
@ -208,4 +216,89 @@ internal class WebsearchRepositoryImpl(
|
||||
out.close()
|
||||
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(":appshortcuts")
|
||||
include(":material-color-utilities")
|
||||
include(":backup")
|
||||
|
||||
@ -139,5 +139,5 @@ dependencies {
|
||||
implementation(project(":ms-services"))
|
||||
implementation(project(":owncloud"))
|
||||
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
|
||||
|
||||
import androidx.compose. animation.core.Animatable
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector
|
||||
import androidx.compose.animation.core.TwoWayConverter
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
|
||||
@ -60,6 +60,7 @@ fun HiddenItemsSheet(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val swipeState =
|
||||
rememberSwipeableState(initialValue = SwipeState.Default) {
|
||||
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.accounts.AccountsSettingsScreen
|
||||
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.buildinfo.BuildInfoSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.calendarwidget.CalendarWidgetSettingsScreen
|
||||
@ -149,6 +150,9 @@ class SettingsActivity : BaseActivity() {
|
||||
composable("settings/debug") {
|
||||
DebugSettingsScreen()
|
||||
}
|
||||
composable("settings/backup") {
|
||||
BackupSettingsScreen()
|
||||
}
|
||||
composable("settings/debug/crashreporter") {
|
||||
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")
|
||||
}
|
||||
)
|
||||
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(
|
||||
icon = Icons.Rounded.BugReport,
|
||||
title = stringResource(id = R.string.preference_screen_debug),
|
||||
|
||||
@ -52,5 +52,6 @@ dependencies {
|
||||
implementation(project(":base"))
|
||||
implementation(project(":preferences"))
|
||||
implementation(project(":database"))
|
||||
implementation(project(":crashreporter"))
|
||||
|
||||
}
|
||||
@ -1,10 +1,16 @@
|
||||
package de.mm20.launcher2.widgets
|
||||
|
||||
import android.content.Context
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import java.io.File
|
||||
|
||||
interface WidgetRepository {
|
||||
fun getWidgets(): Flow<List<Widget>>
|
||||
@ -17,6 +23,9 @@ interface WidgetRepository {
|
||||
fun isMusicWidgetEnabled(): Flow<Boolean>
|
||||
fun isCalendarWidgetEnabled(): Flow<Boolean>
|
||||
fun isFavoritesWidgetEnabled(): Flow<Boolean>
|
||||
|
||||
suspend fun export(toDir: File)
|
||||
suspend fun import(fromDir: File)
|
||||
}
|
||||
|
||||
internal class WidgetRepositoryImpl(
|
||||
@ -96,4 +105,57 @@ internal class WidgetRepositoryImpl(
|
||||
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