diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fabf9483..68f7fab0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) diff --git a/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt b/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt index d79f6db0..160b8d57 100644 --- a/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt +++ b/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt @@ -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, diff --git a/backup/.gitignore b/backup/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/backup/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/backup/build.gradle.kts b/backup/build.gradle.kts new file mode 100644 index 00000000..ec9437f0 --- /dev/null +++ b/backup/build.gradle.kts @@ -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")) + +} \ No newline at end of file diff --git a/backup/consumer-rules.pro b/backup/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/backup/proguard-rules.pro b/backup/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/backup/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/backup/src/main/AndroidManifest.xml b/backup/src/main/AndroidManifest.xml new file mode 100644 index 00000000..52f7c94c --- /dev/null +++ b/backup/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/backup/src/main/java/de/mm20/launcher2/backup/BackupComponent.kt b/backup/src/main/java/de/mm20/launcher2/backup/BackupComponent.kt new file mode 100644 index 00000000..75ebda90 --- /dev/null +++ b/backup/src/main/java/de/mm20/launcher2/backup/BackupComponent.kt @@ -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 } + } + } +} \ No newline at end of file diff --git a/backup/src/main/java/de/mm20/launcher2/backup/BackupManager.kt b/backup/src/main/java/de/mm20/launcher2/backup/BackupManager.kt new file mode 100644 index 00000000..bf6f21a9 --- /dev/null +++ b/backup/src/main/java/de/mm20/launcher2/backup/BackupManager.kt @@ -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.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.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 +} \ No newline at end of file diff --git a/backup/src/main/java/de/mm20/launcher2/backup/BackupMetadata.kt b/backup/src/main/java/de/mm20/launcher2/backup/BackupMetadata.kt new file mode 100644 index 00000000..6e4a795f --- /dev/null +++ b/backup/src/main/java/de/mm20/launcher2/backup/BackupMetadata.kt @@ -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, +) { + + 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() + 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 + } + } + } + } +} \ No newline at end of file diff --git a/backup/src/main/java/de/mm20/launcher2/backup/Module.kt b/backup/src/main/java/de/mm20/launcher2/backup/Module.kt new file mode 100644 index 00000000..bf98fbda --- /dev/null +++ b/backup/src/main/java/de/mm20/launcher2/backup/Module.kt @@ -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(androidContext(), get(), get(), get(), get()) } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index d5450428..cc4818e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } diff --git a/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt b/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt index 143cade4..0293a02b 100644 --- a/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt +++ b/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt @@ -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 diff --git a/database/src/main/java/de/mm20/launcher2/database/BackupRestoreDao.kt b/database/src/main/java/de/mm20/launcher2/database/BackupRestoreDao.kt new file mode 100644 index 00000000..a4fa6481 --- /dev/null +++ b/database/src/main/java/de/mm20/launcher2/database/BackupRestoreDao.kt @@ -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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun importFavorites(items: List) + + @Query("DELETE FROM Widget") + suspend fun wipeWidgets() + + @Query("SELECT * FROM Widget LIMIT :limit OFFSET :offset") + suspend fun exportWidgets(limit: Int, offset: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun importWidgets(items: List) + + @Query("DELETE FROM Websearch") + suspend fun wipeWebsearches() + + @Query("SELECT * FROM Websearch LIMIT :limit OFFSET :offset") + suspend fun exportWebsearches(limit: Int, offset: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun importWebsearches(items: List) +} \ No newline at end of file diff --git a/favorites/build.gradle.kts b/favorites/build.gradle.kts index ac9a8103..efd42e99 100644 --- a/favorites/build.gradle.kts +++ b/favorites/build.gradle.kts @@ -54,5 +54,6 @@ dependencies { implementation(project(":websites")) implementation(project(":wikipedia")) implementation(project(":badges")) + implementation(project(":crashreporter")) } \ No newline at end of file diff --git a/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt b/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt index 735525d3..777c6e3a 100644 --- a/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt +++ b/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt @@ -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 fun saveFavorites(favorites: List) fun getHiddenItems(): Flow> + + 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() + 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) + } + } + } } \ No newline at end of file diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index edf20501..1674f1de 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -514,6 +514,12 @@ Show the current battery level when the battery is low or charging Alarms Show alarms that will ring within the next 15 minutes + Backup & Restore + Export and import launcher data + Backup + Export preferences and launcher data + Restore + Import a previously created backup Crash reporter Error and crash reports Export log file @@ -587,4 +593,24 @@ Full in %1$s minute Full in %1$s minutes + + Select what to backup: + Not included: + Favorites & hidden apps + Settings + Web search shortcuts + Built-in widgets + Connected accounts + 3rd party app widgets + The backup has been completed. + + The selected file does not appear to be a backup. Are you sure you selected the right file? + + This backup has been created with a different version of %1$s and cannot be restored with this version. + + This backup has been created with a different version of %1$s. Some data might not be restored correctly. + + Created %1$s on %2$s with %3$s. + Select what to restore. Existing data will be overwritten! + The backup has been restored. \ No newline at end of file diff --git a/preferences/src/main/java/de/mm20/launcher2/preferences/DataStore.kt b/preferences/src/main/java/de/mm20/launcher2/preferences/DataStore.kt index ec65bbb6..58fa6148 100644 --- a/preferences/src/main/java/de/mm20/launcher2/preferences/DataStore.kt +++ b/preferences/src/main/java/de/mm20/launcher2/preferences/DataStore.kt @@ -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 \ No newline at end of file +internal const val SchemaVersion = 6 + +internal fun getMigrations(context: Context): List> { + return listOf( + FactorySettingsMigration(context), + Migration_1_2(), + Migration_2_3(), + Migration_3_4(), + Migration_4_5(), + Migration_5_6(), + ) +} \ No newline at end of file diff --git a/preferences/src/main/java/de/mm20/launcher2/preferences/ImportExport.kt b/preferences/src/main/java/de/mm20/launcher2/preferences/ImportExport.kt new file mode 100644 index 00000000..80b17b17 --- /dev/null +++ b/preferences/src/main/java/de/mm20/launcher2/preferences/ImportExport.kt @@ -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() +} \ No newline at end of file diff --git a/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt b/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt index 097b70a5..8b716411 100644 --- a/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt +++ b/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt @@ -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() + 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) + } + } + } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 56613cc5..45531829 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -390,3 +390,4 @@ include(":notifications") include(":accounts") include(":appshortcuts") include(":material-color-utilities") +include(":backup") diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 8562cdb6..90fc0018 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -139,5 +139,5 @@ dependencies { implementation(project(":ms-services")) implementation(project(":owncloud")) implementation(project(":accounts")) - + implementation(project(":backup")) } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/common/RestoreBackupSheet.kt b/ui/src/main/java/de/mm20/launcher2/ui/common/RestoreBackupSheet.kt new file mode 100644 index 00000000..cb921822 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/common/RestoreBackupSheet.kt @@ -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 + ) + ) + } + } + } +} diff --git a/ui/src/main/java/de/mm20/launcher2/ui/common/RestoreBackupSheetVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/common/RestoreBackupSheetVM.kt new file mode 100644 index 00000000..a029904c --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/common/RestoreBackupSheetVM.kt @@ -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(null) + val compatibility = MutableLiveData(null) + val selectedComponents = MutableLiveData(setOf()) + + val availableComponents = MutableLiveData(emptyList()) + + 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, +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt new file mode 100644 index 00000000..3b20b838 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt @@ -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 +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/LargeMessage.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/LargeMessage.kt new file mode 100644 index 00000000..a7de8300 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/LargeMessage.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/SmallMessage.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/SmallMessage.kt new file mode 100644 index 00000000..2466e8c5 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/SmallMessage.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/ktx/MutableState.kt b/ui/src/main/java/de/mm20/launcher2/ui/ktx/MutableState.kt index 93ee2c8d..6bf2af18 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/ktx/MutableState.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/ktx/MutableState.kt @@ -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 diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsSheet.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsSheet.kt index 6fb56e2e..9fcefa85 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsSheet.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsSheet.kt @@ -60,6 +60,7 @@ fun HiddenItemsSheet( } } + val swipeState = rememberSwipeableState(initialValue = SwipeState.Default) { if (it == SwipeState.Dismiss) onDismiss() diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index 61349d03..996dbf09 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -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() } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/BackupSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/BackupSettingsScreen.kt new file mode 100644 index 00000000..616d58f5 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/BackupSettingsScreen.kt @@ -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) + }) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/BackupSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/BackupSettingsScreenVM.kt new file mode 100644 index 00000000..c3d46141 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/BackupSettingsScreenVM.kt @@ -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(null) + + fun setShowBackupSheet(show: Boolean) { + showBackupSheet.value = show + } + + fun setRestoreUri(uri: Uri?) { + restoreUri.value = uri + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/CreateBackupSheet.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/CreateBackupSheet.kt new file mode 100644 index 00000000..f0a5bddd --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/CreateBackupSheet.kt @@ -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 + ) + } +} + diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/CreateBackupSheetVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/CreateBackupSheetVM.kt new file mode 100644 index 00000000..ae1b0cf0 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/backup/CreateBackupSheetVM.kt @@ -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, +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt index 255e0cb6..5bae7e0a 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt @@ -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), diff --git a/widgets/build.gradle.kts b/widgets/build.gradle.kts index e95dfc88..8c589290 100644 --- a/widgets/build.gradle.kts +++ b/widgets/build.gradle.kts @@ -52,5 +52,6 @@ dependencies { implementation(project(":base")) implementation(project(":preferences")) implementation(project(":database")) + implementation(project(":crashreporter")) } \ No newline at end of file diff --git a/widgets/src/main/java/de/mm20/launcher2/widgets/WidgetRepository.kt b/widgets/src/main/java/de/mm20/launcher2/widgets/WidgetRepository.kt index d108fe8d..0b3b69c7 100644 --- a/widgets/src/main/java/de/mm20/launcher2/widgets/WidgetRepository.kt +++ b/widgets/src/main/java/de/mm20/launcher2/widgets/WidgetRepository.kt @@ -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> @@ -17,6 +23,9 @@ interface WidgetRepository { fun isMusicWidgetEnabled(): Flow fun isCalendarWidgetEnabled(): Flow fun isFavoritesWidgetEnabled(): Flow + + 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() + 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) + } + } + } } \ No newline at end of file