diff --git a/lawnchair/protos/lawnchair.proto b/lawnchair/protos/lawnchair.proto index a3afd6829a..2d482dabb6 100644 --- a/lawnchair/protos/lawnchair.proto +++ b/lawnchair/protos/lawnchair.proto @@ -11,7 +11,9 @@ message BackupInfo { google.protobuf.Timestamp created_at = 3; int32 contents = 4; - GridState grid_state = 5; + int32 preview_width = 5; + int32 preview_height = 6; + GridState grid_state = 7; } message GridState { diff --git a/lawnchair/res/values/strings.xml b/lawnchair/res/values/strings.xml index 02d012d347..9f778bb6bc 100644 --- a/lawnchair/res/values/strings.xml +++ b/lawnchair/res/values/strings.xml @@ -266,4 +266,10 @@ Wallpaper Backup created Failed to create backup + Restore backup + Restore + What to Restore + Backup restored + Failed to restore backup + Invalid backup file diff --git a/lawnchair/src/app/lawnchair/LawnchairApp.kt b/lawnchair/src/app/lawnchair/LawnchairApp.kt index 6a39342d4b..4f67a0a4b5 100644 --- a/lawnchair/src/app/lawnchair/LawnchairApp.kt +++ b/lawnchair/src/app/lawnchair/LawnchairApp.kt @@ -35,6 +35,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider +import androidx.core.content.edit +import app.lawnchair.backup.LawnchairBackup import app.lawnchair.preferences.PreferenceManager import app.lawnchair.ui.AlertBottomSheetContent import app.lawnchair.ui.preferences.openAppInfo @@ -74,6 +76,13 @@ class LawnchairApp : Application() { } } + fun renameRestoredDb(dbName: String) { + val restoredDbFile = getDatabasePath(LawnchairBackup.RESTORED_DB_FILE_NAME) + if (!restoredDbFile.exists()) return + val dbFile = getDatabasePath(dbName) + restoredDbFile.renameTo(dbFile) + } + fun migrateDbName(dbName: String) { val dbFile = getDatabasePath(dbName) if (dbFile.exists()) return diff --git a/lawnchair/src/app/lawnchair/backup/LawnchairBackup.kt b/lawnchair/src/app/lawnchair/backup/LawnchairBackup.kt index eb77c89fc7..93cc6e4585 100644 --- a/lawnchair/src/app/lawnchair/backup/LawnchairBackup.kt +++ b/lawnchair/src/app/lawnchair/backup/LawnchairBackup.kt @@ -8,8 +8,10 @@ import android.graphics.BitmapFactory import android.net.Uri import androidx.core.graphics.drawable.toBitmap import app.lawnchair.LawnchairProto.BackupInfo +import app.lawnchair.data.AppDatabase import app.lawnchair.util.hasFlag import app.lawnchair.util.scaleDownTo +import app.lawnchair.util.scaleDownToDisplaySize import com.android.launcher3.BuildConfig import com.android.launcher3.LauncherAppState import com.android.launcher3.LauncherFiles @@ -27,6 +29,7 @@ import java.util.* import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream +import kotlin.math.max class LawnchairBackup( private val context: Context, @@ -36,12 +39,40 @@ class LawnchairBackup( var screenshot: Bitmap? = null var wallpaper: Bitmap? = null - private suspend fun readInfoAndPreview() { + suspend fun readInfoAndPreview() { + var tmpScreenshot: Bitmap? = null + var tmpWallpaper: Bitmap? = null readZip(mapOf( INFO_FILE_NAME to { info = BackupInfo.newBuilder().mergeFrom(it).build() }, - SCREENSHOT_FILE_NAME to { screenshot = BitmapFactory.decodeStream(it)?.scaleDownTo(1000, false) }, - WALLPAPER_FILE_NAME to { wallpaper = BitmapFactory.decodeStream(it)?.scaleDownTo(1000, false) }, + SCREENSHOT_FILE_NAME to { tmpScreenshot = BitmapFactory.decodeStream(it) }, + WALLPAPER_FILE_NAME to { tmpWallpaper = BitmapFactory.decodeStream(it) }, )) + val size = max(info.previewWidth, info.previewHeight).coerceAtMost(4000) + screenshot = tmpScreenshot?.scaleDownTo(size) + wallpaper = tmpWallpaper?.scaleDownToDisplaySize(context) + } + + suspend fun restore(selectedContents: Int) { + val handlers = mutableMapOf Unit>() + val contents = selectedContents and info.contents + if (contents.hasFlag(INCLUDE_LAYOUT_AND_SETTINGS)) { + handlers.putAll(getFiles(context, forRestore = true).mapValues { entry -> + { + val file = entry.value + file.parentFile?.mkdirs() + it.copyTo(file.outputStream()) + } + }) + } + if (contents.hasFlag(INCLUDE_WALLPAPER)) { + handlers[WALLPAPER_FILE_NAME] = { + val wallpaperManager = WallpaperManager.getInstance(context) + wallpaperManager.setBitmap(BitmapFactory.decodeStream(it)) + } + } + context.getDatabasePath(LAUNCHER_DB_FILE_NAME).parentFile?.deleteRecursively() + DeviceGridState(info.gridState).writeToPrefs(context, true) + readZip(handlers) } private suspend fun readZip(handlers: Map Unit>) { @@ -68,10 +99,18 @@ class LawnchairBackup( const val INFO_FILE_NAME = "info.pb" const val WALLPAPER_FILE_NAME = "wallpaper.png" const val SCREENSHOT_FILE_NAME = "screenshot.png" + const val LAUNCHER_DB_FILE_NAME = "launcher.db" + const val RESTORED_DB_FILE_NAME = "restored.db" + const val PREFS_FILE_NAME = "${LauncherFiles.SHARED_PREFERENCES_KEY}.xml" + const val PREFS_DB_FILE_NAME = "preferences" + const val PREFS_DATASTORE_FILE_NAME = "preferences.preferences_pb" const val INCLUDE_LAYOUT_AND_SETTINGS = 1 shl 0 const val INCLUDE_WALLPAPER = 1 shl 1 + const val MIME_TYPE = "application/zip" + val EXTRA_MIME_TYPES = arrayOf(MIME_TYPE, "application/x-zip", "application/octet-stream") + val contentOptions = listOf( INCLUDE_LAYOUT_AND_SETTINGS to R.string.backup_content_layout_and_settings, INCLUDE_WALLPAPER to R.string.backup_content_wallpaper, @@ -82,13 +121,17 @@ class LawnchairBackup( return "$fileName.lawnchairbackup" } + fun getFiles(context: Context, forRestore: Boolean): Map { + return mapOf( + LAUNCHER_DB_FILE_NAME to launcherDbFile(context, forRestore), + PREFS_FILE_NAME to prefsFile(context), + PREFS_DB_FILE_NAME to prefsDbFile(context), + PREFS_DATASTORE_FILE_NAME to prefsDataStoreFile(context), + ) + } + @SuppressLint("MissingPermission") suspend fun create(context: Context, contents: Int, screenshotBitmap: Bitmap, fileUri: Uri) { - val files: MutableList = ArrayList() - if (contents.hasFlag(INCLUDE_LAYOUT_AND_SETTINGS)) { - files.add(prefsFile(context)) - files.add(prefsDataStoreFile(context)) - } val idp = LauncherAppState.getIDP(context) val createdAt = Timestamp.newBuilder() .setSeconds(System.currentTimeMillis() / 1000) @@ -98,8 +141,11 @@ class LawnchairBackup( .setCreatedAt(createdAt) .setContents(contents) .setGridState(DeviceGridState(idp).toProtoMessage()) + .setPreviewWidth(screenshotBitmap.width) + .setPreviewHeight(screenshotBitmap.height) .build() + AppDatabase.INSTANCE.get(context).checkpoint() val pfd = context.contentResolver.openFileDescriptor(fileUri, "w")!! withContext(Dispatchers.IO) { pfd.use { @@ -118,31 +164,33 @@ class LawnchairBackup( if (contents.hasFlag(INCLUDE_LAYOUT_AND_SETTINGS)) { out.putNextEntry(ZipEntry(SCREENSHOT_FILE_NAME)) screenshotBitmap.compress(Bitmap.CompressFormat.PNG, 85, out) - - out.putNextEntry(ZipEntry("launcher.db")) - gridDbFile(context).inputStream().copyTo(out) } - files.forEach { file -> - out.putNextEntry(ZipEntry(file.name)) - file.inputStream().copyTo(out) + getFiles(context, forRestore = false).entries.forEach { + out.putNextEntry(ZipEntry(it.key)) + it.value.inputStream().copyTo(out) } } } } } - private fun gridDbFile(context: Context): File { - return context.getDatabasePath(LauncherAppState.getIDP(context).dbFile) + private fun launcherDbFile(context: Context, forRestore: Boolean): File { + val dbName = if (forRestore) RESTORED_DB_FILE_NAME else LauncherAppState.getIDP(context).dbFile + return context.getDatabasePath(dbName) } private fun prefsFile(context: Context): File { val dir = context.cacheDir.parent - return File(dir, "shared_prefs/" + LauncherFiles.SHARED_PREFERENCES_KEY + ".xml") + return File(dir, "shared_prefs/$PREFS_FILE_NAME") + } + + private fun prefsDbFile(context: Context): File { + return context.getDatabasePath(PREFS_DB_FILE_NAME) } private fun prefsDataStoreFile(context: Context): File { - return File(context.filesDir, "datastore/preferences.preferences_pb") + return File(context.filesDir, "datastore/${PREFS_DATASTORE_FILE_NAME}") } } } diff --git a/lawnchair/src/app/lawnchair/backup/ui/RestoreBackupScreen.kt b/lawnchair/src/app/lawnchair/backup/ui/RestoreBackupScreen.kt new file mode 100644 index 0000000000..4ccd645a85 --- /dev/null +++ b/lawnchair/src/app/lawnchair/backup/ui/RestoreBackupScreen.kt @@ -0,0 +1,213 @@ +package app.lawnchair.backup.ui + +import android.app.Activity +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.navArgument +import app.lawnchair.backup.LawnchairBackup +import app.lawnchair.ui.preferences.LocalNavController +import app.lawnchair.ui.preferences.Routes +import app.lawnchair.ui.preferences.components.* +import app.lawnchair.ui.preferences.preferenceGraph +import app.lawnchair.util.hasFlag +import app.lawnchair.util.restartLauncher +import com.android.launcher3.R +import com.google.accompanist.navigation.animation.composable +import kotlinx.coroutines.launch +import java.net.URLDecoder +import java.net.URLEncoder + +@OptIn(ExperimentalAnimationApi::class) +fun NavGraphBuilder.restoreBackupGraph(route: String) { + preferenceGraph(route, {}) { subRoute -> + composable( + route = subRoute("{backupUri}"), + arguments = listOf( + navArgument("backupUri") { type = NavType.StringType } + ) + ) { backStackEntry -> + val args = backStackEntry.arguments!! + val encodedUri = args.getString("backupUri")!! + val backupUriString = URLDecoder.decode(encodedUri, "utf-8") + val viewModel: RestoreBackupViewModel = viewModel() + DisposableEffect(key1 = null) { + viewModel.init(Uri.parse(backupUriString)) + onDispose { } + } + RestoreBackupScreen(viewModel) + } + } +} + +@Composable +fun RestoreBackupScreen(viewModel: RestoreBackupViewModel) { + val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT + val scrollState = rememberScrollState() + val uiState = viewModel.uiState.collectAsState().value + + PreferenceLayout( + label = stringResource(id = R.string.restore_backup), + scrollState = if (isPortrait) null else scrollState + ) { + when (uiState) { + is RestoreBackupUiState.Success -> RestoreBackupOptions(isPortrait, uiState.backup, viewModel = viewModel) + is RestoreBackupUiState.Loading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + is RestoreBackupUiState.Error -> { + val context = LocalContext.current + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + DisposableEffect(null) { + Toast.makeText(context, R.string.invalid_backup_file, Toast.LENGTH_SHORT).show() + backDispatcher?.onBackPressed() + onDispose { } + } + } + } + } +} + +@Composable +fun ColumnScope.RestoreBackupOptions( + isPortrait: Boolean, + backup: LawnchairBackup, + viewModel: RestoreBackupViewModel +) { + val backupContents = backup.info.contents + val contents by viewModel.backupContents.collectAsState() + + val context = LocalContext.current + val scope = rememberCoroutineScope() + var restoringBackup by remember { mutableStateOf(false) } + + fun restoreBackup() { + if (restoringBackup) return + scope.launch { + restoringBackup = true + try { + backup.restore(contents) + Toast.makeText(context, R.string.backup_restore_success, Toast.LENGTH_SHORT).show() + restartLauncher(context) + } catch (t: Throwable) { + Log.d("RestoreBackupScreen", "failed to restore backup", t) + Toast.makeText(context, R.string.backup_restore_error, Toast.LENGTH_SHORT).show() + } + restoringBackup = false + } + } + + if (isPortrait) { + DummyLauncherBox( + modifier = Modifier + .padding(top = 8.dp) + .weight(1f) + .align(Alignment.CenterHorizontally) + .clip(MaterialTheme.shapes.large) + ) { + val wallpaper = backup.wallpaper + if (contents.hasFlag(LawnchairBackup.INCLUDE_WALLPAPER) && wallpaper != null) { + Image( + bitmap = wallpaper.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillHeight + ) + } + val screenshot = backup.screenshot + if (contents.hasFlag(LawnchairBackup.INCLUDE_LAYOUT_AND_SETTINGS) && screenshot != null) { + Log.d("RestoreBackupScreen", "height: ${screenshot.height}") + Image( + bitmap = screenshot.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillHeight + ) + } + } + } + + PreferenceGroup(heading = stringResource(id = R.string.what_to_restore)) { + FlagSwitchPreference( + flags = contents, + setFlags = viewModel::setBackupContents, + mask = LawnchairBackup.INCLUDE_LAYOUT_AND_SETTINGS, + label = stringResource(id = R.string.backup_content_layout_and_settings), + enabled = backupContents.hasFlag(LawnchairBackup.INCLUDE_LAYOUT_AND_SETTINGS), + ) + FlagSwitchPreference( + flags = contents, + setFlags = viewModel::setBackupContents, + mask = LawnchairBackup.INCLUDE_WALLPAPER, + label = stringResource(id = R.string.backup_content_wallpaper), + enabled = backupContents.hasFlag(LawnchairBackup.INCLUDE_WALLPAPER), + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + Button( + onClick = { restoreBackup() }, + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxWidth(), + enabled = contents != 0 && !restoringBackup + ) { + Text(text = stringResource(id = R.string.restore_backup_action)) + } + } +} + +@Composable +fun restoreBackupOpener(): () -> Unit { + val navController = LocalNavController.current + + val request = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + val uri = it.data?.data ?: return@rememberLauncherForActivityResult + + @Suppress("BlockingMethodInNonBlockingContext") + val encodedUri = URLEncoder.encode(uri.toString(), "utf-8") + navController.navigate("/${Routes.RESTORE_BACKUP}/${encodedUri}/") + } + + return { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = LawnchairBackup.MIME_TYPE + putExtra(Intent.EXTRA_MIME_TYPES, LawnchairBackup.EXTRA_MIME_TYPES) + } + request.launch(intent) + } +} diff --git a/lawnchair/src/app/lawnchair/backup/ui/RestoreBackupViewModel.kt b/lawnchair/src/app/lawnchair/backup/ui/RestoreBackupViewModel.kt new file mode 100644 index 0000000000..aedf224baa --- /dev/null +++ b/lawnchair/src/app/lawnchair/backup/ui/RestoreBackupViewModel.kt @@ -0,0 +1,80 @@ +package app.lawnchair.backup.ui + +import android.app.Application +import android.net.Uri +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import app.lawnchair.backup.LawnchairBackup +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +sealed interface RestoreBackupUiState { + val isLoading: Boolean + + data class Success( + val backup: LawnchairBackup + ) : RestoreBackupUiState { + override val isLoading = false + } + + object Loading : RestoreBackupUiState { + override val isLoading = true + } + + object Error : RestoreBackupUiState { + override val isLoading = true + } +} + +private data class RestoreBackupViewModelState( + val backup: LawnchairBackup? = null, + val hasError: Boolean = false +) { + fun toUiState(): RestoreBackupUiState = when { + hasError -> RestoreBackupUiState.Error + backup != null -> RestoreBackupUiState.Success(backup) + else -> RestoreBackupUiState.Loading + } +} + +class RestoreBackupViewModel( + application: Application, + private val savedStateHandle: SavedStateHandle +) : AndroidViewModel(application) { + private var initialized = false + private lateinit var backupUri: Uri + + private val viewModelState = MutableStateFlow(RestoreBackupViewModelState()) + val uiState = viewModelState + .map { it.toUiState() } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value.toUiState() + ) + + val backupContents = savedStateHandle.getStateFlow("contents", 0) + + fun init(backupUri: Uri) { + if (initialized) return + initialized = true + this.backupUri = backupUri + viewModelScope.launch { + try { + val backup = LawnchairBackup(getApplication(), backupUri) + backup.readInfoAndPreview() + setBackupContents(backup.info.contents) + viewModelState.update { it.copy(backup = backup) } + } catch (t: Throwable) { + Log.d("RestoreBackupViewModel", "failed to parse backup", t) + viewModelState.update { it.copy(hasError = true) } + } + } + } + + fun setBackupContents(contents: Int) { + savedStateHandle["contents"] = contents + } +} diff --git a/lawnchair/src/app/lawnchair/data/AppDatabase.kt b/lawnchair/src/app/lawnchair/data/AppDatabase.kt index 6b7e62fb7e..031a89c421 100644 --- a/lawnchair/src/app/lawnchair/data/AppDatabase.kt +++ b/lawnchair/src/app/lawnchair/data/AppDatabase.kt @@ -4,6 +4,7 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.sqlite.db.SimpleSQLiteQuery import app.lawnchair.data.iconoverride.IconOverride import app.lawnchair.data.iconoverride.IconOverrideDao import com.android.launcher3.util.MainThreadInitializedObject @@ -14,6 +15,10 @@ abstract class AppDatabase : RoomDatabase() { abstract fun iconOverrideDao(): IconOverrideDao + suspend fun checkpoint() { + iconOverrideDao().checkpoint(SimpleSQLiteQuery("pragma wal_checkpoint(full)")) + } + companion object { val INSTANCE = MainThreadInitializedObject { context -> Room.databaseBuilder( diff --git a/lawnchair/src/app/lawnchair/data/iconoverride/IconOverrideDao.kt b/lawnchair/src/app/lawnchair/data/iconoverride/IconOverrideDao.kt index f48d3bdc2c..6e8c9a3cc9 100644 --- a/lawnchair/src/app/lawnchair/data/iconoverride/IconOverrideDao.kt +++ b/lawnchair/src/app/lawnchair/data/iconoverride/IconOverrideDao.kt @@ -1,12 +1,11 @@ package app.lawnchair.data.iconoverride -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query +import androidx.room.* +import androidx.sqlite.db.SupportSQLiteQuery import com.android.launcher3.util.ComponentKey import kotlinx.coroutines.flow.Flow + @Dao interface IconOverrideDao { @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -26,4 +25,7 @@ interface IconOverrideDao { @Query("DELETE FROM iconoverride") suspend fun deleteAll() + + @RawQuery + suspend fun checkpoint(supportSQLiteQuery: SupportSQLiteQuery): Int } diff --git a/lawnchair/src/app/lawnchair/ui/preferences/Preferences.kt b/lawnchair/src/app/lawnchair/ui/preferences/Preferences.kt index c55d0c5ebc..e83b10c802 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/Preferences.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/Preferences.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import app.lawnchair.backup.ui.createBackupGraph +import app.lawnchair.backup.ui.restoreBackupGraph import app.lawnchair.ui.preferences.about.aboutGraph import app.lawnchair.ui.preferences.components.SystemUi import app.lawnchair.ui.util.ProvideBottomSheetHandler @@ -102,6 +103,7 @@ fun Preferences(interactor: PreferenceInteractor = viewModel