Backup & restore

This commit is contained in:
MM20 2022-06-09 19:26:45 +02:00
parent c7fb8bf57a
commit eaf4701500
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
37 changed files with 1660 additions and 29 deletions

View File

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

View File

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

@ -0,0 +1 @@
/build

49
backup/build.gradle.kts Normal file
View 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"))
}

View File

21
backup/proguard-rules.pro vendored Normal file
View 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

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="de.mm20.launcher2.backup">
</manifest>

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -54,5 +54,6 @@ dependencies {
implementation(project(":websites"))
implementation(project(":wikipedia"))
implementation(project(":badges"))
implementation(project(":crashreporter"))
}

View File

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

View File

@ -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 &amp; 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 &amp; 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>

View File

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

View File

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

View File

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

View File

@ -390,3 +390,4 @@ include(":notifications")
include(":accounts")
include(":appshortcuts")
include(":material-color-utilities")
include(":backup")

View File

@ -139,5 +139,5 @@ dependencies {
implementation(project(":ms-services"))
implementation(project(":owncloud"))
implementation(project(":accounts"))
implementation(project(":backup"))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,6 +60,7 @@ fun HiddenItemsSheet(
}
}
val swipeState =
rememberSwipeableState(initialValue = SwipeState.Default) {
if (it == SwipeState.Dismiss) onDismiss()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,5 +52,6 @@ dependencies {
implementation(project(":base"))
implementation(project(":preferences"))
implementation(project(":database"))
implementation(project(":crashreporter"))
}

View File

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