Refactor weather module

This commit is contained in:
MM20 2023-12-22 00:25:41 +01:00
parent bba805ccd6
commit eb1123ab73
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
21 changed files with 734 additions and 882 deletions

View File

@ -4,12 +4,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import de.mm20.launcher2.weather.WeatherLocation import de.mm20.launcher2.weather.WeatherLocation
import de.mm20.launcher2.weather.WeatherRepository import de.mm20.launcher2.weather.WeatherRepository
import de.mm20.launcher2.weather.settings.WeatherSettings
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
class WeatherLocationSearchDialogVM: ViewModel(), KoinComponent { class WeatherLocationSearchDialogVM: ViewModel(), KoinComponent {
private val weatherSettings: WeatherSettings by inject()
private val repository: WeatherRepository by inject() private val repository: WeatherRepository by inject()
val isSearchingLocation = mutableStateOf(false) val isSearchingLocation = mutableStateOf(false)
@ -27,7 +30,7 @@ class WeatherLocationSearchDialogVM: ViewModel(), KoinComponent {
debounceSearchJob = launch { debounceSearchJob = launch {
delay(1000) delay(1000)
isSearchingLocation.value = true isSearchingLocation.value = true
locationResults.value = repository.lookupLocation(query) locationResults.value = repository.searchLocations(query).first()
isSearchingLocation.value = false isSearchingLocation.value = false
} }
} }
@ -35,7 +38,6 @@ class WeatherLocationSearchDialogVM: ViewModel(), KoinComponent {
fun setLocation(location: WeatherLocation) { fun setLocation(location: WeatherLocation) {
locationResults.value = emptyList() locationResults.value = emptyList()
repository.setAutoLocation(false) weatherSettings.setLocation(location)
repository.setLocation(location)
} }
} }

View File

