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