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