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 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)
}
}

View File

@ -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 }

View File

@ -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"

View File

@ -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" },
)
}
}

View File

@ -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

View File

@ -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<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)
.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()
}
}

View File

@ -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<List<ForecastEntity>>
@Query("SELECT * FROM ${ForecastEntity.TABLE_NAME} ORDER BY timestamp ASC LIMIT :limit")
fun getForecasts(limit: Int = 99999): Flow<List<ForecastEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(forecasts: List<ForecastEntity>)

View File

@ -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"))

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
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<WeatherRepository> { 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> { WeatherSettings(androidContext()) }
factory<Backupable>(named<WeatherSettings>()) { get<WeatherSettings>() }
factory<WeatherProvider> { (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")
}
}
}

View File

@ -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
}

View File

@ -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<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
get() {
return preferences.getBoolean(AUTO_LOCATION, true)
}
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)
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<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
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<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?>
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()
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<out WeatherLocation>
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<List<DailyForecast>>
get() = database.weatherDao().getForecasts()
override fun getForecasts(limit: Int?): Flow<List<Forecast>> {
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 {
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) {
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<WeatherLocation> {
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<List<WeatherLocation>> {
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<Forecast>): List<DailyForecast> {
@ -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<WeatherSettings.WeatherProvider> {
val providers = mutableListOf<WeatherSettings.WeatherProvider>()
if (BrightskyProvider(context).isAvailable()) {
providers.add(WeatherSettings.WeatherProvider.BrightSky)
override fun getProviders(): Flow<List<WeatherProviderInfo>> {
val providers = mutableListOf<WeatherProviderInfo>()
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<out WeatherLocation> = 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<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
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<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 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"
}
}

View File

@ -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<HereWeatherApi>()
}
override val preferences: SharedPreferences
get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult<LatLonWeatherLocation>? {
return loadWeatherData(location.lat, location.lon)
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
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<Forecast>? {
return getWeatherData(lat, lon, null)
}
private suspend fun getWeatherData(
lat: Double,
lon: Double
): WeatherUpdateResult<LatLonWeatherLocation>? {
lon: Double,
locationName: String?
): List<Forecast>? {
val updateTime = System.currentTimeMillis()
val lang = Locale.getDefault().language
@ -44,7 +56,7 @@ class HereProvider(override val context: Context) : LatLonWeatherProvider() {
val forecastList = mutableListOf<Forecast>()
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<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 {
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 {
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
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<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 {
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<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 {
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
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<OpenWeatherMapLocation>() {
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<OpenWeatherMapLocation> {
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<Forecast>? {
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<OpenWeatherMapLocation>? {
return fetchWeatherData(location = location)
override suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>? {
return getWeatherData(lat, lon, null)
}
override suspend fun loadWeatherData(
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>? {
private suspend fun getWeatherData(lat: Double, lon: Double, locationName: String?): List<Forecast>? {
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<WeatherLocation> {
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
}

View File

@ -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<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
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<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)
}
}