Refactor weather module
This commit is contained in:
parent
bba805ccd6
commit
eb1123ab73
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>)
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package de.mm20.launcher2.weather
|
||||
|
||||
data class WeatherProviderInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user