diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/model/domain/AppExceptions.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/model/domain/AppExceptions.kt index 54a474aa0..bab5b96e2 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/model/domain/AppExceptions.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/model/domain/AppExceptions.kt @@ -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 } } diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/ui/navigation/AppNavHost.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/ui/navigation/AppNavHost.kt index 75a18beee..e943f5b4b 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/ui/navigation/AppNavHost.kt @@ -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( diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/ui/navigation/NavRoutes.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/ui/navigation/NavRoutes.kt index ff6b137dc..d67d73729 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/ui/navigation/NavRoutes.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/ui/navigation/NavRoutes.kt @@ -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" diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/backup/BackupRepository.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/backup/BackupRepository.kt new file mode 100644 index 000000000..e9ef2828e --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/backup/BackupRepository.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/backup/model/BackupPayload.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/backup/model/BackupPayload.kt new file mode 100644 index 000000000..128b1a3ab --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/backup/model/BackupPayload.kt @@ -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, + val locationKeys: List, + val apiKeys: List = emptyList(), // empty = opt-out was chosen, or none existed + val weatherUnits: WeatherUnitsBackup?, + val weatherBlocks: List, + 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 +) diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/location/LocationKeysDao.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/location/LocationKeysDao.kt index 5453616e8..d28078555 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/location/LocationKeysDao.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/location/LocationKeysDao.kt @@ -13,9 +13,18 @@ interface LocationKeysDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCityKey(entity: LocationKeyEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + @Query("SELECT * FROM location_keys WHERE locationId = :locationId") suspend fun getCityKeyForLocation(locationId: String): LocationKeyEntity? + @Query("SELECT * FROM location_keys") + suspend fun getAllCityKeys(): List + @Query("DELETE FROM location_keys WHERE locationId = :locationId") suspend fun deleteCityKeyForLocation(locationId: String) + + @Query("DELETE FROM location_keys") + suspend fun deleteAll() } \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/location/LocationsDao.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/location/LocationsDao.kt index 8129df56d..a84b3317e 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/location/LocationsDao.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/location/LocationsDao.kt @@ -19,6 +19,12 @@ interface LocationsDao { weatherLocation: WeatherLocationEntity ) + @Insert(onConflict = OnConflictStrategy.Companion.REPLACE) + suspend fun insertAllLocations(locations: List) + + @Query("DELETE FROM weather_locations") + suspend fun deleteAllLocations() + @Query("SELECT * FROM weather_locations ORDER BY isDefault DESC") fun getLocations(): Flow> diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/weather/ApiKeysDao.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/weather/ApiKeysDao.kt index bb4479fe4..ccb932c50 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/weather/ApiKeysDao.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/dao/weather/ApiKeysDao.kt @@ -12,6 +12,9 @@ interface ApiKeysDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertApiKeyForSource(apiKeyEntity: ApiKeyEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + @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() } \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/feature/intro/IntroScreen.kt b/app/src/main/java/com/pranshulgg/weather_master_app/feature/intro/IntroScreen.kt index b9777de0b..97ea426bd 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/feature/intro/IntroScreen.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/feature/intro/IntroScreen.kt @@ -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( diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/feature/intro/IntroScreenViewModel.kt b/app/src/main/java/com/pranshulgg/weather_master_app/feature/intro/IntroScreenViewModel.kt index 37129e700..b520ec02d 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/feature/intro/IntroScreenViewModel.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/feature/intro/IntroScreenViewModel.kt @@ -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 + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/SettingsScreen.kt b/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/SettingsScreen.kt index dd3153397..92bdf5ccb 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/SettingsScreen.kt @@ -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( diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/backup/BackupScreen.kt b/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/backup/BackupScreen.kt new file mode 100644 index 000000000..eb4d3225e --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/backup/BackupScreen.kt @@ -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(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" +} diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/backup/BackupScreenViewModel.kt b/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/backup/BackupScreenViewModel.kt new file mode 100644 index 000000000..c486135a9 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/feature/settings/backup/BackupScreenViewModel.kt @@ -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 + } + } + } +} diff --git a/app/src/main/res/drawable/cloud_upload_24px.xml b/app/src/main/res/drawable/cloud_upload_24px.xml new file mode 100644 index 000000000..763a44661 --- /dev/null +++ b/app/src/main/res/drawable/cloud_upload_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d17edeb8..a15dfb71d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -245,6 +245,23 @@ Check for updates View + Backup & Restore + Export or import your locations and settings + Export backup + Save your locations and settings to a file + Import backup + Replace your current locations and settings from a file + Include API keys + Store your saved API keys in the backup file + Import backup? + This will replace your current locations, units, blocks, and settings with the contents of the backup file. This can\'t be undone. + Backup exported + Backup restored + This backup file was made with a newer version of the app and can\'t be imported + This backup file is invalid or corrupted + Couldn\'t read or write the backup file + This backup doesn\'t have a default location and can\'t be imported + Separate sources for each region are recommended based on the country code Weather animations