From eb1123ab739570ee9c878871b59718a9e77e5414 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Fri, 22 Dec 2023 00:25:41 +0100 Subject: [PATCH] Refactor weather module --- .../common/WeatherLocationSearchDialogVM.kt | 8 +- .../widgets/weather/WeatherWidgetVM.kt | 6 +- .../buildinfo/BuildInfoSettingsScreen.kt | 3 +- .../buildinfo/BuildInfoSettingsScreenVM.kt | 17 +- .../WeatherIntegrationSettingsScreen.kt | 21 +- .../WeatherIntegrationSettingsScreenVM.kt | 42 ++- .../de/mm20/launcher2/database/WeatherDao.kt | 4 +- data/weather/build.gradle.kts | 1 + .../weather/GeocoderWeatherProvider.kt | 78 +++++ .../weather/LatLonWeatherProvider.kt | 162 ---------- .../java/de/mm20/launcher2/weather/Module.kt | 21 +- .../mm20/launcher2/weather/WeatherLocation.kt | 13 +- .../mm20/launcher2/weather/WeatherProvider.kt | 116 +------ .../launcher2/weather/WeatherProviderInfo.kt | 6 + .../launcher2/weather/WeatherRepository.kt | 201 ++++++------ ...ghtskyProvider.kt => BrightSkyProvider.kt} | 66 ++-- .../launcher2/weather/here/HereProvider.kt | 141 +++++---- .../launcher2/weather/metno/MetNoProvider.kt | 286 ++++++++++-------- .../openweathermap/OpenWeatherMapProvider.kt | 264 ++++------------ .../weather/settings/WeatherSettings.kt | 118 ++++++++ .../weather/settings/WeatherSettingsData.kt | 42 ++- 21 files changed, 734 insertions(+), 882 deletions(-) create mode 100644 data/weather/src/main/java/de/mm20/launcher2/weather/GeocoderWeatherProvider.kt delete mode 100644 data/weather/src/main/java/de/mm20/launcher2/weather/LatLonWeatherProvider.kt create mode 100644 data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProviderInfo.kt rename data/weather/src/main/java/de/mm20/launcher2/weather/brightsky/{BrightskyProvider.kt => BrightSkyProvider.kt} (76%) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/common/WeatherLocationSearchDialogVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/common/WeatherLocationSearchDialogVM.kt index 01231557..a55172bd 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/common/WeatherLocationSearchDialogVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/common/WeatherLocationSearchDialogVM.kt @@ -4,12 +4,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import de.mm20.launcher2.weather.WeatherLocation import de.mm20.launcher2.weather.WeatherRepository +import de.mm20.launcher2.weather.settings.WeatherSettings import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.coroutines.coroutineContext class WeatherLocationSearchDialogVM: ViewModel(), KoinComponent { + private val weatherSettings: WeatherSettings by inject() private val repository: WeatherRepository by inject() val isSearchingLocation = mutableStateOf(false) @@ -27,7 +30,7 @@ class WeatherLocationSearchDialogVM: ViewModel(), KoinComponent { debounceSearchJob = launch { delay(1000) isSearchingLocation.value = true - locationResults.value = repository.lookupLocation(query) + locationResults.value = repository.searchLocations(query).first() isSearchingLocation.value = false } } @@ -35,7 +38,6 @@ class WeatherLocationSearchDialogVM: ViewModel(), KoinComponent { fun setLocation(location: WeatherLocation) { locationResults.value = emptyList() - repository.setAutoLocation(false) - repository.setLocation(location) + weatherSettings.setLocation(location) } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetVM.kt index f64048cb..a865322f 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetVM.kt @@ -9,6 +9,7 @@ import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.weather.DailyForecast import de.mm20.launcher2.weather.Forecast import de.mm20.launcher2.weather.WeatherRepository +import de.mm20.launcher2.weather.settings.WeatherSettings import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map @@ -21,6 +22,7 @@ import kotlin.math.min class WeatherWidgetVM : ViewModel(), KoinComponent { private val weatherRepository: WeatherRepository by inject() + private val weatherSettings: WeatherSettings by inject() private val permissionsManager: PermissionsManager by inject() @@ -58,7 +60,7 @@ class WeatherWidgetVM : ViewModel(), KoinComponent { currentForecast.value = getCurrentlySelectedForecast() } - private val forecastsFlow = weatherRepository.forecasts + private val forecastsFlow = weatherRepository.getDailyForecasts() /** * All available forecasts, grouped by day @@ -106,7 +108,7 @@ class WeatherWidgetVM : ViewModel(), KoinComponent { fun requestLocationPermission(context: AppCompatActivity) { permissionsManager.requestPermission(context, PermissionGroup.Location) } - val autoLocation = weatherRepository.autoLocation + val autoLocation = weatherSettings.autoLocation .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) val imperialUnits = dataStore.data.map { it.weather.imperialUnits } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/buildinfo/BuildInfoSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/buildinfo/BuildInfoSettingsScreen.kt index d35d1b2c..b66e8e93 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/buildinfo/BuildInfoSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/buildinfo/BuildInfoSettingsScreen.kt @@ -18,6 +18,7 @@ import java.security.MessageDigest fun BuildInfoSettingsScreen() { val viewModel: BuildInfoSettingsScreenVM = viewModel() val context = LocalContext.current + val buildFeatures by viewModel.buildFeatures.collectAsState(emptyMap()) PreferenceScreen(title = stringResource(R.string.preference_screen_buildinfo)) { item { Preference(title = "Build type", summary = BuildConfig.BUILD_TYPE) @@ -47,7 +48,7 @@ fun BuildInfoSettingsScreen() { } item { PreferenceCategory(title = "Features") { - for (feature in viewModel.buildFeatures) { + for (feature in buildFeatures) { Preference( title = feature.key, summary = if (feature.value) "YES" else "NO" diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/buildinfo/BuildInfoSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/buildinfo/BuildInfoSettingsScreenVM.kt index 3e629b79..90bb8e23 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/buildinfo/BuildInfoSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/buildinfo/BuildInfoSettingsScreenVM.kt @@ -5,6 +5,7 @@ import de.mm20.launcher2.accounts.AccountType import de.mm20.launcher2.accounts.AccountsRepository import de.mm20.launcher2.preferences.Settings.WeatherSettings.WeatherProvider import de.mm20.launcher2.weather.WeatherRepository +import kotlinx.coroutines.flow.map import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -12,12 +13,14 @@ class BuildInfoSettingsScreenVM : ViewModel(), KoinComponent { private val accountsRepository: AccountsRepository by inject() private val weatherRepository: WeatherRepository by inject() - private val availableWeatherProviders = weatherRepository.getAvailableProviders() + private val availableWeatherProviders = weatherRepository.getProviders() - val buildFeatures = mapOf( - "Accounts: Google" to accountsRepository.isSupported(AccountType.Google), - "Weather providers: HERE" to availableWeatherProviders.contains(WeatherProvider.Here), - "Weather providers: Met No" to availableWeatherProviders.contains(WeatherProvider.MetNo), - "Weather providers: OpenWeatherMap" to availableWeatherProviders.contains(WeatherProvider.OpenWeatherMap), - ) + val buildFeatures = availableWeatherProviders.map { + mapOf( + "Accounts: Google" to accountsRepository.isSupported(AccountType.Google), + "Weather providers: HERE" to it.any { it.id == "here" }, + "Weather providers: Met No" to it.any { it.id == "metno" }, + "Weather providers: OpenWeatherMap" to it.any { it.id == "owm" }, + ) + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/weather/WeatherIntegrationSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/weather/WeatherIntegrationSettingsScreen.kt index 72f72ade..ee787a05 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/weather/WeatherIntegrationSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/weather/WeatherIntegrationSettingsScreen.kt @@ -8,20 +8,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import de.mm20.launcher2.preferences.Settings.WeatherSettings.WeatherProvider import de.mm20.launcher2.ui.BuildConfig import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.common.WeatherLocationSearchDialog import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.preferences.* import de.mm20.launcher2.weather.WeatherLocation +import de.mm20.launcher2.weather.WeatherProviderInfo @Composable fun WeatherIntegrationSettingsScreen() { val viewModel: WeatherIntegrationSettingsScreenVM = viewModel() val context = LocalContext.current + val availableProviders by viewModel.availableProviders.collectAsState(emptyList()) + PreferenceScreen( title = stringResource(R.string.preference_screen_weatherwidget), helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/integrations/weather" @@ -31,14 +34,8 @@ fun WeatherIntegrationSettingsScreen() { val weatherProvider by viewModel.weatherProvider.collectAsState() ListPreference( title = stringResource(R.string.preference_weather_provider), - items = viewModel.availableProviders.map { - when (it) { - WeatherProvider.MetNo -> stringResource(R.string.provider_metno) - WeatherProvider.OpenWeatherMap -> stringResource(R.string.provider_openweathermap) - WeatherProvider.Here -> stringResource(R.string.provider_here) - WeatherProvider.BrightSky -> stringResource(R.string.provider_brightsky) - else -> "Unknown provider" - } to it + items = availableProviders.map{ + it.name to it.id }, onValueChanged = { if (it != null) viewModel.setWeatherProvider(it) @@ -78,7 +75,7 @@ fun WeatherIntegrationSettingsScreen() { viewModel.setAutoLocation(it) } ) - val location by viewModel.location + val location by viewModel.location.collectAsStateWithLifecycle() LocationPreference( title = stringResource(R.string.preference_location), value = location, @@ -104,13 +101,13 @@ fun WeatherIntegrationSettingsScreen() { @Composable fun LocationPreference( title: String, - value: WeatherLocation?, + value: String?, enabled: Boolean = true ) { var showDialog by remember { mutableStateOf(false) } Preference( title = title, - summary = value?.name, + summary = value, enabled = enabled, onClick = { showDialog = true diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/weather/WeatherIntegrationSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/weather/WeatherIntegrationSettingsScreenVM.kt index fadecea1..0e90ed46 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/weather/WeatherIntegrationSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/weather/WeatherIntegrationSettingsScreenVM.kt @@ -7,13 +7,16 @@ import androidx.lifecycle.viewModelScope import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.preferences.LauncherDataStore -import de.mm20.launcher2.preferences.Settings.WeatherSettings import de.mm20.launcher2.weather.WeatherLocation +import de.mm20.launcher2.weather.WeatherProviderInfo import de.mm20.launcher2.weather.WeatherRepository +import de.mm20.launcher2.weather.settings.WeatherSettings import kotlinx.coroutines.* import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.koin.core.component.KoinComponent @@ -21,15 +24,16 @@ import org.koin.core.component.inject class WeatherIntegrationSettingsScreenVM : ViewModel(), KoinComponent { private val repository: WeatherRepository by inject() + private val weatherSettings: WeatherSettings by inject() private val permissionsManager: PermissionsManager by inject() private val dataStore: LauncherDataStore by inject() - val availableProviders = repository.getAvailableProviders() + val availableProviders = repository.getProviders() - val weatherProvider = repository.selectedProvider + val weatherProvider = weatherSettings.providerId .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - fun setWeatherProvider(provider: WeatherSettings.WeatherProvider) { - repository.selectProvider(provider) + fun setWeatherProvider(provider: String) { + weatherSettings.setProviderId(provider) } val imperialUnits = dataStore.data.map { it.weather.imperialUnits } @@ -43,13 +47,19 @@ class WeatherIntegrationSettingsScreenVM : ViewModel(), KoinComponent { } } - val autoLocation = repository.autoLocation + val autoLocation = weatherSettings.autoLocation .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) fun setAutoLocation(autoLocation: Boolean) { - repository.setAutoLocation(autoLocation) + weatherSettings.setAutoLocation(autoLocation) } - val location = mutableStateOf(null) + val location = weatherSettings.autoLocation.flatMapLatest { + if (it) { + repository.getForecasts(limit = 1).map { it.firstOrNull()?.location } + } else { + weatherSettings.location.map { it?.name } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) @@ -59,22 +69,8 @@ class WeatherIntegrationSettingsScreenVM : ViewModel(), KoinComponent { } - init { - viewModelScope.launch { - val autoLocation = repository.autoLocation - val location = repository.location - val lastLocation = repository.lastLocation - combine(autoLocation, lastLocation, location) { autoLoc, lastLoc, loc -> - if (autoLoc) lastLoc - else loc - }.collectLatest { - this@WeatherIntegrationSettingsScreenVM.location.value = it - } - } - } - fun clearWeatherData() { - repository.clearForecasts() + repository.deleteForecasts() } } \ No newline at end of file diff --git a/data/database/src/main/java/de/mm20/launcher2/database/WeatherDao.kt b/data/database/src/main/java/de/mm20/launcher2/database/WeatherDao.kt index 8c236abc..7537bc4d 100644 --- a/data/database/src/main/java/de/mm20/launcher2/database/WeatherDao.kt +++ b/data/database/src/main/java/de/mm20/launcher2/database/WeatherDao.kt @@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.Flow @Dao interface WeatherDao { - @Query("SELECT * FROM ${ForecastEntity.TABLE_NAME} ORDER BY timestamp ASC") - fun getForecasts(): Flow> + @Query("SELECT * FROM ${ForecastEntity.TABLE_NAME} ORDER BY timestamp ASC LIMIT :limit") + fun getForecasts(limit: Int = 99999): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertAll(forecasts: List) diff --git a/data/weather/build.gradle.kts b/data/weather/build.gradle.kts index ea4df376..7d34d554 100644 --- a/data/weather/build.gradle.kts +++ b/data/weather/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(libs.koin.android) implementation(project(":data:database")) + implementation(project(":core:base")) implementation(project(":core:ktx")) implementation(project(":core:crashreporter")) implementation(project(":core:preferences")) diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/GeocoderWeatherProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/GeocoderWeatherProvider.kt new file mode 100644 index 00000000..db6106b5 --- /dev/null +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/GeocoderWeatherProvider.kt @@ -0,0 +1,78 @@ +package de.mm20.launcher2.weather + +import android.content.Context +import android.location.Geocoder +import de.mm20.launcher2.crashreporter.CrashReporter +import de.mm20.launcher2.ktx.formatToString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +internal abstract class GeocoderWeatherProvider( + private val context: Context, +): WeatherProvider { + override suspend fun findLocation(query: String): List { + val parts = query.split(" ", limit = 3) + val lat = parts.getOrNull(0)?.toDoubleOrNull() + val lon = parts.getOrNull(1)?.toDoubleOrNull() + if (lat != null && lon != null && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { + val name = parts.getOrElse(2) { getLocationName(lat, lon) } + return listOf( + WeatherLocation.LatLon(name, lat, lon) + ) + } + if (!Geocoder.isPresent()) return emptyList() + val geocoder = Geocoder(context) + val locations = + withContext(Dispatchers.IO) { + try { + geocoder.getFromLocationName(query, 10) + } catch (e: IOException) { + CrashReporter.logException(e) + emptyList() + } + } ?: emptyList() + return locations.mapNotNull { + WeatherLocation.LatLon( + lat = it.latitude, + lon = it.longitude, + name = it.formatToString() + ) + } + } + + internal suspend fun getLocationName(lat: Double, lon: Double): String { + if (!Geocoder.isPresent()) return formatLatLon(lat, lon) + return withContext(Dispatchers.IO) { + try { + Geocoder(context).getFromLocation(lat, lon, 1) + ?.firstOrNull() + ?.formatToString() ?: formatLatLon(lat, lon) + } catch (e: IOException) { + CrashReporter.logException(e) + formatLatLon(lat, lon) + } + } + } + + internal fun formatLatLon(lat: Double, lon: Double): String { + val absLat = lat.absoluteValue + val absLon = lon.absoluteValue + + val dLat = absLat.toInt() + val dLon = absLon.toInt() + + val mLat = ((absLat - dLat) * 60).roundToInt() + + val mLon = ((absLon - dLon) * 60).roundToInt() + + + val dmsLat = "$dLat°$mLat'${if (lat >= 0) "N" else "S"}" + + val dmsLon = "$dLon°$mLon'${if (lat >= 0) "E" else "W"}" + + return "$dmsLat $dmsLon" + } +} \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/LatLonWeatherProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/LatLonWeatherProvider.kt deleted file mode 100644 index 6d80246e..00000000 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/LatLonWeatherProvider.kt +++ /dev/null @@ -1,162 +0,0 @@ -package de.mm20.launcher2.weather - -import android.location.Geocoder -import androidx.core.content.edit -import de.mm20.launcher2.crashreporter.CrashReporter -import de.mm20.launcher2.ktx.formatToString -import de.mm20.launcher2.ktx.getDouble -import de.mm20.launcher2.ktx.putDouble -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.IOException -import kotlin.math.absoluteValue -import kotlin.math.roundToInt - -/** - * A WeatherProvider that uses lat/lon locations only (instead of provider specific location IDs) - */ -abstract class LatLonWeatherProvider : WeatherProvider() { - - - override suspend fun lookupLocation(query: String): List { - val parts = query.split(" ", limit = 3) - val lat = parts.getOrNull(0)?.toDoubleOrNull() - val lon = parts.getOrNull(1)?.toDoubleOrNull() - if (lat != null && lon != null && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { - val name = parts.getOrElse(2) { getLocationName(lat, lon) } - return listOf( - LatLonWeatherLocation(name, lat, lon) - ) - } - if (!Geocoder.isPresent()) return emptyList() - val geocoder = Geocoder(context) - val locations = - withContext(Dispatchers.IO) { - try { - geocoder.getFromLocationName(query, 10) - } catch (e: IOException) { - CrashReporter.logException(e) - emptyList() - } - } ?: emptyList() - return locations.mapNotNull { - LatLonWeatherLocation( - lat = it.latitude, - lon = it.longitude, - name = it.formatToString() - ) - } - } - - override suspend fun loadWeatherData( - lat: Double, - lon: Double - ): WeatherUpdateResult? { - return try { - val locationName = getLocationName(lat, lon) - loadWeatherData( - LatLonWeatherLocation( - name = locationName, - lat = lat, - lon = lon - ) - ) - } catch (e: IOException) { - CrashReporter.logException(e) - null - } - } - - private suspend fun getLocationName(lat: Double, lon: Double): String { - if (!Geocoder.isPresent()) return formatLatLon(lat, lon) - return withContext(Dispatchers.IO) { - try { - Geocoder(context).getFromLocation(lat, lon, 1) - ?.firstOrNull() - ?.formatToString() ?: formatLatLon(lat, lon) - } catch (e: IOException) { - CrashReporter.logException(e) - formatLatLon(lat, lon) - } - } - } - - private fun formatLatLon(lat: Double, lon: Double): String { - val absLat = lat.absoluteValue - val absLon = lon.absoluteValue - - val dLat = absLat.toInt() - val dLon = absLon.toInt() - - val mLat = ((absLat - dLat) * 60).roundToInt() - - val mLon = ((absLon - dLon) * 60).roundToInt() - - - val dmsLat = "$dLat°$mLat'${if (lat >= 0) "N" else "S"}" - - val dmsLon = "$dLon°$mLon'${if (lat >= 0) "E" else "W"}" - - return "$dmsLat $dmsLon" - } - - override fun setLocation(location: WeatherLocation?) { - location as LatLonWeatherLocation? - preferences.edit { - if (location == null) { - remove(LAT) - remove(LON) - remove(LOCATION_NAME) - } else { - putDouble(LAT, location.lat) - putDouble(LON, location.lon) - putString(LOCATION_NAME, location.name) - } - } - } - - override fun getLocation(): LatLonWeatherLocation? { - val lat = preferences.getDouble(LAT) ?: return null - val lon = preferences.getDouble(LON) ?: return null - val name = preferences.getString(LOCATION_NAME, null) ?: return null - return LatLonWeatherLocation( - name = name, - lat = lat, - lon = lon - ) - } - - override fun saveLastLocation(location: LatLonWeatherLocation) { - preferences.edit { - putDouble(LAST_LAT, location.lat) - putDouble(LAST_LON, location.lon) - putString(LAST_LOCATION_NAME, location.name) - } - } - - override fun getLastLocation(): LatLonWeatherLocation? { - val lat = preferences.getDouble(LAST_LAT) ?: return null - val lon = preferences.getDouble(LAST_LON) ?: return null - val name = preferences.getString(LAST_LOCATION_NAME, null) ?: return null - return LatLonWeatherLocation( - name = name, - lat = lat, - lon = lon - ) - } - - companion object { - private const val LAT = "lat" - private const val LON = "lon" - private const val LOCATION_NAME = "location_name" - private const val LAST_LAT = "last_lat" - private const val LAST_LON = "last_lon" - private const val LAST_LOCATION_NAME = "last_location_name" - } -} - -data class LatLonWeatherLocation( - override val name: String, - val lat: Double, - val lon: Double -) : WeatherLocation \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/Module.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/Module.kt index 95869296..61ec11e7 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/Module.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/Module.kt @@ -1,21 +1,26 @@ package de.mm20.launcher2.weather -import de.mm20.launcher2.preferences.Settings.WeatherSettings -import de.mm20.launcher2.weather.brightsky.BrightskyProvider +import de.mm20.launcher2.backup.Backupable +import de.mm20.launcher2.weather.brightsky.BrightSkyProvider import de.mm20.launcher2.weather.here.HereProvider import de.mm20.launcher2.weather.metno.MetNoProvider import de.mm20.launcher2.weather.openweathermap.OpenWeatherMapProvider +import de.mm20.launcher2.weather.settings.WeatherSettings import org.koin.android.ext.koin.androidContext +import org.koin.core.qualifier.named import org.koin.dsl.module val weatherModule = module { single { WeatherRepositoryImpl(androidContext(), get(), get()) } - factory { (selectedProvider: WeatherSettings.WeatherProvider) -> - when (selectedProvider) { - WeatherSettings.WeatherProvider.OpenWeatherMap -> OpenWeatherMapProvider(androidContext()) - WeatherSettings.WeatherProvider.Here -> HereProvider(androidContext()) - WeatherSettings.WeatherProvider.BrightSky -> BrightskyProvider(androidContext()) - else -> MetNoProvider(androidContext()) + single { WeatherSettings(androidContext()) } + factory(named()) { get() } + factory { (providerId: String) -> + when (providerId) { + OpenWeatherMapProvider.Id -> OpenWeatherMapProvider(androidContext()) + MetNoProvider.Id -> MetNoProvider(androidContext(), get()) + HereProvider.Id -> HereProvider(androidContext()) + BrightSkyProvider.Id -> BrightSkyProvider(androidContext()) + else -> TODO("Implement plugin provider") } } } \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherLocation.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherLocation.kt index c8f31b52..65a894ce 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherLocation.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherLocation.kt @@ -1,5 +1,16 @@ package de.mm20.launcher2.weather -interface WeatherLocation { +sealed interface WeatherLocation { val name: String + + data class LatLon( + override val name: String, + val lat: Double, + val lon: Double, + ) : WeatherLocation + + data class Id( + override val name: String, + val locationId: String, + ) : WeatherLocation } \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt index 5abc8cf8..6c053cf9 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt @@ -1,111 +1,21 @@ package de.mm20.launcher2.weather -import android.Manifest -import android.content.Context -import android.content.SharedPreferences -import android.location.Location -import android.location.LocationManager -import androidx.core.content.edit -import androidx.core.content.getSystemService -import de.mm20.launcher2.ktx.checkPermission +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf -abstract class WeatherProvider { +interface WeatherProvider { - internal abstract val context: Context + val updateInterval: Long + get() = 1000 * 60 * 60L - internal abstract val preferences: SharedPreferences + suspend fun getWeatherData(location: WeatherLocation): List? + suspend fun getWeatherData(lat: Double, lon: Double): List? + suspend fun findLocation(query: String): List - var autoLocation: Boolean - get() { - return preferences.getBoolean(AUTO_LOCATION, true) - } - set(value) { - preferences.edit { - putBoolean(AUTO_LOCATION, value) - } - } - - - suspend fun fetchNewWeatherData(): List? { - val result: WeatherUpdateResult = if (autoLocation) { - val location = getCurrentLocation() - if (location != null) { - loadWeatherData(location.latitude, location.longitude) ?: return null - } else { - val lastLocation = getLastLocation() ?: return null - loadWeatherData(lastLocation) ?: return null - } - } else { - val setLocation = getLocation() ?: return null - loadWeatherData(setLocation) ?: return null - } - saveLastLocation(result.location) - setLastUpdate(System.currentTimeMillis()) - return result.forecasts - } - - private fun getCurrentLocation(): Location? { - val lm = context.getSystemService()!! - var location: Location? = null - if (context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { - location = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) - } - if (location == null && context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) { - location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) - } - return location - } - - internal abstract suspend fun loadWeatherData(location: T): WeatherUpdateResult? - internal abstract suspend fun loadWeatherData(lat: Double, lon: Double): WeatherUpdateResult? - - abstract fun isUpdateRequired(): Boolean - - fun getLastUpdate(): Long { - return preferences.getLong(LAST_UPDATE, 0) - } - - private fun setLastUpdate(time: Long) { - preferences.edit { - putLong(LAST_UPDATE, time) + companion object: KoinComponent { + internal fun getInstance(providerId: String): WeatherProvider { + return get { parametersOf(providerId) } } } - - /** - * Lookup a location based on a string query. - * @param query the location to lookup - * @return a list of locations - */ - abstract suspend fun lookupLocation(query: String): List - - /** - * @param location must be of type T - */ - abstract fun setLocation(location: WeatherLocation?) - abstract fun getLocation(): T? - - abstract fun isAvailable(): Boolean - - abstract val name: String - - abstract fun getLastLocation(): T? - - abstract fun saveLastLocation(location: T) - - fun resetLastUpdate() { - preferences.edit { - putLong(LAST_UPDATE, 0L) - } - } - - companion object { - - private const val LAST_UPDATE = "last_update" - private const val AUTO_LOCATION = "auto_location" - } -} - -data class WeatherUpdateResult( - val forecasts: List, - val location: T -) \ No newline at end of file +} \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProviderInfo.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProviderInfo.kt new file mode 100644 index 00000000..ad8a2054 --- /dev/null +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProviderInfo.kt @@ -0,0 +1,6 @@ +package de.mm20.launcher2.weather + +data class WeatherProviderInfo( + val id: String, + val name: String, +) \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt index 250e6b49..5cec348a 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt @@ -1,107 +1,70 @@ package de.mm20.launcher2.weather +import android.Manifest import android.content.Context +import android.location.Location +import android.location.LocationManager import android.util.Log +import androidx.core.content.getSystemService import androidx.work.* import de.mm20.launcher2.database.AppDatabase +import de.mm20.launcher2.ktx.checkPermission import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager -import de.mm20.launcher2.preferences.LauncherDataStore -import de.mm20.launcher2.preferences.Settings.WeatherSettings -import de.mm20.launcher2.weather.brightsky.BrightskyProvider +import de.mm20.launcher2.weather.brightsky.BrightSkyProvider import de.mm20.launcher2.weather.here.HereProvider import de.mm20.launcher2.weather.metno.MetNoProvider import de.mm20.launcher2.weather.openweathermap.OpenWeatherMapProvider +import de.mm20.launcher2.weather.settings.LatLon +import de.mm20.launcher2.weather.settings.ProviderSettings +import de.mm20.launcher2.weather.settings.WeatherSettings import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.koin.core.component.KoinComponent -import org.koin.core.component.get import org.koin.core.component.inject -import org.koin.core.parameter.parametersOf import java.util.* import java.util.concurrent.TimeUnit interface WeatherRepository { - val forecasts: Flow> + fun getProviders(): Flow> + fun searchLocations(query: String): Flow> - suspend fun lookupLocation(query: String): List + fun getForecasts(limit: Int? = null): Flow> + fun getDailyForecasts(): Flow> - val lastLocation: Flow - val location: Flow - val autoLocation: Flow - - fun setLocation(location: WeatherLocation) - fun setAutoLocation(autoLocation: Boolean) - fun setLastLocation(lastLocation: WeatherLocation?) - - fun getAvailableProviders(): List - - fun selectProvider(provider: WeatherSettings.WeatherProvider) - - val selectedProvider: Flow - - fun clearForecasts() + fun deleteForecasts() } internal class WeatherRepositoryImpl( private val context: Context, private val database: AppDatabase, - private val dataStore: LauncherDataStore, + private val settings: WeatherSettings, ) : WeatherRepository, KoinComponent { private val scope = CoroutineScope(Job() + Dispatchers.Default) - private var provider: WeatherProvider private val permissionsManager: PermissionsManager by inject() private val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location) - override val selectedProvider = dataStore.data.map { it.weather.provider } - override val forecasts: Flow> - get() = database.weatherDao().getForecasts() + override fun getForecasts(limit: Int?): Flow> { + return database.weatherDao().getForecasts(limit ?: 99999) + .map { it.map { Forecast(it) } } + } + override fun getDailyForecasts(): Flow> { + return database.weatherDao().getForecasts() .map { it.map { Forecast(it) } } .map { groupForecastsPerDay(it) } - - override val lastLocation = MutableStateFlow(null) - override val location = MutableStateFlow(null) - override val autoLocation = MutableStateFlow(false) - - override fun setLocation(location: WeatherLocation) { - provider.setLocation(location) - this.location.value = location - provider.resetLastUpdate() - requestUpdate() } - override fun setAutoLocation(autoLocation: Boolean) { - provider.autoLocation = autoLocation - this.autoLocation.value = autoLocation - provider.resetLastUpdate() - requestUpdate() - } - - override fun setLastLocation(lastLocation: WeatherLocation?) { - this.lastLocation.value = lastLocation - } - - override suspend fun lookupLocation(query: String): List { - return provider.lookupLocation(query) - } - - override fun selectProvider(provider: WeatherSettings.WeatherProvider) { - scope.launch { - dataStore.updateData { - it.toBuilder() - .setWeather( - it.weather.toBuilder() - .setProvider(provider) - ) - .build() - } + override fun searchLocations(query: String): Flow> { + return settings.data.map { + val provider = WeatherProvider.getInstance(it.provider) + provider.findLocation(query) } } @@ -114,35 +77,16 @@ internal class WeatherRepositoryImpl( ExistingPeriodicWorkPolicy.KEEP, weatherRequest ) - provider = runBlocking { - val selectedProvider = selectedProvider.first() - get { parametersOf(selectedProvider) } - } - - scope.launch { - var providerSetting: WeatherSettings.WeatherProvider? = null - selectedProvider.collectLatest { - if (it != providerSetting) { - provider = get { parametersOf(it) } - location.value = provider.getLocation() - lastLocation.value = provider.getLastLocation() - autoLocation.value = provider.autoLocation - - // Force weather data update but only if provider has changed; not during - // initialization - if (providerSetting != null) { - provider.resetLastUpdate() - requestUpdate() - } - providerSetting = it - } - } - } scope.launch { hasLocationPermission.collectLatest { if (it) requestUpdate() } } + scope.launch { + settings.data.collectLatest { + requestUpdate() + } + } } private fun groupForecastsPerDay(forecasts: List): List { @@ -193,59 +137,88 @@ internal class WeatherRepositoryImpl( WorkManager.getInstance(context).enqueue(weatherRequest) } - override fun clearForecasts() { + override fun deleteForecasts() { scope.launch { withContext(Dispatchers.IO) { database.weatherDao().deleteAll() - provider.resetLastUpdate() + settings.setLastUpdate(0L) } } } - override fun getAvailableProviders(): List { - val providers = mutableListOf() - if (BrightskyProvider(context).isAvailable()) { - providers.add(WeatherSettings.WeatherProvider.BrightSky) + override fun getProviders(): Flow> { + val providers = mutableListOf() + providers.add(WeatherProviderInfo(BrightSkyProvider.Id, context.getString(R.string.provider_brightsky))) + if (OpenWeatherMapProvider.isAvailable(context)) { + providers.add(WeatherProviderInfo(OpenWeatherMapProvider.Id, context.getString(R.string.provider_openweathermap))) } - if (OpenWeatherMapProvider(context).isAvailable()) { - providers.add(WeatherSettings.WeatherProvider.OpenWeatherMap) + if (MetNoProvider.isAvailable(context)) { + providers.add(WeatherProviderInfo(MetNoProvider.Id, context.getString(R.string.provider_metno))) } - if (MetNoProvider(context).isAvailable()) { - providers.add(WeatherSettings.WeatherProvider.MetNo) + if (HereProvider.isAvailable(context)) { + providers.add(WeatherProviderInfo(HereProvider.Id, context.getString(R.string.provider_here))) } - if (HereProvider(context).isAvailable()) { - providers.add(WeatherSettings.WeatherProvider.Here) - } - return providers + return flowOf(providers) } } class WeatherUpdateWorker(val context: Context, params: WorkerParameters) : CoroutineWorker(context, params), KoinComponent { - val repository: WeatherRepository by inject() + + private val appDatabase: AppDatabase by inject() + private val settings: WeatherSettings by inject() override suspend fun doWork(): Result { - Log.d("MM20", "Requesting weather data") - val providerPref = repository.selectedProvider.first() - val provider: WeatherProvider = get { parametersOf(providerPref) } - if (!provider.isAvailable()) { - Log.d("MM20", "Weather provider is not available") - return Result.failure() - } - if (!provider.isUpdateRequired()) { + Log.d("WeatherUpdateWorker", "Requesting weather data") + val settingsData = settings.data.first() + val provider = WeatherProvider.getInstance(settingsData.provider) + + val updateInterval = provider.updateInterval + val lastUpdate = settingsData.lastUpdate + + if (lastUpdate + updateInterval > System.currentTimeMillis()) { Log.d("MM20", "No weather update required") return Result.failure() } - val weatherData = provider.fetchNewWeatherData() + + val weatherData = if (settingsData.autoLocation) { + val latLon = getLastKnownLocation() ?: settingsData.lastLocation + if (latLon == null) { + Log.e("WeatherUpdateWorker", "Could not get location") + return Result.failure() + } + settings.setLastLocation(latLon) + provider.getWeatherData(latLon.lat, latLon.lon) + } else { + val location = settings.location.first() + if (location == null) { + Log.e("WeatherUpdateWorker", "Location not set") + return Result.failure() + } + provider.getWeatherData(location) + } + return if (weatherData == null) { - Log.d("MM20", "Weather update failed") + Log.w("WeatherUpdateWorker", "Weather update failed") Result.retry() } else { - repository.setLastLocation(provider.getLastLocation()) - Log.d("MM20", "Weather update succeeded") - AppDatabase.getInstance(applicationContext).weatherDao() + Log.i("WeatherUpdateWorker", "Weather update succeeded") + appDatabase.weatherDao() .replaceAll(weatherData.map { it.toDatabaseEntity() }) + settings.setLastUpdate(System.currentTimeMillis()) Result.success() } } + + private fun getLastKnownLocation(): LatLon? { + val lm = context.getSystemService()!! + var location: Location? = null + if (context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { + location = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) + } + if (location == null && context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) { + location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + } + return location?.let { LatLon(it.latitude, it.longitude) } + } } \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/brightsky/BrightskyProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/brightsky/BrightSkyProvider.kt similarity index 76% rename from data/weather/src/main/java/de/mm20/launcher2/weather/brightsky/BrightskyProvider.kt rename to data/weather/src/main/java/de/mm20/launcher2/weather/brightsky/BrightSkyProvider.kt index fa109f15..c2f2ef39 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/brightsky/BrightskyProvider.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/brightsky/BrightSkyProvider.kt @@ -1,23 +1,23 @@ package de.mm20.launcher2.weather.brightsky import android.content.Context -import android.content.SharedPreferences import android.icu.text.SimpleDateFormat import android.icu.util.Calendar +import android.util.Log import de.mm20.launcher2.crashreporter.CrashReporter -import de.mm20.launcher2.weather.* +import de.mm20.launcher2.weather.Forecast +import de.mm20.launcher2.weather.GeocoderWeatherProvider +import de.mm20.launcher2.weather.R +import de.mm20.launcher2.weather.WeatherLocation import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.create -import java.lang.Exception import kotlin.math.roundToInt -class BrightskyProvider( - override val context: Context -) : LatLonWeatherProvider() { - - - val apiClient by lazy { +internal class BrightSkyProvider( + private val context: Context, +) : GeocoderWeatherProvider(context) { + private val apiClient by lazy { val retrofit = Retrofit.Builder() .baseUrl("https://api.brightsky.dev/") .addConverterFactory(GsonConverterFactory.create()) @@ -25,8 +25,27 @@ class BrightskyProvider( retrofit.create() } - override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult? { + override suspend fun getWeatherData(location: WeatherLocation): List? { + return when (location) { + is WeatherLocation.LatLon -> getWeatherData(location.lat, location.lon, location.name) + else -> { + Log.e("BrightSkyProvider", "Unsupported location type: $location") + null + } + } + } + + override suspend fun getWeatherData(lat: Double, lon: Double): List? { + val locationName = getLocationName(lat, lon) + return getWeatherData(lat, lon, locationName) + } + + private suspend fun getWeatherData( + lat: Double, + lon: Double, + locationName: String + ): List? { val result = runCatching { val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") val date = Calendar.getInstance() @@ -37,8 +56,8 @@ class BrightskyProvider( apiClient.weather( date = startDate, lastDate = endDate, - lat = location.lat, - lon = location.lon, + lat = lat, + lon = lon, ) }.getOrElse { CrashReporter.logException(Exception(it)) @@ -55,7 +74,7 @@ class BrightskyProvider( condition = getCondition(weather.icon ?: continue) ?: continue, humidity = weather.relativeHumidity ?: -1.0, icon = getIcon(weather.icon) ?: continue, - location = location.name, + location = locationName, maxTemp = weather.temperature ?: continue, minTemp = weather.temperature, night = (weather.sunshine ?: 100.0).roundToInt() == 0, @@ -71,9 +90,7 @@ class BrightskyProvider( ) ) } - return WeatherUpdateResult( - forecasts, location - ) + return forecasts } private fun getIcon(icon: String): Int? { @@ -109,21 +126,8 @@ class BrightskyProvider( return context.getString(resId) } - override val preferences: SharedPreferences - get() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - - override fun isUpdateRequired(): Boolean { - return getLastUpdate() + 3600000 < System.currentTimeMillis() - } - - override fun isAvailable(): Boolean { - return true - } - - override val name: String - get() = context.getString(R.string.provider_brightsky) - companion object { - const val PREFS = "bright_sky" + internal const val Id = "dwd" } + } \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/here/HereProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/here/HereProvider.kt index def6050d..db612e21 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/here/HereProvider.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/here/HereProvider.kt @@ -1,17 +1,22 @@ package de.mm20.launcher2.weather.here import android.content.Context -import android.content.SharedPreferences +import android.util.Log import de.mm20.launcher2.crashreporter.CrashReporter -import de.mm20.launcher2.weather.* +import de.mm20.launcher2.weather.Forecast +import de.mm20.launcher2.weather.R +import de.mm20.launcher2.weather.WeatherLocation +import de.mm20.launcher2.weather.WeatherProvider import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.create import java.text.ParseException import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale -class HereProvider(override val context: Context) : LatLonWeatherProvider() { +internal class HereProvider( + private val context: Context, +) : WeatherProvider { private val retrofit by lazy { Retrofit.Builder() @@ -24,18 +29,25 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() { retrofit.create() } - override val preferences: SharedPreferences - get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) - - - override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult? { - return loadWeatherData(location.lat, location.lon) + override suspend fun getWeatherData(location: WeatherLocation): List? { + return when (location) { + is WeatherLocation.LatLon -> getWeatherData(location.lat, location.lon, location.name) + else -> { + Log.e("HereProvider", "Unsupported location type: $location") + null + } + } } - override suspend fun loadWeatherData( + override suspend fun getWeatherData(lat: Double, lon: Double): List? { + return getWeatherData(lat, lon, null) + } + + private suspend fun getWeatherData( lat: Double, - lon: Double - ): WeatherUpdateResult? { + lon: Double, + locationName: String? + ): List? { val updateTime = System.currentTimeMillis() val lang = Locale.getDefault().language @@ -44,7 +56,7 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() { val forecastList = mutableListOf() try { - val apiKey = getApiKey() ?: return null + val apiKey = getApiKey(context) ?: return null val response = hereWeatherService.report( apiKey = apiKey, @@ -56,7 +68,7 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() { val forecastLocation = response.hourlyForecasts?.forecastLocation ?: return null val forecasts = forecastLocation.forecast ?: return null - val location = forecastLocation.city ?: return null + val location = locationName ?: forecastLocation.city ?: return null for (forecast in forecasts) { @@ -109,22 +121,37 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() { ) } - return WeatherUpdateResult( - forecasts = forecastList, - location = LatLonWeatherLocation( - name = location, - lat = lat, - lon = lon - ) - ) - - + return forecastList } catch (e: Exception) { CrashReporter.logException(e) return null } } + override suspend fun findLocation(query: String): List { + val retrofit = Retrofit.Builder() + .baseUrl("https://geocoder.ls.hereapi.com/6.2/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + val geocodeService = retrofit.create() + try { + val apiKey = getApiKey(context) ?: return emptyList() + val response = geocodeService.geocode(apiKey, query) + + return response.Response.View?.getOrNull(0)?.Result?.mapNotNull { + WeatherLocation.LatLon( + name = it.Location?.Address?.Label ?: return@mapNotNull null, + lat = it.Location.DisplayPosition?.Latitude ?: return@mapNotNull null, + lon = it.Location.DisplayPosition.Longitude ?: return@mapNotNull null, + ) + } ?: emptyList() + } catch (e: Exception) { + CrashReporter.logException(e) + } + return emptyList() + } + + private fun getIcon(iconName: String): Int { with(Forecast) { @@ -280,53 +307,21 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() { } } - - override fun isUpdateRequired(): Boolean { - return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis() - } - - override suspend fun lookupLocation(query: String): List { - val retrofit = Retrofit.Builder() - .baseUrl("https://geocoder.ls.hereapi.com/6.2/") - .addConverterFactory(GsonConverterFactory.create()) - .build() - val geocodeService = retrofit.create() - try { - val apiKey = getApiKey() ?: return emptyList() - val response = geocodeService.geocode(apiKey, query) - - return response.Response.View?.getOrNull(0)?.Result?.mapNotNull { - LatLonWeatherLocation( - name = it.Location?.Address?.Label ?: return@mapNotNull null, - lat = it.Location.DisplayPosition?.Latitude ?: return@mapNotNull null, - lon = it.Location.DisplayPosition.Longitude ?: return@mapNotNull null, - ) - } ?: emptyList() - } catch (e: Exception) { - CrashReporter.logException(e) - } - return emptyList() - } - - private fun getApiKey(): String? { - val resId = getApiKeyResId() - if (resId != 0) return context.getString(resId) - return null - } - - override fun isAvailable(): Boolean { - return getApiKeyResId() != 0 - } - - - override val name: String - get() = context.getString(R.string.provider_here) - - private fun getApiKeyResId(): Int { - return context.resources.getIdentifier("here_key", "string", context.packageName) - } - companion object { - private const val PREFERENCES = "here" + fun isAvailable(context: Context): Boolean { + return getApiKeyResId(context) != 0 + } + + private fun getApiKey(context: Context): String? { + val resId = getApiKeyResId(context) + if (resId != 0) return context.getString(resId) + return null + } + + private fun getApiKeyResId(context: Context): Int { + return context.resources.getIdentifier("here_key", "string", context.packageName) + } + + internal const val Id = "here" } -} +} \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt index a1bd7ccd..657768c1 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt @@ -1,12 +1,23 @@ package de.mm20.launcher2.weather.metno import android.content.Context -import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build import android.util.Base64 +import android.util.Log +import androidx.annotation.WorkerThread import de.mm20.launcher2.crashreporter.CrashReporter -import de.mm20.launcher2.weather.* +import de.mm20.launcher2.weather.Forecast +import de.mm20.launcher2.weather.GeocoderWeatherProvider +import de.mm20.launcher2.weather.R +import de.mm20.launcher2.weather.WeatherLocation +import de.mm20.launcher2.weather.WeatherProvider +import de.mm20.launcher2.weather.settings.ProviderSettings +import de.mm20.launcher2.weather.settings.WeatherSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONException @@ -15,13 +26,110 @@ import org.shredzone.commons.suncalc.SunTimes import java.io.IOException import java.security.MessageDigest import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale import kotlin.math.roundToInt -class MetNoProvider(override val context: Context) : LatLonWeatherProvider() { +internal class MetNoProvider( + private val context: Context, + private val weatherSettings: WeatherSettings, +): GeocoderWeatherProvider(context) { + override suspend fun getWeatherData(location: WeatherLocation): List? { + return when (location) { + is WeatherLocation.LatLon -> withContext(Dispatchers.IO) { + getWeatherData(location.lat, location.lon, location.name) + } + else -> { + Log.e("MetNoProvider", "Unsupported location type: $location") + null + } + } + } + + override suspend fun getWeatherData(lat: Double, lon: Double): List? { + val locationName = getLocationName(lat, lon) + return withContext(Dispatchers.IO) { + getWeatherData(lat, lon, locationName) + } + } + + @WorkerThread + private suspend fun getWeatherData(lat: Double, lon: Double, locationName: String): List? { + val lastUpdate = weatherSettings.lastUpdate.first() + val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ROOT) + val ifModifiedSince = httpDateFormat.format(Date(lastUpdate)) + try { + val forecasts = mutableListOf() + + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT) + + val httpClient = OkHttpClient() + + val latParam = String.format(Locale.ROOT, "%.4f", lat) + val lonParam = String.format(Locale.ROOT, "%.4f", lon) + + val forecastRequest = Request.Builder() + .url("https://api.met.no/weatherapi/locationforecast/2.0/?lat=$latParam&lon=$lonParam") + .addHeader("User-Agent", getUserAgent() ?: return null) + .addHeader("If-Modified-Since", ifModifiedSince) + .get() + .build() + + val response = httpClient.newCall(forecastRequest).execute() + val responseBody = response.body?.string() ?: return null + + val json = JSONObject(responseBody) + val properties = json.getJSONObject("properties") + val meta = properties.getJSONObject("meta") + val updatedAt = dateFormat.parse(meta.getString("updated_at"))?.time + ?: System.currentTimeMillis() + val timeseries = properties.getJSONArray("timeseries") + + for (i in 0 until timeseries.length()) { + val fc = timeseries.getJSONObject(i) + val data = fc.getJSONObject("data") + val timestamp = dateFormat.parse(fc.getString("time"))?.time ?: continue + val details = data.getJSONObject("instant").getJSONObject("details") + var hours = 0 + val nextHours = data.optJSONObject("next_1_hours")?.also { hours = 1 } + ?: data.optJSONObject("next_6_hours")?.also { hours = 6 } + ?: data.optJSONObject("next_12_hours")?.also { hours = 12 } + ?: continue + val symbolCode = nextHours.optJSONObject("summary")?.getString("symbol_code") + ?: continue + val precipitationAmount = + (nextHours.optJSONObject("details")?.optDouble("precipitation_amount") + ?: 0.0) / hours + forecasts.add( + Forecast( + timestamp = timestamp, + temperature = details.getDouble("air_temperature") + 273.15, + updateTime = updatedAt, + clouds = details.getDouble("cloud_area_fraction").roundToInt(), + humidity = details.getDouble("relative_humidity"), + windDirection = details.getDouble("wind_from_direction"), + windSpeed = details.getDouble("wind_speed"), + pressure = details.getDouble("air_pressure_at_sea_level"), + location = locationName, + provider = context.getString(R.string.provider_metno), + providerUrl = "https://www.yr.no/", + icon = iconForCode(symbolCode), + condition = conditionForCode(symbolCode), + precipitation = precipitationAmount, + night = isNight(timestamp, lat, lon) + + ) + ) + } + return forecasts + } catch (e: JSONException) { + CrashReporter.logException(e) + } catch (e: IOException) { + CrashReporter.logException(e) + } + return null + } - override val preferences: SharedPreferences - get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) private fun isNight(timestamp: Long, lat: Double, lon: Double): Boolean { val sunTimes = SunTimes.compute().on(Date(timestamp)).at(lat, lon).execute() @@ -49,6 +157,37 @@ class MetNoProvider(override val context: Context) : LatLonWeatherProvider() { } + private fun getUserAgent(): String? { + val contactData = getContactInfo() ?: return null + + val signature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val pi = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNING_CERTIFICATES + ) + pi.signingInfo.apkContentsSigners.firstOrNull() + } else { + val pi = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNATURES + ) + pi.signatures.firstOrNull() + } + val signatureHash = if (signature != null) { + val digest = MessageDigest.getInstance("SHA") + digest.update(signature.toByteArray()) + Base64.encodeToString(digest.digest(), Base64.NO_WRAP) + } else "null" + return "${context.packageName}/signature:$signatureHash $contactData" + } + + private fun getContactInfo(): String? { + val resId = getContactResId(context).takeIf { it != 0 } ?: return null + return context.getString(resId).takeIf { it.isNotBlank() } + } + + + private fun conditionForCode(code: String): String { return context.getString( when (code.substringBefore("_")) { @@ -124,132 +263,15 @@ class MetNoProvider(override val context: Context) : LatLonWeatherProvider() { } } - override fun isUpdateRequired(): Boolean { - return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis() - } - - private fun getUserAgent(): String? { - val contactData = getContactInfo() ?: return null - - val signature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val pi = context.packageManager.getPackageInfo( - context.packageName, - PackageManager.GET_SIGNING_CERTIFICATES - ) - pi.signingInfo.apkContentsSigners.firstOrNull() - } else { - val pi = context.packageManager.getPackageInfo( - context.packageName, - PackageManager.GET_SIGNATURES - ) - pi.signatures.firstOrNull() - } - val signatureHash = if (signature != null) { - val digest = MessageDigest.getInstance("SHA") - digest.update(signature.toByteArray()) - Base64.encodeToString(digest.digest(), Base64.NO_WRAP) - } else "null" - return "${context.packageName}/signature:$signatureHash $contactData" - } - - override fun isAvailable(): Boolean { - return getContactResId() != 0 - } - - private fun getContactInfo(): String? { - val resId = getContactResId().takeIf { it != 0 } ?: return null - return context.getString(resId).takeIf { it.isNotBlank() } - } - - - override val name: String - get() = context.getString(R.string.provider_metno) - - private fun getContactResId(): Int { - return context.resources.getIdentifier("metno_contact", "string", context.packageName) - } - - override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult? { - - val lastUpdate = getLastUpdate() - val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ROOT) - val ifModifiedSince = httpDateFormat.format(Date(lastUpdate)) - try { - val forecasts = mutableListOf() - - val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT) - - val httpClient = OkHttpClient() - - val latParam = String.format(Locale.ROOT, "%.4f", location.lat) - val lonParam = String.format(Locale.ROOT, "%.4f", location.lon) - - val forecastRequest = Request.Builder() - .url("https://api.met.no/weatherapi/locationforecast/2.0/?lat=$latParam&lon=$lonParam") - .addHeader("User-Agent", getUserAgent() ?: return null) - .addHeader("If-Modified-Since", ifModifiedSince) - .get() - .build() - - val response = httpClient.newCall(forecastRequest).execute() - val responseBody = response.body?.string() ?: return null - - val json = JSONObject(responseBody) - val properties = json.getJSONObject("properties") - val meta = properties.getJSONObject("meta") - val updatedAt = dateFormat.parse(meta.getString("updated_at"))?.time - ?: System.currentTimeMillis() - val timeseries = properties.getJSONArray("timeseries") - - for (i in 0 until timeseries.length()) { - val fc = timeseries.getJSONObject(i) - val data = fc.getJSONObject("data") - val timestamp = dateFormat.parse(fc.getString("time"))?.time ?: continue - val details = data.getJSONObject("instant").getJSONObject("details") - var hours = 0 - val nextHours = data.optJSONObject("next_1_hours")?.also { hours = 1 } - ?: data.optJSONObject("next_6_hours")?.also { hours = 6 } - ?: data.optJSONObject("next_12_hours")?.also { hours = 12 } - ?: continue - val symbolCode = nextHours.optJSONObject("summary")?.getString("symbol_code") - ?: continue - val precipitationAmount = - (nextHours.optJSONObject("details")?.optDouble("precipitation_amount") - ?: 0.0) / hours - forecasts.add( - Forecast( - timestamp = timestamp, - temperature = details.getDouble("air_temperature") + 273.15, - updateTime = updatedAt, - clouds = details.getDouble("cloud_area_fraction").roundToInt(), - humidity = details.getDouble("relative_humidity"), - windDirection = details.getDouble("wind_from_direction"), - windSpeed = details.getDouble("wind_speed"), - pressure = details.getDouble("air_pressure_at_sea_level"), - location = location.name, - provider = context.getString(R.string.provider_metno), - providerUrl = "https://www.yr.no/", - icon = iconForCode(symbolCode), - condition = conditionForCode(symbolCode), - precipitation = precipitationAmount, - night = isNight(timestamp, location.lat, location.lon) - - ) - ) - } - return WeatherUpdateResult( - forecasts = forecasts, - location = location - ) - } catch (e: JSONException) { - CrashReporter.logException(e) - } catch (e: IOException) { - CrashReporter.logException(e) - } - return null - } - companion object { - private const val PREFERENCES = "metno" + fun isAvailable(context: Context): Boolean { + return getContactResId(context) != 0 + } + + private fun getContactResId(context: Context): Int { + return context.resources.getIdentifier("metno_contact", "string", context.packageName) + } + + internal const val Id = "metno" } } \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/openweathermap/OpenWeatherMapProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/openweathermap/OpenWeatherMapProvider.kt index 504b8624..276b3721 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/openweathermap/OpenWeatherMapProvider.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/openweathermap/OpenWeatherMapProvider.kt @@ -1,24 +1,19 @@ package de.mm20.launcher2.weather.openweathermap import android.content.Context -import android.content.SharedPreferences import android.util.Log -import androidx.core.content.edit import de.mm20.launcher2.crashreporter.CrashReporter -import de.mm20.launcher2.ktx.getDouble -import de.mm20.launcher2.ktx.putDouble import de.mm20.launcher2.weather.Forecast import de.mm20.launcher2.weather.R import de.mm20.launcher2.weather.WeatherLocation import de.mm20.launcher2.weather.WeatherProvider -import de.mm20.launcher2.weather.WeatherUpdateResult import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.Locale - -class OpenWeatherMapProvider(override val context: Context) : - WeatherProvider() { +internal class OpenWeatherMapProvider( + private val context: Context, +): WeatherProvider { private val retrofit by lazy { Retrofit.Builder() @@ -30,78 +25,30 @@ class OpenWeatherMapProvider(override val context: Context) : private val openWeatherMapService by lazy { retrofit.create(OpenWeatherMapApi::class.java) } - - override fun isUpdateRequired(): Boolean { - return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis() - } - - override suspend fun lookupLocation(query: String): List { - - val response = try { - openWeatherMapService.geocode( - appid = getApiKey() ?: return emptyList(), - q = query, - ) - } catch (e: Exception) { - CrashReporter.logException(e) - return emptyList() - } - - // Here, OWM uses the correct language codes, so we don't need to map anything - val lang = Locale.getDefault().language - - return response.mapNotNull { - val name = it.local_names?.get(lang) ?: it.name ?: return@mapNotNull null - OpenWeatherMapLatLonLocation( - name = "$name, ${it.country}", - lat = it.lat ?: return@mapNotNull null, - lon = it.lon ?: return@mapNotNull null, - ) + override suspend fun getWeatherData(location: WeatherLocation): List? { + return when (location) { + is WeatherLocation.LatLon -> getWeatherData(location.lat, location.lon, location.name) + else -> { + Log.e("OpenWeatherMapProvider", "Unsupported location type: $location") + null + } } } - override suspend fun loadWeatherData(location: OpenWeatherMapLocation): WeatherUpdateResult? { - return fetchWeatherData(location = location) + override suspend fun getWeatherData(lat: Double, lon: Double): List? { + return getWeatherData(lat, lon, null) } - override suspend fun loadWeatherData( - lat: Double, - lon: Double - ): WeatherUpdateResult? { - return fetchWeatherData(lat = lat, lon = lon) - } - - private suspend fun fetchWeatherData( - lat: Double? = null, - lon: Double? = null, - location: OpenWeatherMapLocation? = null - ): WeatherUpdateResult? { + private suspend fun getWeatherData(lat: Double, lon: Double, locationName: String?): List? { val lang = getLanguageCode() val currentWeather = try { - when { - location is OpenWeatherMapLatLonLocation -> openWeatherMapService.currentWeather( - appid = getApiKey() ?: return null, - lat = location.lat, - lon = location.lon, - lang = lang, - ) - location is OpenWeatherMapLegacyLocation -> openWeatherMapService.currentWeather( - appid = getApiKey() ?: return null, - id = location.id, - lang = lang, - ) - lat != null && lon != null -> openWeatherMapService.currentWeather( - appid = getApiKey() ?: return null, - lat = lat, - lon = lon, - lang = lang, - ) - else -> { - Log.w("MM20", "OpenWeatherMapProvider returned no data because no location was provided") - return null - } - } + openWeatherMapService.currentWeather( + appid = getApiKey(context) ?: return null, + lat = lat, + lon = lon, + lang = lang, + ) } catch (e: Exception) { CrashReporter.logException(e) return null @@ -114,13 +61,13 @@ class OpenWeatherMapProvider(override val context: Context) : val cityId = currentWeather.id ?: return null val coords = currentWeather.coord ?: return null if (coords.lat == null || coords.lon == null) return null - val loc = location?.name ?: "$city, $country" + val loc = locationName ?: "$city, $country" val forecasts = try { openWeatherMapService.forecast5Day3Hour( lat = coords.lat, lon = coords.lon, - appid = getApiKey() ?: return null, + appid = getApiKey(context) ?: return null, lang = lang ) } catch (e: Exception) { @@ -181,14 +128,31 @@ class OpenWeatherMapProvider(override val context: Context) : ) } ) - return WeatherUpdateResult( - forecasts = forecastList, - location = OpenWeatherMapLatLonLocation( - name = loc, - lat = coords.lat, - lon = coords.lon, + return forecastList + } + + override suspend fun findLocation(query: String): List { + val response = try { + openWeatherMapService.geocode( + appid = getApiKey(context) ?: return emptyList(), + q = query, ) - ) + } catch (e: Exception) { + CrashReporter.logException(e) + return emptyList() + } + + // Here, OWM uses the correct language codes, so we don't need to map anything + val lang = Locale.getDefault().language + + return response.mapNotNull { + val name = it.local_names?.get(lang) ?: it.name ?: return@mapNotNull null + WeatherLocation.LatLon( + name = "$name, ${it.country}", + lat = it.lat ?: return@mapNotNull null, + lon = it.lon ?: return@mapNotNull null, + ) + } } private fun getLanguageCode(): String { @@ -204,22 +168,6 @@ class OpenWeatherMapProvider(override val context: Context) : } } - private fun getApiKey(): String? { - val resId = getApiKeyResId() - if (resId != 0) return context.getString(resId) - return null - } - - override fun isAvailable(): Boolean { - return getApiKeyResId() != 0 - } - - override val name: String - get() = context.getString(R.string.provider_openweathermap) - - private fun getApiKeyResId(): Int { - return context.resources.getIdentifier("openweathermap_key", "string", context.packageName) - } private fun iconForId(id: Int): Int { @@ -248,115 +196,21 @@ class OpenWeatherMapProvider(override val context: Context) : } } - override val preferences: SharedPreferences - get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) - - override fun setLocation(location: WeatherLocation?) { - location as OpenWeatherMapLocation? - preferences.edit { - if (location == null) { - remove(CITY_ID) - remove(LAT) - remove(LON) - remove(LOCATION) - } else { - if (location is OpenWeatherMapLatLonLocation) { - putDouble(LAT, location.lat) - putDouble(LON, location.lon) - putString(LOCATION, location.name) - } else if (location is OpenWeatherMapLegacyLocation) { - putInt(CITY_ID, location.id) - putString(LOCATION, location.name) - } - } - } - } - - override fun getLocation(): OpenWeatherMapLocation? { - val lat = preferences.getDouble(LAT, Double.NaN).takeIf { !it.isNaN() } - val lon = preferences.getDouble(LON, Double.NaN).takeIf { !it.isNaN() } - val name = preferences.getString(LOCATION, null) ?: return null - if (lat != null && lon != null) { - return OpenWeatherMapLatLonLocation( - name = name, - lat = lat, - lon = lon, - ) - } - val id = preferences.getInt(CITY_ID, -1).takeIf { it != -1 } - if (id != null) { - return OpenWeatherMapLegacyLocation( - name = name, - id = id, - ) - } - return null - } - - override fun getLastLocation(): OpenWeatherMapLocation? { - val lat = preferences.getDouble(LAST_LAT, Double.NaN).takeIf { !it.isNaN() } - val lon = preferences.getDouble(LAST_LON, Double.NaN).takeIf { !it.isNaN() } - val name = preferences.getString(LAST_LOCATION, null) ?: return null - if (lat != null && lon != null) { - return OpenWeatherMapLatLonLocation( - name = name, - lat = lat, - lon = lon, - ) - } - val id = preferences.getInt(LAST_CITY_ID, -1).takeIf { it != -1 } - if (id != null) { - return OpenWeatherMapLegacyLocation( - name = name, - id = id, - ) - } - return null - } - - override fun saveLastLocation(location: OpenWeatherMapLocation) { - preferences.edit { - if (location is OpenWeatherMapLatLonLocation) { - putDouble(LAST_LAT, location.lat) - putDouble(LAST_LON, location.lon) - remove(LAST_CITY_ID) - putString(LAST_LOCATION, location.name) - } else if (location is OpenWeatherMapLegacyLocation) { - putInt(LAST_CITY_ID, location.id) - remove(LAST_LAT) - remove(LAST_LON) - putString(LAST_LOCATION, location.name) - } - } - } - companion object { - private const val PREFERENCES = "openweathermap" + fun isAvailable(context: Context): Boolean { + return getApiKeyResId(context) != 0 + } - @Deprecated("Use LAT and LON instead") - private const val CITY_ID = "city_id" + private fun getApiKey(context: Context): String? { + val resId = getApiKeyResId(context) + if (resId != 0) return context.getString(resId) + return null + } - @Deprecated("Use LAST_LAT and LAST_LON instead") - private const val LAST_CITY_ID = "last_city_id" - private const val LAST_UPDATE = "last_update" - private const val LOCATION = "location" - private const val LAT = "lat" - private const val LON = "lon" - private const val LAST_LOCATION = "last_location" - private const val LAST_LAT = "last_lat" - private const val LAST_LON = "last_lon" + private fun getApiKeyResId(context: Context): Int { + return context.resources.getIdentifier("openweathermap_key", "string", context.packageName) + } + + internal const val Id = "owm" } -} - -sealed interface OpenWeatherMapLocation : WeatherLocation - -data class OpenWeatherMapLatLonLocation( - override val name: String, - val lat: Double, - val lon: Double, -) : OpenWeatherMapLocation - -data class OpenWeatherMapLegacyLocation( - override val name: String, - val id: Int, -) : OpenWeatherMapLocation \ No newline at end of file +} \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/settings/WeatherSettings.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/settings/WeatherSettings.kt index 515ea095..6c16bc3e 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/settings/WeatherSettings.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/settings/WeatherSettings.kt @@ -1 +1,119 @@ package de.mm20.launcher2.weather.settings + +import android.content.Context +import de.mm20.launcher2.settings.BaseSettings +import de.mm20.launcher2.weather.WeatherLocation +import de.mm20.launcher2.weather.WeatherProviderInfo +import kotlinx.coroutines.flow.map + +class WeatherSettings( + private val context: Context, +) : BaseSettings( + context, + "weather_settings.json", + WeatherSettingsSerializer, + emptyList(), +) { + internal val data + get() = context.dataStore.data + + val location = data.map { + val providerSettings = it.providerSettings[it.provider] + val id = providerSettings?.locationId + val name = providerSettings?.locationName + + if (id != null && name != null) { + WeatherLocation.Id(name, id) + } else if (it.location != null && it.locationName != null) { + WeatherLocation.LatLon(it.locationName, it.location.lat, it.location.lon) + } else { + null + } + } + + val autoLocation = data.map { it.autoLocation } + + fun setLocation(location: WeatherLocation) { + updateData { + val providerSettings = + it.providerSettings.getOrDefault(it.provider, ProviderSettings()) + when (location) { + is WeatherLocation.LatLon -> { + it.copy( + location = LatLon(lat = location.lat, lon = location.lon), + locationName = location.name, + lastUpdate = 0L, + autoLocation = false, + providerSettings = it.providerSettings.toMutableMap().apply { + put( + it.provider, + providerSettings.copy( + locationId = null, + locationName = null, + ) + ) + } + ) + } + + is WeatherLocation.Id -> { + it.copy( + location = null, + locationName = null, + autoLocation = false, + lastUpdate = 0L, + providerSettings = it.providerSettings.toMutableMap().apply { + put( + it.provider, + providerSettings.copy( + locationId = location.locationId, + locationName = location.name + ) + ) + } + ) + } + } + } + } + + fun setLastLocation(location: LatLon) { + updateData { + it.copy( + lastLocation = location, + ) + } + } + + val lastUpdate = data.map { it.lastUpdate } + + fun setLastUpdate(lastUpdate: Long) { + updateData { + it.copy(lastUpdate = lastUpdate) + } + } + + val providerId = data.map { it.provider } + + fun setProvider(provider: WeatherProviderInfo) { + setProviderId(provider.id) + } + + fun setAutoLocation(autoLocation: Boolean) { + updateData { + it.copy( + autoLocation = autoLocation, + lastUpdate = 0L, + ) + } + } + + fun setProviderId(providerId: String) { + updateData { + it.copy( + provider = providerId, + lastUpdate = 0L, + ) + } + } +} \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/settings/WeatherSettingsData.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/settings/WeatherSettingsData.kt index 124272cd..41e886b5 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/settings/WeatherSettingsData.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/settings/WeatherSettingsData.kt @@ -1,6 +1,15 @@ package de.mm20.launcher2.weather.settings +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream @Serializable @@ -11,17 +20,44 @@ data class LatLon( @Serializable data class ProviderSettings( - val lastUpdate: Long = 0, val locationId: String? = null, val locationName: String? = null, ) @Serializable data class WeatherSettingsData( + val schemaVersion: Int = 1, val provider: String = "metno", val autoLocation: Boolean = true, val location: LatLon? = null, val locationName: String? = null, val lastLocation: LatLon? = null, - val providerSettings: Map -) \ No newline at end of file + val lastUpdate: Long = 0L, + val providerSettings: Map = emptyMap(), +) + +internal object WeatherSettingsSerializer : Serializer{ + internal val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override val defaultValue: WeatherSettingsData + get() = WeatherSettingsData() + + override suspend fun readFrom(input: InputStream): WeatherSettingsData { + try { + return json.decodeFromStream(input) + } catch (e: IllegalArgumentException) { + throw (CorruptionException("Cannot read json.", e)) + } catch (e: SerializationException) { + throw (CorruptionException("Cannot read json.", e)) + } catch (e: IOException) { + throw (CorruptionException("Cannot read json.", e)) + } + } + + override suspend fun writeTo(t: WeatherSettingsData, output: OutputStream) { + json.encodeToStream(t, output) + } +} \ No newline at end of file