Merge pull request #1065 from reveler-hub/feature/backup-restore

Add Backup & Restore (export/import locations and settings)
This commit is contained in:
Pranshul authored and GitHub committed 2026-08-22 17:11:52 +05:30
commit d852fbbeea
15 files changed
+687 -3

No files matched your search

@@ -35,6 +35,11 @@ sealed class AppException(message: String? = null) : Exception(message) {
class EmptyResponseBody : AppException()
class BackupSchemaVersionUnsupported : AppException()
class BackupFileCorrupted : AppException()
class BackupFileIOError : AppException()
class BackupMissingDefaultLocation : AppException()
}
fun AppException.toMessageRes(): Int {
@@ -53,6 +58,10 @@ fun AppException.toMessageRes(): Int {
is AppException.ApiKeyRejectedError -> R.string.error_api_key_rejected
is AppException.SecureConnection -> R.string.error_secure_connection_failed
is AppException.EmptyResponseBody -> R.string.error_empty_response_body
is AppException.BackupSchemaVersionUnsupported -> R.string.error_backup_schema_unsupported
is AppException.BackupFileCorrupted -> R.string.error_backup_file_corrupted
is AppException.BackupFileIOError -> R.string.error_backup_file_io
is AppException.BackupMissingDefaultLocation -> R.string.error_backup_missing_default_location
}
}
@@ -47,6 +47,7 @@ import com.pranshulgg.weather_master_app.feature.settings.about.terms.TermsCondi
import com.pranshulgg.weather_master_app.feature.settings.appearance.AppearanceScreen
import com.pranshulgg.weather_master_app.feature.settings.background.BackgroundUpdatesScreen
import com.pranshulgg.weather_master_app.feature.settings.background.WorkerInfoScreen
import com.pranshulgg.weather_master_app.feature.settings.backup.BackupScreen
import com.pranshulgg.weather_master_app.feature.settings.language.LanguageScreen
import com.pranshulgg.weather_master_app.feature.settings.sources.WeatherSourcesScreen
import com.pranshulgg.weather_master_app.feature.settings.units.UnitsScreen
@@ -115,6 +116,11 @@ fun AppNavHost(
) {
BackgroundUpdatesScreen(navController)
}
composable(
NavRoutes.BACKUP_RESTORE
) {
BackupScreen(navController)
}
composable(
route = "${NavRoutes.DAILY}/{index}/{locationId}",
arguments = listOf(
@@ -20,6 +20,8 @@ object NavRoutes {
const val BACKGROUND_UPDATES = "background_updates"
const val BACKUP_RESTORE = "backup_restore"
const val ABOUT = "about"
const val TERMS_CONDITIONS = "terms_conditions"
@@ -0,0 +1,136 @@
package com.pranshulgg.weather_master_app.data.backup
import androidx.room.withTransaction
import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlockType
import com.pranshulgg.weather_master_app.core.prefs.helper.PreferencesHelper
import com.pranshulgg.weather_master_app.data.backup.model.AppPrefsBackup
import com.pranshulgg.weather_master_app.data.backup.model.BACKUP_SCHEMA_VERSION
import com.pranshulgg.weather_master_app.data.backup.model.BackupPayload
import com.pranshulgg.weather_master_app.data.backup.model.WeatherBlockBackup
import com.pranshulgg.weather_master_app.data.backup.model.toBackupDto
import com.pranshulgg.weather_master_app.data.backup.model.toEntity
import com.pranshulgg.weather_master_app.data.local.WeatherMasterDatabase
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationKeysDao
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.ApiKeysDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherBlocksDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherUnitsDao
import com.pranshulgg.weather_master_app.data.local.entity.weather.blocks.WeatherBlockEntity
import kotlinx.serialization.json.Json
import javax.inject.Inject
class BackupRepository @Inject constructor(
private val db: WeatherMasterDatabase,
private val locationsDao: LocationsDao,
private val locationKeysDao: LocationKeysDao,
private val apiKeysDao: ApiKeysDao,
private val weatherUnitsDao: WeatherUnitsDao,
private val weatherBlocksDao: WeatherBlocksDao
) {
suspend fun buildBackup(includeApiKeys: Boolean): BackupPayload {
val locations = locationsDao.getLocationsOnce()
val locationKeys = locationKeysDao.getAllCityKeys()
val apiKeys = if (includeApiKeys) apiKeysDao.getAllApiKeys() else emptyList()
val weatherUnits = weatherUnitsDao.getOnce()
val weatherBlocks = weatherBlocksDao.getBlocks()
return BackupPayload(
exportedAt = System.currentTimeMillis(),
locations = locations.map { it.toBackupDto() },
locationKeys = locationKeys.map { it.toBackupDto() },
apiKeys = apiKeys.map { it.toBackupDto() },
weatherUnits = weatherUnits?.toBackupDto(),
weatherBlocks = weatherBlocks.map { it.toBackupDto() },
prefs = readPrefsBackup()
)
}
suspend fun serializeBackup(includeApiKeys: Boolean): String {
val payload = buildBackup(includeApiKeys)
return Json { prettyPrint = true }.encodeToString(BackupPayload.serializer(), payload)
}
suspend fun restoreBackup(payload: BackupPayload) {
if (payload.schemaVersion > BACKUP_SCHEMA_VERSION) {
throw AppException.BackupSchemaVersionUnsupported()
}
// A location list with no default would leave the app in a state it never expects
// (default-location lookups assume exactly one exists) - reject the whole import
// rather than silently producing that state.
if (payload.locations.none { it.isDefault }) {
throw AppException.BackupMissingDefaultLocation()
}
db.withTransaction {
locationKeysDao.deleteAll()
locationsDao.deleteAllLocations()
locationsDao.insertAllLocations(payload.locations.map { it.toEntity() })
locationKeysDao.insertAll(payload.locationKeys.map { it.toEntity() })
// An empty list means the export toggle was off (or there was nothing to export),
// not "the user wants their keys deleted" - so existing keys are left untouched
// instead of being wiped, unlike everything else in a full-replace restore.
if (payload.apiKeys.isNotEmpty()) {
apiKeysDao.deleteAllApiKeys()
apiKeysDao.insertAll(payload.apiKeys.map { it.toEntity() })
}
payload.weatherUnits?.let { weatherUnitsDao.insert(it.toEntity()) }
weatherBlocksDao.clearMainBlocks()
weatherBlocksDao.clearDailyBlocks()
weatherBlocksDao.insertBlocks(
payload.weatherBlocks.mapNotNull { it.toEntityOrNull() }
)
}
payload.prefs.applyToPreferences()
}
private fun readPrefsBackup(): AppPrefsBackup = AppPrefsBackup(
appTheme = PreferencesHelper.getString("app_theme"),
customThemeColor = PreferencesHelper.getString("custom_theme_color"),
isCustomTheme = PreferencesHelper.getBool("isCustomTheme"),
isDynamicTheme = PreferencesHelper.getBool("isDynamicTheme"),
themeVariantType = PreferencesHelper.getString("theme_variant_type"),
searchSource = PreferencesHelper.getString("searchSource"),
backgroundUpdatesEnabled = PreferencesHelper.getBool("backgroundUpdatesEnabled"),
backgroundUpdatesInterval = PreferencesHelper.getInt("backgroundUpdatesInterval"),
isFroggyLayout = PreferencesHelper.getBool("isFroggyLayout"),
isShowWeatherAnimations = PreferencesHelper.getBool("isShowWeatherAnimations"),
isWeatherBasedTheme = PreferencesHelper.getBool("isWeatherBasedTheme"),
is24HrTimeFormat = PreferencesHelper.getBool("is24HrTimeFormat"),
isShowSummary = PreferencesHelper.getBool("isShowSummary"),
isGoogleSansFlex = PreferencesHelper.getBool("isGoogleSansFlex")
)
private fun AppPrefsBackup.applyToPreferences() {
appTheme?.let { PreferencesHelper.setString("app_theme", it) }
customThemeColor?.let { PreferencesHelper.setString("custom_theme_color", it) }
isCustomTheme?.let { PreferencesHelper.setBool("isCustomTheme", it) }
isDynamicTheme?.let { PreferencesHelper.setBool("isDynamicTheme", it) }
themeVariantType?.let { PreferencesHelper.setString("theme_variant_type", it) }
searchSource?.let { PreferencesHelper.setString("searchSource", it) }
backgroundUpdatesEnabled?.let { PreferencesHelper.setBool("backgroundUpdatesEnabled", it) }
backgroundUpdatesInterval?.let { PreferencesHelper.setInt("backgroundUpdatesInterval", it) }
isFroggyLayout?.let { PreferencesHelper.setBool("isFroggyLayout", it) }
isShowWeatherAnimations?.let { PreferencesHelper.setBool("isShowWeatherAnimations", it) }
isWeatherBasedTheme?.let { PreferencesHelper.setBool("isWeatherBasedTheme", it) }
is24HrTimeFormat?.let { PreferencesHelper.setBool("is24HrTimeFormat", it) }
isShowSummary?.let { PreferencesHelper.setBool("isShowSummary", it) }
isGoogleSansFlex?.let { PreferencesHelper.setBool("isGoogleSansFlex", it) }
}
private fun WeatherBlockBackup.toEntityOrNull(): WeatherBlockEntity? {
val blockType = runCatching { WeatherBlockType.valueOf(type) }.getOrNull() ?: return null
return WeatherBlockEntity(
isDaily = isDaily,
type = blockType,
isHidden = isHidden,
position = position
)
}
}
@@ -0,0 +1,191 @@
package com.pranshulgg.weather_master_app.data.backup.model
import com.pranshulgg.weather_master_app.core.model.sources.Source
import com.pranshulgg.weather_master_app.core.model.weather.DistanceUnit
import com.pranshulgg.weather_master_app.core.model.weather.PrecipitationUnit
import com.pranshulgg.weather_master_app.core.model.weather.PressureUnit
import com.pranshulgg.weather_master_app.core.model.weather.TemperatureUnit
import com.pranshulgg.weather_master_app.core.model.weather.WindSpeedUnit
import com.pranshulgg.weather_master_app.core.model.weather.openmeteo.OpenMeteoModel
import com.pranshulgg.weather_master_app.data.local.entity.location.LocationKeyEntity
import com.pranshulgg.weather_master_app.data.local.entity.location.WeatherLocationEntity
import com.pranshulgg.weather_master_app.data.local.entity.weather.ApiKeyEntity
import com.pranshulgg.weather_master_app.data.local.entity.weather.blocks.WeatherBlockEntity
import com.pranshulgg.weather_master_app.data.local.entity.weather.units.AppWeatherUnitsEntity
import kotlinx.serialization.Serializable
const val BACKUP_SCHEMA_VERSION = 1
// Dedicated DTOs rather than the Room @Entity classes directly - the entities' shape is
// governed by WeatherMasterDatabase's migration history, which shouldn't also have to stay
// backward-compatible with old export files.
@Serializable
data class BackupPayload(
val schemaVersion: Int = BACKUP_SCHEMA_VERSION,
val exportedAt: Long,
val locations: List<LocationBackup>,
val locationKeys: List<LocationKeyBackup>,
val apiKeys: List<ApiKeyBackup> = emptyList(), // empty = opt-out was chosen, or none existed
val weatherUnits: WeatherUnitsBackup?,
val weatherBlocks: List<WeatherBlockBackup>,
val prefs: AppPrefsBackup
)
@Serializable
data class LocationBackup(
val id: String,
val name: String,
val country: String,
val lat: Double,
val lon: Double,
val timezone: String,
val source: Source,
val state: String? = null,
val isFavorite: Boolean = false,
val isPinned: Boolean = false,
val countryCode: String? = null,
val isDefault: Boolean = false,
val isDeviceLocation: Boolean = false,
val alertSource: Source,
val airQualitySource: Source,
val customName: String? = null,
val openMeteoModel: OpenMeteoModel = OpenMeteoModel.BEST_MATCH,
val alertsLastFetchedAt: Long? = null
)
@Serializable
data class LocationKeyBackup(
val locationId: String,
val cityKey: String
)
@Serializable
data class ApiKeyBackup(
val source: Source,
val apiKey: String?
)
@Serializable
data class WeatherUnitsBackup(
val tempUnit: TemperatureUnit,
val windUnit: WindSpeedUnit,
val distanceUnit: DistanceUnit,
val pressureUnit: PressureUnit,
val precipitationUnit: PrecipitationUnit
)
// type is stored as a plain string (WeatherBlockType.name), not the enum directly -
// kotlinx.serialization fails the whole decode on an unrecognized enum constant, and a string
// lets a future block type addition degrade to "skip this one row" instead.
@Serializable
data class WeatherBlockBackup(
val isDaily: Boolean,
val type: String,
val isHidden: Boolean,
val position: Int
)
// Every field nullable: null means "leave this pref alone" on import, not "clear it" - so an
// older/partial export file still imports cleanly.
@Serializable
data class AppPrefsBackup(
val appTheme: String? = null,
val customThemeColor: String? = null,
val isCustomTheme: Boolean? = null,
val isDynamicTheme: Boolean? = null,
val themeVariantType: String? = null,
val searchSource: String? = null,
val backgroundUpdatesEnabled: Boolean? = null,
val backgroundUpdatesInterval: Int? = null,
val isFroggyLayout: Boolean? = null,
val isShowWeatherAnimations: Boolean? = null,
val isWeatherBasedTheme: Boolean? = null,
val is24HrTimeFormat: Boolean? = null,
val isShowSummary: Boolean? = null,
val isGoogleSansFlex: Boolean? = null
)
fun WeatherLocationEntity.toBackupDto(): LocationBackup = LocationBackup(
id = id,
name = name,
country = country,
lat = lat,
lon = lon,
timezone = timezone,
source = source,
state = state,
isFavorite = isFavorite,
isPinned = isPinned,
countryCode = countryCode,
isDefault = isDefault,
isDeviceLocation = isDeviceLocation,
alertSource = alertSource,
airQualitySource = airQualitySource,
customName = customName,
openMeteoModel = openMeteoModel,
alertsLastFetchedAt = alertsLastFetchedAt
)
fun LocationBackup.toEntity(): WeatherLocationEntity = WeatherLocationEntity(
id = id,
name = name,
country = country,
lat = lat,
lon = lon,
timezone = timezone,
source = source,
state = state,
isFavorite = isFavorite,
isPinned = isPinned,
countryCode = countryCode,
isDefault = isDefault,
isDeviceLocation = isDeviceLocation,
alertSource = alertSource,
airQualitySource = airQualitySource,
customName = customName,
openMeteoModel = openMeteoModel,
alertsLastFetchedAt = alertsLastFetchedAt
)
fun LocationKeyEntity.toBackupDto(): LocationKeyBackup = LocationKeyBackup(
locationId = locationId,
cityKey = cityKey
)
fun LocationKeyBackup.toEntity(): LocationKeyEntity = LocationKeyEntity(
locationId = locationId,
cityKey = cityKey
)
fun ApiKeyEntity.toBackupDto(): ApiKeyBackup = ApiKeyBackup(
source = source,
apiKey = apiKey
)
fun ApiKeyBackup.toEntity(): ApiKeyEntity = ApiKeyEntity(
source = source,
apiKey = apiKey
)
fun AppWeatherUnitsEntity.toBackupDto(): WeatherUnitsBackup = WeatherUnitsBackup(
tempUnit = tempUnit,
windUnit = windUnit,
distanceUnit = distanceUnit,
pressureUnit = pressureUnit,
precipitationUnit = precipitationUnit
)
fun WeatherUnitsBackup.toEntity(): AppWeatherUnitsEntity = AppWeatherUnitsEntity(
tempUnit = tempUnit,
windUnit = windUnit,
distanceUnit = distanceUnit,
pressureUnit = pressureUnit,
precipitationUnit = precipitationUnit
)
fun WeatherBlockEntity.toBackupDto(): WeatherBlockBackup = WeatherBlockBackup(
isDaily = isDaily,
type = type.name,
isHidden = isHidden,
position = position
)
@@ -13,9 +13,18 @@ interface LocationKeysDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCityKey(entity: LocationKeyEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(entities: List<LocationKeyEntity>)
@Query("SELECT * FROM location_keys WHERE locationId = :locationId")
suspend fun getCityKeyForLocation(locationId: String): LocationKeyEntity?
@Query("SELECT * FROM location_keys")
suspend fun getAllCityKeys(): List<LocationKeyEntity>
@Query("DELETE FROM location_keys WHERE locationId = :locationId")
suspend fun deleteCityKeyForLocation(locationId: String)
@Query("DELETE FROM location_keys")
suspend fun deleteAll()
}
@@ -19,6 +19,12 @@ interface LocationsDao {
weatherLocation: WeatherLocationEntity
)
@Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
suspend fun insertAllLocations(locations: List<WeatherLocationEntity>)
@Query("DELETE FROM weather_locations")
suspend fun deleteAllLocations()
@Query("SELECT * FROM weather_locations ORDER BY isDefault DESC")
fun getLocations(): Flow<List<WeatherLocationEntity>>
@@ -12,6 +12,9 @@ interface ApiKeysDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertApiKeyForSource(apiKeyEntity: ApiKeyEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(entities: List<ApiKeyEntity>)
@Query("SELECT * FROM api_keys WHERE source = :source")
suspend fun getApiKeyForSource(source: Source): ApiKeyEntity?
@@ -20,4 +23,7 @@ interface ApiKeysDao {
@Query("UPDATE api_keys SET apiKey = :apiKey WHERE source = :source")
suspend fun updateApiKeyForSource(source: Source, apiKey: String)
@Query("DELETE FROM api_keys")
suspend fun deleteAllApiKeys()
}
@@ -2,6 +2,8 @@ package com.pranshulgg.weather_master_app.feature.intro
import android.content.Context
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -21,6 +23,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -65,6 +68,13 @@ fun IntroScreen(navController: NavController) {
var isLoading by remember { mutableStateOf(false) }
var backgroundLocationPermissionInfoDialogOpen by remember { mutableStateOf(false) }
var locationPermissionInfoDialogOpen by remember { mutableStateOf(false) }
val isImportingBackup = viewModel.isImportingBackup
val importBackupLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.importBackup(it) }
}
val continueWithLocation = {
isLoading = true
@@ -146,7 +156,7 @@ fun IntroScreen(navController: NavController) {
)
Gap(28.dp)
Button(
enabled = !isLoading,
enabled = !isLoading && !isImportingBackup,
onClick = {
locationPermissionInfoDialogOpen = true
},
@@ -163,7 +173,7 @@ fun IntroScreen(navController: NavController) {
}
Gap(12.dp)
OutlinedButton(
enabled = !isLoading,
enabled = !isLoading && !isImportingBackup,
onClick = {
navController.navigate(NavRoutes.SEARCH)
},
@@ -179,6 +189,22 @@ fun IntroScreen(navController: NavController) {
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Gap(4.dp)
TextButton(
enabled = !isLoading && !isImportingBackup,
onClick = {
importBackupLauncher.launch(arrayOf("application/json"))
},
modifier = Modifier.heightIn(btnSize),
contentPadding = ButtonDefaults.contentPaddingFor(btnSize),
shapes = ButtonDefaults.shapes()
) {
Text(
if (isImportingBackup) "Importing..." else "Import a backup",
style = ButtonDefaults.textStyleFor(btnSize),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Box(
@@ -1,25 +1,43 @@
package com.pranshulgg.weather_master_app.feature.intro
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.domain.toAppException
import com.pranshulgg.weather_master_app.core.model.domain.toMessageRes
import com.pranshulgg.weather_master_app.core.network.sources.address.nominatim.json.NominatimRepository
import com.pranshulgg.weather_master_app.core.prefs.AppPrefs
import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager
import com.pranshulgg.weather_master_app.data.backup.BackupRepository
import com.pranshulgg.weather_master_app.data.backup.model.BackupPayload
import com.pranshulgg.weather_master_app.data.provider.devicelocation.DeviceLocation
import com.pranshulgg.weather_master_app.data.repository.LocationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class IntroScreenViewModel @Inject constructor(
val locationsRepo: LocationsRepository,
@ApplicationContext private val context: Context,
private val nominatimRepository: NominatimRepository
private val nominatimRepository: NominatimRepository,
private val backupRepository: BackupRepository
) : ViewModel() {
var isImportingBackup by mutableStateOf(false)
private set
fun saveDeviceLocation(location: DeviceLocation) {
viewModelScope.launch {
@@ -45,4 +63,33 @@ class IntroScreenViewModel @Inject constructor(
}
fun importBackup(uri: Uri) {
viewModelScope.launch {
isImportingBackup = true
try {
val text = context.contentResolver.openInputStream(uri)?.use {
it.readBytes().decodeToString()
} ?: throw AppException.BackupFileIOError()
val payload = Json { ignoreUnknownKeys = true }
.decodeFromString(BackupPayload.serializer(), text)
backupRepository.restoreBackup(payload)
AppPrefs.initPrefs(context)
// MainScreen watches the location list reactively and steps past this screen
// itself once it's non-empty - no explicit navigation needed here.
} catch (e: SerializationException) {
SnackbarManager.show(AppException.BackupFileCorrupted().toMessageRes())
} catch (e: IllegalArgumentException) {
SnackbarManager.show(AppException.BackupFileCorrupted().toMessageRes())
} catch (e: IOException) {
SnackbarManager.show(AppException.BackupFileIOError().toMessageRes())
} catch (e: Exception) {
SnackbarManager.show(e.toAppException().toMessageRes())
} finally {
isImportingBackup = false
}
}
}
}
@@ -98,6 +98,12 @@ fun SettingsScreen(navController: NavController) {
description = getCurrentAppLocale().displayName,
onClick = { navController.navigate(NavRoutes.LANGUAGE) }
),
SettingTile.ActionTile(
leading = { SettingsTileIcon(R.drawable.cloud_download_24px) },
title = stringResource(R.string.setting_backup_restore),
description = stringResource(R.string.setting_backup_restore_secondary),
onClick = { navController.navigate(NavRoutes.BACKUP_RESTORE) }
),
)
)
SettingSection(
@@ -0,0 +1,123 @@
package com.pranshulgg.weather_master_app.feature.settings.backup
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.ui.components.LargeTopBarScaffold
import com.pranshulgg.weather_master_app.core.ui.components.NavigateUpBtn
import com.pranshulgg.weather_master_app.core.ui.components.SettingSection
import com.pranshulgg.weather_master_app.core.ui.components.SettingTile
import com.pranshulgg.weather_master_app.core.ui.components.SettingsTileIcon
import com.pranshulgg.weather_master_app.core.ui.components.TextAlertDialog
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun BackupScreen(navController: NavController) {
val viewModel: BackupScreenViewModel = hiltViewModel()
var isImportConfirmDialogOpen by remember { mutableStateOf(false) }
var pendingImportUri by remember { mutableStateOf<Uri?>(null) }
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { viewModel.exportTo(it) }
}
val importLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) {
pendingImportUri = uri
isImportConfirmDialogOpen = true
}
}
LargeTopBarScaffold(
title = stringResource(R.string.setting_backup_restore),
navigationIcon = { NavigateUpBtn(navController) },
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
SettingSection(
title = stringResource(R.string.backup_export_action),
tiles = listOf(
SettingTile.SwitchTile(
leading = { SettingsTileIcon(R.drawable.key_24px) },
title = stringResource(R.string.backup_include_api_keys),
description = stringResource(R.string.backup_include_api_keys_secondary),
checked = viewModel.includeApiKeys,
enabled = !viewModel.loading,
onCheckedChange = { viewModel.onIncludeApiKeysChanged(it) }
),
SettingTile.ActionTile(
leading = { SettingsTileIcon(R.drawable.cloud_upload_24px) },
title = stringResource(R.string.backup_export_action),
description = stringResource(R.string.backup_export_action_secondary),
onClick = {
exportLauncher.launch(defaultBackupFileName())
}
),
)
)
SettingSection(
title = stringResource(R.string.backup_import_action),
tiles = listOf(
SettingTile.ActionTile(
leading = { SettingsTileIcon(R.drawable.cloud_download_24px) },
title = stringResource(R.string.backup_import_action),
description = stringResource(R.string.backup_import_action_secondary),
danger = true,
onClick = {
importLauncher.launch(arrayOf("application/json"))
}
),
)
)
}
}
TextAlertDialog(
show = isImportConfirmDialogOpen,
title = stringResource(R.string.backup_import_confirm_title),
message = stringResource(R.string.backup_import_confirm_body),
confirmText = stringResource(R.string.action_confirm),
dismissText = stringResource(R.string.action_cancel),
onConfirm = {
pendingImportUri?.let { viewModel.importFrom(it) }
},
onDismiss = {
isImportConfirmDialogOpen = false
pendingImportUri = null
}
)
}
private fun defaultBackupFileName(): String {
val date = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
return "weathermaster_backup_$date.json"
}
@@ -0,0 +1,90 @@
package com.pranshulgg.weather_master_app.feature.settings.backup
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.toAppException
import com.pranshulgg.weather_master_app.core.model.domain.toMessageRes
import com.pranshulgg.weather_master_app.core.prefs.AppPrefs
import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager
import com.pranshulgg.weather_master_app.data.backup.BackupRepository
import com.pranshulgg.weather_master_app.data.backup.model.BackupPayload
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class BackupScreenViewModel @Inject constructor(
private val backupRepository: BackupRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
var loading by mutableStateOf(false)
private set
var includeApiKeys by mutableStateOf(false)
private set
fun onIncludeApiKeysChanged(value: Boolean) {
includeApiKeys = value
}
fun exportTo(uri: Uri) {
viewModelScope.launch {
loading = true
try {
val json = backupRepository.serializeBackup(includeApiKeys)
context.contentResolver.openOutputStream(uri)?.use {
it.write(json.toByteArray(Charsets.UTF_8))
} ?: throw AppException.BackupFileIOError()
SnackbarManager.show(R.string.message_backup_exported)
} catch (e: IOException) {
SnackbarManager.show(AppException.BackupFileIOError().toMessageRes())
} catch (e: Exception) {
SnackbarManager.show(e.toAppException().toMessageRes())
} finally {
loading = false
}
}
}
fun importFrom(uri: Uri) {
viewModelScope.launch {
loading = true
try {
val text = context.contentResolver.openInputStream(uri)?.use {
it.readBytes().decodeToString()
} ?: throw AppException.BackupFileIOError()
val payload = Json { ignoreUnknownKeys = true }
.decodeFromString(BackupPayload.serializer(), text)
backupRepository.restoreBackup(payload)
AppPrefs.initPrefs(context)
SnackbarManager.show(R.string.message_backup_restored)
} catch (e: SerializationException) {
SnackbarManager.show(AppException.BackupFileCorrupted().toMessageRes())
} catch (e: IllegalArgumentException) {
SnackbarManager.show(AppException.BackupFileCorrupted().toMessageRes())
} catch (e: IOException) {
SnackbarManager.show(AppException.BackupFileIOError().toMessageRes())
} catch (e: Exception) {
SnackbarManager.show(e.toAppException().toMessageRes())
} finally {
loading = false
}
}
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
>
<path
android:fillColor="@android:color/white"
android:pathData="M260,800q-91 0-155.5-63T40,583q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760,440q69 8 114.5 59.5T920,620q0 75-52.5 127.5T740,800H520q-33 0-56.5-23.5T440,720v-206l-64 62-56-56 160-160 160 160-56 56-64-62v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480,240q-83 0-141.5 58.5T280,440h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h100v80H260Zm220-280Z"/>
</vector>
+17
View File
@@ -245,6 +245,23 @@
<string name="about_check_for_updates">Check for updates</string>
<string name="action_view">View</string>
<string name="setting_backup_restore">Backup &amp; Restore</string>
<string name="setting_backup_restore_secondary">Export or import your locations and settings</string>
<string name="backup_export_action">Export backup</string>
<string name="backup_export_action_secondary">Save your locations and settings to a file</string>
<string name="backup_import_action">Import backup</string>
<string name="backup_import_action_secondary">Replace your current locations and settings from a file</string>
<string name="backup_include_api_keys">Include API keys</string>
<string name="backup_include_api_keys_secondary">Store your saved API keys in the backup file</string>
<string name="backup_import_confirm_title">Import backup?</string>
<string name="backup_import_confirm_body">This will replace your current locations, units, blocks, and settings with the contents of the backup file. This can\'t be undone.</string>
<string name="message_backup_exported">Backup exported</string>
<string name="message_backup_restored">Backup restored</string>
<string name="error_backup_schema_unsupported">This backup file was made with a newer version of the app and can\'t be imported</string>
<string name="error_backup_file_corrupted">This backup file is invalid or corrupted</string>
<string name="error_backup_file_io">Couldn\'t read or write the backup file</string>
<string name="error_backup_missing_default_location">This backup doesn\'t have a default location and can\'t be imported</string>
<string name="setting_weather_sources_info">Separate sources for each region are recommended based on the country code</string>
<string name="setting_weather_animations">Weather animations</string>