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