@ -9,6 +9,7 @@ import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.weather.DailyForecast import de.mm20.launcher2.weather.DailyForecast
import de.mm20.launcher2.weather.Forecast import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.WeatherRepository import de.mm20.launcher2.weather.WeatherRepository
import de.mm20.launcher2.weather.settings.WeatherSettings
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -21,6 +22,7 @@ import kotlin.math.min
class WeatherWidgetVM : ViewModel(), KoinComponent { class WeatherWidgetVM : ViewModel(), KoinComponent {
private val weatherRepository: WeatherRepository by inject() private val weatherRepository: WeatherRepository by inject()
private val weatherSettings: WeatherSettings by inject()
private val permissionsManager: PermissionsManager by inject() private val permissionsManager: PermissionsManager by inject()
@ -58,7 +60,7 @@ class WeatherWidgetVM : ViewModel(), KoinComponent {
currentForecast.value = getCurrentlySelectedForecast() currentForecast.value = getCurrentlySelectedForecast()
} }
private val forecastsFlow = weatherRepository.forecasts private val forecastsFlow = weatherRepository.getDailyForecasts()
/** /**
* All available forecasts, grouped by day * All available forecasts, grouped by day
@ -106,7 +108,7 @@ class WeatherWidgetVM : ViewModel(), KoinComponent {
fun requestLocationPermission(context: AppCompatActivity) { fun requestLocationPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.Location) permissionsManager.requestPermission(context, PermissionGroup.Location)
} }
val autoLocation = weatherRepository.autoLocation val autoLocation = weatherSettings.autoLocation
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val imperialUnits = dataStore.data.map { it.weather.imperialUnits } val imperialUnits = dataStore.data.map { it.weather.imperialUnits }

View File

@ -18,6 +18,7 @@ import java.security.MessageDigest
fun BuildInfoSettingsScreen() { fun BuildInfoSettingsScreen() {
val viewModel: BuildInfoSettingsScreenVM = viewModel() val viewModel: BuildInfoSettingsScreenVM = viewModel()
val context = LocalContext.current val context = LocalContext.current
val buildFeatures by viewModel.buildFeatures.collectAsState(emptyMap())
PreferenceScreen(title = stringResource(R.string.preference_screen_buildinfo)) { PreferenceScreen(title = stringResource(R.string.preference_screen_buildinfo)) {
item { item {
Preference(title = "Build type", summary = BuildConfig.BUILD_TYPE) Preference(title = "Build type", summary = BuildConfig.BUILD_TYPE)
@ -47,7 +48,7 @@ fun BuildInfoSettingsScreen() {
} }
item { item {
PreferenceCategory(title = "Features") { PreferenceCategory(title = "Features") {
for (feature in viewModel.buildFeatures) { for (feature in buildFeatures) {
Preference( Preference(
title = feature.key, title = feature.key,
summary = if (feature.value) "YES" else "NO" summary = if (feature.value) "YES" else "NO"

View File

@ -5,6 +5,7 @@ import de.mm20.launcher2.accounts.AccountType
import de.mm20.launcher2.accounts.AccountsRepository import de.mm20.launcher2.accounts.AccountsRepository
import de.mm20.launcher2.preferences.Settings.WeatherSettings.WeatherProvider import de.mm20.launcher2.preferences.Settings.WeatherSettings.WeatherProvider
import de.mm20.launcher2.weather.WeatherRepository import de.mm20.launcher2.weather.WeatherRepository
import kotlinx.coroutines.flow.map
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -12,12 +13,14 @@ class BuildInfoSettingsScreenVM : ViewModel(), KoinComponent {
private val accountsRepository: AccountsRepository by inject() private val accountsRepository: AccountsRepository by inject()
private val weatherRepository: WeatherRepository by inject() private val weatherRepository: WeatherRepository by inject()
private val availableWeatherProviders = weatherRepository.getAvailableProviders() private val availableWeatherProviders = weatherRepository.getProviders()
val buildFeatures = mapOf( val buildFeatures = availableWeatherProviders.map {
"Accounts: Google" to accountsRepository.isSupported(AccountType.Google), mapOf(
"Weather providers: HERE" to availableWeatherProviders.contains(WeatherProvider.Here), "Accounts: Google" to accountsRepository.isSupported(AccountType.Google),
"Weather providers: Met No" to availableWeatherProviders.contains(WeatherProvider.MetNo), "Weather providers: HERE" to it.any { it.id == "here" },
"Weather providers: OpenWeatherMap" to availableWeatherProviders.contains(WeatherProvider.OpenWeatherMap), "Weather providers: Met No" to it.any { it.id == "metno" },
) "Weather providers: OpenWeatherMap" to it.any { it.id == "owm" },
)
}
} }

View File

@ -8,20 +8,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.BuildConfig
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.common.WeatherLocationSearchDialog import de.mm20.launcher2.ui.common.WeatherLocationSearchDialog
import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.* import de.mm20.launcher2.ui.component.preferences.*
import de.mm20.launcher2.weather.WeatherLocation import de.mm20.launcher2.weather.WeatherLocation
import de.mm20.launcher2.weather.WeatherProviderInfo
@Composable @Composable
fun WeatherIntegrationSettingsScreen() { fun WeatherIntegrationSettingsScreen() {
val viewModel: WeatherIntegrationSettingsScreenVM = viewModel() val viewModel: WeatherIntegrationSettingsScreenVM = viewModel()
val context = LocalContext.current val context = LocalContext.current
val availableProviders by viewModel.availableProviders.collectAsState(emptyList())
PreferenceScreen( PreferenceScreen(
title = stringResource(R.string.preference_screen_weatherwidget), title = stringResource(R.string.preference_screen_weatherwidget),
helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/integrations/weather" helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/integrations/weather"
@ -31,14 +34,8 @@ fun WeatherIntegrationSettingsScreen() {
val weatherProvider by viewModel.weatherProvider.collectAsState() val weatherProvider by viewModel.weatherProvider.collectAsState()
ListPreference( ListPreference(
title = stringResource(R.string.preference_weather_provider), title = stringResource(R.string.preference_weather_provider),
items = viewModel.availableProviders.map { items = availableProviders.map{
when (it) { it.name to it.id
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
}, },
onValueChanged = { onValueChanged = {
if (it != null) viewModel.setWeatherProvider(it) if (it != null) viewModel.setWeatherProvider(it)
@ -78,7 +75,7 @@ fun WeatherIntegrationSettingsScreen() {
viewModel.setAutoLocation(it) viewModel.setAutoLocation(it)
} }
) )
val location by viewModel.location val location by viewModel.location.collectAsStateWithLifecycle()
LocationPreference( LocationPreference(
title = stringResource(R.string.preference_location), title = stringResource(R.string.preference_location),
value = location, value = location,
@ -104,13 +101,13 @@ fun WeatherIntegrationSettingsScreen() {
@Composable @Composable
fun LocationPreference( fun LocationPreference(
title: String, title: String,
value: WeatherLocation?, value: String?,
enabled: Boolean = true enabled: Boolean = true
) { ) {
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
Preference( Preference(
title = title, title = title,
summary = value?.name, summary = value,
enabled = enabled, enabled = enabled,
onClick = { onClick = {
showDialog = true showDialog = true

View File

@ -7,13 +7,16 @@ import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.WeatherSettings
import de.mm20.launcher2.weather.WeatherLocation import de.mm20.launcher2.weather.WeatherLocation
import de.mm20.launcher2.weather.WeatherProviderInfo
import de.mm20.launcher2.weather.WeatherRepository import de.mm20.launcher2.weather.WeatherRepository
import de.mm20.launcher2.weather.settings.WeatherSettings
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -21,15 +24,16 @@ import org.koin.core.component.inject
class WeatherIntegrationSettingsScreenVM : ViewModel(), KoinComponent { class WeatherIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
private val repository: WeatherRepository by inject() private val repository: WeatherRepository by inject()
private val weatherSettings: WeatherSettings by inject()
private val permissionsManager: PermissionsManager by inject() private val permissionsManager: PermissionsManager by inject()
private val dataStore: LauncherDataStore 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) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setWeatherProvider(provider: WeatherSettings.WeatherProvider) { fun setWeatherProvider(provider: String) {
repository.selectProvider(provider) weatherSettings.setProviderId(provider)
} }
val imperialUnits = dataStore.data.map { it.weather.imperialUnits } 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) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
fun setAutoLocation(autoLocation: Boolean) { fun setAutoLocation(autoLocation: Boolean) {
repository.setAutoLocation(autoLocation) weatherSettings.setAutoLocation(autoLocation)
} }
val location = mutableStateOf<WeatherLocation?>(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) val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .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() { fun clearWeatherData() {
repository.clearForecasts() repository.deleteForecasts()
} }
} }

View File

@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface WeatherDao { interface WeatherDao {
@Query("SELECT * FROM ${ForecastEntity.TABLE_NAME} ORDER BY timestamp ASC") @Query("SELECT * FROM ${ForecastEntity.TABLE_NAME} ORDER BY timestamp ASC LIMIT :limit")
fun getForecasts(): Flow<List<ForecastEntity>> fun getForecasts(limit: Int = 99999): Flow<List<ForecastEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(forecasts: List<ForecastEntity>) fun insertAll(forecasts: List<ForecastEntity>)

View File

@ -45,6 +45,7 @@ dependencies {
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(project(":data:database")) implementation(project(":data:database"))
implementation(project(":core:base"))
implementation(project(":core:ktx")) implementation(project(":core:ktx"))
implementation(project(":core:crashreporter")) implementation(project(":core:crashreporter"))
implementation(project(":core:preferences")) implementation(project(":core:preferences"))

View File

@ -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<WeatherLocation> {
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"
}
}

View File

@ -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<LatLonWeatherLocation>() {
override suspend fun lookupLocation(query: String): List<LatLonWeatherLocation> {
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<LatLonWeatherLocation>? {
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

View File

@ -1,21 +1,26 @@
package de.mm20.launcher2.weather package de.mm20.launcher2.weather
import de.mm20.launcher2.preferences.Settings.WeatherSettings import de.mm20.launcher2.backup.Backupable
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.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider import de.mm20.launcher2.weather.metno.MetNoProvider
import de.mm20.launcher2.weather.openweathermap.OpenWeatherMapProvider import de.mm20.launcher2.weather.openweathermap.OpenWeatherMapProvider
import de.mm20.launcher2.weather.settings.WeatherSettings
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val weatherModule = module { val weatherModule = module {
single<WeatherRepository> { WeatherRepositoryImpl(androidContext(), get(), get()) } single<WeatherRepository> { WeatherRepositoryImpl(androidContext(), get(), get()) }
factory { (selectedProvider: WeatherSettings.WeatherProvider) -> single<WeatherSettings> { WeatherSettings(androidContext()) }
when (selectedProvider) { factory<Backupable>(named<WeatherSettings>()) { get<WeatherSettings>() }
WeatherSettings.WeatherProvider.OpenWeatherMap -> OpenWeatherMapProvider(androidContext()) factory<WeatherProvider> { (providerId: String) ->
WeatherSettings.WeatherProvider.Here -> HereProvider(androidContext()) when (providerId) {
WeatherSettings.WeatherProvider.BrightSky -> BrightskyProvider(androidContext()) OpenWeatherMapProvider.Id -> OpenWeatherMapProvider(androidContext())
else -> MetNoProvider(androidContext()) MetNoProvider.Id -> MetNoProvider(androidContext(), get())
HereProvider.Id -> HereProvider(androidContext())
BrightSkyProvider.Id -> BrightSkyProvider(androidContext())
else -> TODO("Implement plugin provider")
} }
} }
} }

View File

@ -1,5 +1,16 @@
package de.mm20.launcher2.weather package de.mm20.launcher2.weather
interface WeatherLocation { sealed interface WeatherLocation {
val name: String 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
} }

View File

@ -1,111 +1,21 @@
package de.mm20.launcher2.weather package de.mm20.launcher2.weather
import android.Manifest import org.koin.core.component.KoinComponent
import android.content.Context import org.koin.core.component.get
import android.content.SharedPreferences import org.koin.core.parameter.parametersOf
import android.location.Location
import android.location.LocationManager
import androidx.core.content.edit
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.checkPermission
abstract class WeatherProvider<T : WeatherLocation> { 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<Forecast>?
suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>?
suspend fun findLocation(query: String): List<WeatherLocation>
var autoLocation: Boolean companion object: KoinComponent {
get() { internal fun getInstance(providerId: String): WeatherProvider {
return preferences.getBoolean(AUTO_LOCATION, true) return get { parametersOf(providerId) }
} }
set(value) {
preferences.edit {
putBoolean(AUTO_LOCATION, value)
}
}
suspend fun fetchNewWeatherData(): List<Forecast>? {
val result: WeatherUpdateResult<T> = 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<LocationManager>()!!
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<T>?
internal abstract suspend fun loadWeatherData(lat: Double, lon: Double): WeatherUpdateResult<T>?
abstract fun isUpdateRequired(): Boolean
fun getLastUpdate(): Long {
return preferences.getLong(LAST_UPDATE, 0)
}
private fun setLastUpdate(time: Long) {
preferences.edit {
putLong(LAST_UPDATE, time)
}
}
/**
* 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<T>
/**
* @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<T : WeatherLocation>(
val forecasts: List<Forecast>,
val location: T
)

View File

@ -0,0 +1,6 @@
package de.mm20.launcher2.weather
data class WeatherProviderInfo(
val id: String,
val name: String,
)

View File

@ -1,107 +1,70 @@
package de.mm20.launcher2.weather package de.mm20.launcher2.weather
import android.Manifest
import android.content.Context import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.util.Log import android.util.Log
import androidx.core.content.getSystemService
import androidx.work.* import androidx.work.*
import de.mm20.launcher2.database.AppDatabase import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.weather.brightsky.BrightSkyProvider
import de.mm20.launcher2.preferences.Settings.WeatherSettings
import de.mm20.launcher2.weather.brightsky.BrightskyProvider
import de.mm20.launcher2.weather.here.HereProvider import de.mm20.launcher2.weather.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider import de.mm20.launcher2.weather.metno.MetNoProvider
import de.mm20.launcher2.weather.openweathermap.OpenWeatherMapProvider 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.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject import org.koin.core.component.inject
import org.koin.core.parameter.parametersOf
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
interface WeatherRepository { interface WeatherRepository {
val forecasts: Flow<List<DailyForecast>> fun getProviders(): Flow<List<WeatherProviderInfo>>
fun searchLocations(query: String): Flow<List<WeatherLocation>>
suspend fun lookupLocation(query: String): List<WeatherLocation> fun getForecasts(limit: Int? = null): Flow<List<Forecast>>
fun getDailyForecasts(): Flow<List<DailyForecast>>
val lastLocation: Flow<WeatherLocation?> fun deleteForecasts()
val location: Flow<WeatherLocation?>
val autoLocation: Flow<Boolean>
fun setLocation(location: WeatherLocation)
fun setAutoLocation(autoLocation: Boolean)
fun setLastLocation(lastLocation: WeatherLocation?)
fun getAvailableProviders(): List<WeatherSettings.WeatherProvider>
fun selectProvider(provider: WeatherSettings.WeatherProvider)
val selectedProvider: Flow<WeatherSettings.WeatherProvider>
fun clearForecasts()
} }
internal class WeatherRepositoryImpl( internal class WeatherRepositoryImpl(
private val context: Context, private val context: Context,
private val database: AppDatabase, private val database: AppDatabase,
private val dataStore: LauncherDataStore, private val settings: WeatherSettings,
) : WeatherRepository, KoinComponent { ) : WeatherRepository, KoinComponent {
private val scope = CoroutineScope(Job() + Dispatchers.Default) private val scope = CoroutineScope(Job() + Dispatchers.Default)
private var provider: WeatherProvider<out WeatherLocation>
private val permissionsManager: PermissionsManager by inject() private val permissionsManager: PermissionsManager by inject()
private val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location) private val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location)
override val selectedProvider = dataStore.data.map { it.weather.provider }
override val forecasts: Flow<List<DailyForecast>> override fun getForecasts(limit: Int?): Flow<List<Forecast>> {
get() = database.weatherDao().getForecasts() return database.weatherDao().getForecasts(limit ?: 99999)
.map { it.map { Forecast(it) } }
}
override fun getDailyForecasts(): Flow<List<DailyForecast>> {
return database.weatherDao().getForecasts()
.map { it.map { Forecast(it) } } .map { it.map { Forecast(it) } }
.map { .map {
groupForecastsPerDay(it) groupForecastsPerDay(it)
} }
override val lastLocation = MutableStateFlow<WeatherLocation?>(null)
override val location = MutableStateFlow<WeatherLocation?>(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) { override fun searchLocations(query: String): Flow<List<WeatherLocation>> {
provider.autoLocation = autoLocation return settings.data.map {
this.autoLocation.value = autoLocation val provider = WeatherProvider.getInstance(it.provider)
provider.resetLastUpdate() provider.findLocation(query)
requestUpdate()
}
override fun setLastLocation(lastLocation: WeatherLocation?) {
this.lastLocation.value = lastLocation
}
override suspend fun lookupLocation(query: String): List<WeatherLocation> {
return provider.lookupLocation(query)
}
override fun selectProvider(provider: WeatherSettings.WeatherProvider) {
scope.launch {
dataStore.updateData {
it.toBuilder()
.setWeather(
it.weather.toBuilder()
.setProvider(provider)
)
.build()
}
} }
} }
@ -114,35 +77,16 @@ internal class WeatherRepositoryImpl(
ExistingPeriodicWorkPolicy.KEEP, weatherRequest 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 { scope.launch {
hasLocationPermission.collectLatest { hasLocationPermission.collectLatest {
if (it) requestUpdate() if (it) requestUpdate()
} }
} }
scope.launch {
settings.data.collectLatest {
requestUpdate()
}
}
} }
private fun groupForecastsPerDay(forecasts: List<Forecast>): List<DailyForecast> { private fun groupForecastsPerDay(forecasts: List<Forecast>): List<DailyForecast> {
@ -193,59 +137,88 @@ internal class WeatherRepositoryImpl(
WorkManager.getInstance(context).enqueue(weatherRequest) WorkManager.getInstance(context).enqueue(weatherRequest)
} }
override fun clearForecasts() { override fun deleteForecasts() {
scope.launch { scope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.weatherDao().deleteAll() database.weatherDao().deleteAll()
provider.resetLastUpdate() settings.setLastUpdate(0L)
} }
} }
} }
override fun getAvailableProviders(): List<WeatherSettings.WeatherProvider> { override fun getProviders(): Flow<List<WeatherProviderInfo>> {
val providers = mutableListOf<WeatherSettings.WeatherProvider>() val providers = mutableListOf<WeatherProviderInfo>()
if (BrightskyProvider(context).isAvailable()) { providers.add(WeatherProviderInfo(BrightSkyProvider.Id, context.getString(R.string.provider_brightsky)))
providers.add(WeatherSettings.WeatherProvider.BrightSky) if (OpenWeatherMapProvider.isAvailable(context)) {
providers.add(WeatherProviderInfo(OpenWeatherMapProvider.Id, context.getString(R.string.provider_openweathermap)))
} }
if (OpenWeatherMapProvider(context).isAvailable()) { if (MetNoProvider.isAvailable(context)) {
providers.add(WeatherSettings.WeatherProvider.OpenWeatherMap) providers.add(WeatherProviderInfo(MetNoProvider.Id, context.getString(R.string.provider_metno)))
} }
if (MetNoProvider(context).isAvailable()) { if (HereProvider.isAvailable(context)) {
providers.add(WeatherSettings.WeatherProvider.MetNo) providers.add(WeatherProviderInfo(HereProvider.Id, context.getString(R.string.provider_here)))
} }
if (HereProvider(context).isAvailable()) { return flowOf(providers)
providers.add(WeatherSettings.WeatherProvider.Here)
}
return providers
} }
} }
class WeatherUpdateWorker(val context: Context, params: WorkerParameters) : class WeatherUpdateWorker(val context: Context, params: WorkerParameters) :
CoroutineWorker(context, params), KoinComponent { 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 { override suspend fun doWork(): Result {
Log.d("MM20", "Requesting weather data") Log.d("WeatherUpdateWorker", "Requesting weather data")
val providerPref = repository.selectedProvider.first() val settingsData = settings.data.first()
val provider: WeatherProvider<out WeatherLocation> = get { parametersOf(providerPref) } val provider = WeatherProvider.getInstance(settingsData.provider)
if (!provider.isAvailable()) {
Log.d("MM20", "Weather provider is not available") val updateInterval = provider.updateInterval
return Result.failure() val lastUpdate = settingsData.lastUpdate
}
if (!provider.isUpdateRequired()) { if (lastUpdate + updateInterval > System.currentTimeMillis()) {
Log.d("MM20", "No weather update required") Log.d("MM20", "No weather update required")
return Result.failure() 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) { return if (weatherData == null) {
Log.d("MM20", "Weather update failed") Log.w("WeatherUpdateWorker", "Weather update failed")
Result.retry() Result.retry()
} else { } else {
repository.setLastLocation(provider.getLastLocation()) Log.i("WeatherUpdateWorker", "Weather update succeeded")
Log.d("MM20", "Weather update succeeded") appDatabase.weatherDao()
AppDatabase.getInstance(applicationContext).weatherDao()
.replaceAll(weatherData.map { it.toDatabaseEntity() }) .replaceAll(weatherData.map { it.toDatabaseEntity() })
settings.setLastUpdate(System.currentTimeMillis())
Result.success() Result.success()
} }
} }
private fun getLastKnownLocation(): LatLon? {
val lm = context.getSystemService<LocationManager>()!!
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) }
}
} }

View File

@ -1,23 +1,23 @@
package de.mm20.launcher2.weather.brightsky package de.mm20.launcher2.weather.brightsky
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.icu.text.SimpleDateFormat import android.icu.text.SimpleDateFormat
import android.icu.util.Calendar import android.icu.util.Calendar
import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter 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.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create import retrofit2.create
import java.lang.Exception
import kotlin.math.roundToInt import kotlin.math.roundToInt
class BrightskyProvider( internal class BrightSkyProvider(
override val context: Context private val context: Context,
) : LatLonWeatherProvider() { ) : GeocoderWeatherProvider(context) {
private val apiClient by lazy {
val apiClient by lazy {
val retrofit = Retrofit.Builder() val retrofit = Retrofit.Builder()
.baseUrl("https://api.brightsky.dev/") .baseUrl("https://api.brightsky.dev/")
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
@ -25,8 +25,27 @@ class BrightskyProvider(
retrofit.create<BrightSkyApi>() retrofit.create<BrightSkyApi>()
} }
override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult<LatLonWeatherLocation>? {
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
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<Forecast>? {
val locationName = getLocationName(lat, lon)
return getWeatherData(lat, lon, locationName)
}
private suspend fun getWeatherData(
lat: Double,
lon: Double,
locationName: String
): List<Forecast>? {
val result = runCatching { val result = runCatching {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
val date = Calendar.getInstance() val date = Calendar.getInstance()
@ -37,8 +56,8 @@ class BrightskyProvider(
apiClient.weather( apiClient.weather(
date = startDate, date = startDate,
lastDate = endDate, lastDate = endDate,
lat = location.lat, lat = lat,
lon = location.lon, lon = lon,
) )
}.getOrElse { }.getOrElse {
CrashReporter.logException(Exception(it)) CrashReporter.logException(Exception(it))
@ -55,7 +74,7 @@ class BrightskyProvider(
condition = getCondition(weather.icon ?: continue) ?: continue, condition = getCondition(weather.icon ?: continue) ?: continue,
humidity = weather.relativeHumidity ?: -1.0, humidity = weather.relativeHumidity ?: -1.0,
icon = getIcon(weather.icon) ?: continue, icon = getIcon(weather.icon) ?: continue,
location = location.name, location = locationName,
maxTemp = weather.temperature ?: continue, maxTemp = weather.temperature ?: continue,
minTemp = weather.temperature, minTemp = weather.temperature,
night = (weather.sunshine ?: 100.0).roundToInt() == 0, night = (weather.sunshine ?: 100.0).roundToInt() == 0,
@ -71,9 +90,7 @@ class BrightskyProvider(
) )
) )
} }
return WeatherUpdateResult( return forecasts
forecasts, location
)
} }
private fun getIcon(icon: String): Int? { private fun getIcon(icon: String): Int? {
@ -109,21 +126,8 @@ class BrightskyProvider(
return context.getString(resId) 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 { companion object {
const val PREFS = "bright_sky" internal const val Id = "dwd"
} }
} }

View File

@ -1,17 +1,22 @@
package de.mm20.launcher2.weather.here package de.mm20.launcher2.weather.here
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter 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.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create import retrofit2.create
import java.text.ParseException import java.text.ParseException
import java.text.SimpleDateFormat 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 { private val retrofit by lazy {
Retrofit.Builder() Retrofit.Builder()
@ -24,18 +29,25 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() {
retrofit.create<HereWeatherApi>() retrofit.create<HereWeatherApi>()
} }
override val preferences: SharedPreferences override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) return when (location) {
is WeatherLocation.LatLon -> getWeatherData(location.lat, location.lon, location.name)
else -> {
override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult<LatLonWeatherLocation>? { Log.e("HereProvider", "Unsupported location type: $location")
return loadWeatherData(location.lat, location.lon) null
}
}
} }
override suspend fun loadWeatherData( override suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>? {
return getWeatherData(lat, lon, null)
}
private suspend fun getWeatherData(
lat: Double, lat: Double,
lon: Double lon: Double,
): WeatherUpdateResult<LatLonWeatherLocation>? { locationName: String?
): List<Forecast>? {
val updateTime = System.currentTimeMillis() val updateTime = System.currentTimeMillis()
val lang = Locale.getDefault().language val lang = Locale.getDefault().language
@ -44,7 +56,7 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() {
val forecastList = mutableListOf<Forecast>() val forecastList = mutableListOf<Forecast>()
try { try {
val apiKey = getApiKey() ?: return null val apiKey = getApiKey(context) ?: return null
val response = hereWeatherService.report( val response = hereWeatherService.report(
apiKey = apiKey, apiKey = apiKey,
@ -56,7 +68,7 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() {
val forecastLocation = response.hourlyForecasts?.forecastLocation ?: return null val forecastLocation = response.hourlyForecasts?.forecastLocation ?: return null
val forecasts = forecastLocation.forecast ?: 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) { for (forecast in forecasts) {
@ -109,22 +121,37 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() {
) )
} }
return WeatherUpdateResult( return forecastList
forecasts = forecastList,
location = LatLonWeatherLocation(
name = location,
lat = lat,
lon = lon
)
)
} catch (e: Exception) { } catch (e: Exception) {
CrashReporter.logException(e) CrashReporter.logException(e)
return null return null
} }
} }
override suspend fun findLocation(query: String): List<WeatherLocation> {
val retrofit = Retrofit.Builder()
.baseUrl("https://geocoder.ls.hereapi.com/6.2/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val geocodeService = retrofit.create<HereGeocodeApi>()
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 { private fun getIcon(iconName: String): Int {
with(Forecast) { 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<LatLonWeatherLocation> {
val retrofit = Retrofit.Builder()
.baseUrl("https://geocoder.ls.hereapi.com/6.2/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val geocodeService = retrofit.create<HereGeocodeApi>()
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 { 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"
} }
} }

View File

@ -1,12 +1,23 @@
package de.mm20.launcher2.weather.metno package de.mm20.launcher2.weather.metno
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.Log
import androidx.annotation.WorkerThread
import de.mm20.launcher2.crashreporter.CrashReporter 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.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONException import org.json.JSONException
@ -15,13 +26,110 @@ import org.shredzone.commons.suncalc.SunTimes
import java.io.IOException import java.io.IOException
import java.security.MessageDigest import java.security.MessageDigest
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt 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<Forecast>? {
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<Forecast>? {
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<Forecast>? {
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<Forecast>()
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 { private fun isNight(timestamp: Long, lat: Double, lon: Double): Boolean {
val sunTimes = SunTimes.compute().on(Date(timestamp)).at(lat, lon).execute() 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 { private fun conditionForCode(code: String): String {
return context.getString( return context.getString(
when (code.substringBefore("_")) { 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<LatLonWeatherLocation>? {
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<Forecast>()
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 { 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"
} }
} }

View File

@ -1,24 +1,19 @@
package de.mm20.launcher2.weather.openweathermap package de.mm20.launcher2.weather.openweathermap
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.core.content.edit
import de.mm20.launcher2.crashreporter.CrashReporter 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.Forecast
import de.mm20.launcher2.weather.R import de.mm20.launcher2.weather.R
import de.mm20.launcher2.weather.WeatherLocation import de.mm20.launcher2.weather.WeatherLocation
import de.mm20.launcher2.weather.WeatherProvider import de.mm20.launcher2.weather.WeatherProvider
import de.mm20.launcher2.weather.WeatherUpdateResult
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.util.Locale import java.util.Locale
internal class OpenWeatherMapProvider(
class OpenWeatherMapProvider(override val context: Context) : private val context: Context,
WeatherProvider<OpenWeatherMapLocation>() { ): WeatherProvider {
private val retrofit by lazy { private val retrofit by lazy {
Retrofit.Builder() Retrofit.Builder()
@ -30,78 +25,30 @@ class OpenWeatherMapProvider(override val context: Context) :
private val openWeatherMapService by lazy { private val openWeatherMapService by lazy {
retrofit.create(OpenWeatherMapApi::class.java) retrofit.create(OpenWeatherMapApi::class.java)
} }
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
override fun isUpdateRequired(): Boolean { return when (location) {
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis() is WeatherLocation.LatLon -> getWeatherData(location.lat, location.lon, location.name)
} else -> {
Log.e("OpenWeatherMapProvider", "Unsupported location type: $location")
override suspend fun lookupLocation(query: String): List<OpenWeatherMapLocation> { null
}
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 loadWeatherData(location: OpenWeatherMapLocation): WeatherUpdateResult<OpenWeatherMapLocation>? { override suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>? {
return fetchWeatherData(location = location) return getWeatherData(lat, lon, null)
} }
override suspend fun loadWeatherData( private suspend fun getWeatherData(lat: Double, lon: Double, locationName: String?): List<Forecast>? {
lat: Double,
lon: Double
): WeatherUpdateResult<OpenWeatherMapLocation>? {
return fetchWeatherData(lat = lat, lon = lon)
}
private suspend fun fetchWeatherData(
lat: Double? = null,
lon: Double? = null,
location: OpenWeatherMapLocation? = null
): WeatherUpdateResult<OpenWeatherMapLocation>? {
val lang = getLanguageCode() val lang = getLanguageCode()
val currentWeather = try { val currentWeather = try {
when { openWeatherMapService.currentWeather(
location is OpenWeatherMapLatLonLocation -> openWeatherMapService.currentWeather( appid = getApiKey(context) ?: return null,
appid = getApiKey() ?: return null, lat = lat,
lat = location.lat, lon = lon,
lon = location.lon, lang = lang,
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
}
}
} catch (e: Exception) { } catch (e: Exception) {
CrashReporter.logException(e) CrashReporter.logException(e)
return null return null
@ -114,13 +61,13 @@ class OpenWeatherMapProvider(override val context: Context) :
val cityId = currentWeather.id ?: return null val cityId = currentWeather.id ?: return null
val coords = currentWeather.coord ?: return null val coords = currentWeather.coord ?: return null
if (coords.lat == null || coords.lon == null) 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 { val forecasts = try {
openWeatherMapService.forecast5Day3Hour( openWeatherMapService.forecast5Day3Hour(
lat = coords.lat, lat = coords.lat,
lon = coords.lon, lon = coords.lon,
appid = getApiKey() ?: return null, appid = getApiKey(context) ?: return null,
lang = lang lang = lang
) )
} catch (e: Exception) { } catch (e: Exception) {
@ -181,14 +128,31 @@ class OpenWeatherMapProvider(override val context: Context) :
) )
} }
) )
return WeatherUpdateResult( return forecastList
forecasts = forecastList, }
location = OpenWeatherMapLatLonLocation(
name = loc, override suspend fun findLocation(query: String): List<WeatherLocation> {
lat = coords.lat, val response = try {
lon = coords.lon, 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 { 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 { 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 { companion object {
private const val PREFERENCES = "openweathermap" fun isAvailable(context: Context): Boolean {
return getApiKeyResId(context) != 0
}
@Deprecated("Use LAT and LON instead") private fun getApiKey(context: Context): String? {
private const val CITY_ID = "city_id" val resId = getApiKeyResId(context)
if (resId != 0) return context.getString(resId)
return null
}
@Deprecated("Use LAST_LAT and LAST_LON instead") private fun getApiKeyResId(context: Context): Int {
private const val LAST_CITY_ID = "last_city_id" return context.resources.getIdentifier("openweathermap_key", "string", context.packageName)
private const val LAST_UPDATE = "last_update" }
private const val LOCATION = "location"
private const val LAT = "lat" internal const val Id = "owm"
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"
} }
} }
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

View File

@ -1 +1,119 @@
package de.mm20.launcher2.weather.settings 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<WeatherSettingsData>(
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,
)
}
}
}

View File

@ -1,6 +1,15 @@
package de.mm20.launcher2.weather.settings package de.mm20.launcher2.weather.settings
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import kotlinx.serialization.Serializable 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 @Serializable
@ -11,17 +20,44 @@ data class LatLon(
@Serializable @Serializable
data class ProviderSettings( data class ProviderSettings(
val lastUpdate: Long = 0,
val locationId: String? = null, val locationId: String? = null,
val locationName: String? = null, val locationName: String? = null,
) )
@Serializable @Serializable
data class WeatherSettingsData( data class WeatherSettingsData(
val schemaVersion: Int = 1,
val provider: String = "metno", val provider: String = "metno",
val autoLocation: Boolean = true, val autoLocation: Boolean = true,
val location: LatLon? = null, val location: LatLon? = null,
val locationName: String? = null, val locationName: String? = null,
val lastLocation: LatLon? = null, val lastLocation: LatLon? = null,
val providerSettings: Map<String, ProviderSettings> val lastUpdate: Long = 0L,
val providerSettings: Map<String, ProviderSettings> = emptyMap(),
) )
internal object WeatherSettingsSerializer : Serializer<WeatherSettingsData>{
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)
}
}