6 Commits

Author SHA1 Message Date
61f3424de5 Stop the weather worker pinning location for providers that manage it
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Breezy Weather resolves its own location and pushes weather updates, but
WeatherUpdateWorker only looked at autoLocation before requesting a location.
BreezyWeatherProvider.getWeatherData() is a noop that always returns null, so
the worker then returned Result.retry() forever and never set lastUpdate -
which meant the update interval check could never short-circuit it either.

Every attempt called getLastKnownLocation(), which registers GPS *and* network
listeners at a one second interval and held them for up to ten minutes, so the
location stack never idled. On the phone this showed up as ~46k delivered fixes,
`*location*` wakelocks held ~99% of the time and Doze never engaging at all.

- expose the selected provider's managedLocation in WeatherSettingsData
- skip and cancel the weather work for providers that manage their own location
- give up on a fix after two minutes instead of ten
- LocationsRepository: don't collect the location flow when location search is
  off or not permitted. As a combineTransform argument it was always collected,
  so every search query registered GPS and network listeners for up to 30s.

Bump to 1.40.2-typing.8 (versionCode 2026091201).
2026-09-12 15:19:10 +02:00
45ac29e8a7 Document the desktop's actual build setup and the release procedure
Some checks failed
Build Nightly APK / build (push) Has been cancelled
The desktop (jonas@192.168.1.86) has no ~/jdk21 and no ~/android-sdk; its
checkout is ~/sources/Kvaesitso, the SDK is ~/Android/Sdk and it builds with
Arch's java-21-openjdk. Also record how the APK gets published, since that
was only in the release checklist of these notes.
2026-09-11 21:31:02 +02:00
291752abcd Bump version to 1.40.2-typing.7
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
The clock widget's agenda part is immediate and reliable now: its query is
no longer held back by the repository's 500 ms debounce, collectors no longer
receive an empty list before the provider has run, and the parts are not
rebuilt (losing their cached data) every time the launcher leaves the
background.
2026-09-11 21:21:28 +02:00
4836618609 Stop calendar queries emitting an empty list before they have run
`queryCalendarEvents` forwarded the `MutableStateFlow` it accumulates
the provider results in, so the initial empty value was the first thing
collectors saw - before any provider had reported. Callers in the
launcher take that first result (`CalendarPartProvider` uses `.first()`)
and cannot tell it apart from "no events", so the agenda stayed empty
and its part never showed up.

`CalendarRepository.findMany`'s 500 ms debounce used to hide this: the
placeholder was superseded by the real list within the debounce window,
which is exactly why the agenda only ever appeared half a second late.

Keep the accumulator null until the first provider reports, and let
`findMany` - a one-shot query for the clock and calendar widgets - wait
for all providers (`awaitAllProviders`) so it never hands out a partial
list. The search path keeps streaming results as providers report, now
without an empty flash.
2026-09-11 21:17:57 +02:00
c8109d9197 Keep clock widget parts alive across lifecycle stops
`partProviders` was a `stateIn(WhileSubscribed)` flow, so its upstream
map ran again every time the clock widget was subscribed after the
launcher had been in the background - and built new part providers.
Providers cache what their ranking is based on, so the new agenda part
started at ranking 0, vanished while the launcher was gone and only came
back once its query had run again.

Build the provider list in `viewModelScope` into a `MutableStateFlow`
instead, so the instances (and their cached state) survive for as long
as the ViewModel does. Their ranking flows are still only collected
while the widget is subscribed, so observers are still registered and
unregistered with the widget's lifecycle.
2026-09-11 21:01:03 +02:00
3b3d6473ca Don't debounce the agenda query in the clock widget
`CalendarRepository.findMany` holds every result back by 500 ms, which
was added for the calendar widget to collapse bursts of plugin and
permission changes. `debounce` delays the first result too, so the
agenda part - whose ranking stays 0 until the first result arrives -
only appeared half a second after the clock and the other parts.

The agenda is re-queried on a calendar change or every 15 minutes, so
it does not need the debounce. Make the delay a parameter (still 500 ms
by default) and pass 0 from `CalendarPartProvider`.
2026-09-11 21:01:00 +02:00
8 changed files with 183 additions and 74 deletions

View File

@@ -87,7 +87,7 @@ state in `ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt`. New strings a
On the desktop: On the desktop:
JAVA_HOME=~/jdk21 ANDROID_HOME=~/android-sdk \ JAVA_HOME=/usr/lib/jvm/java-21-openjdk ANDROID_HOME=~/Android/Sdk \
./gradlew :app:app:assembleDefaultRelease ./gradlew :app:app:assembleDefaultRelease
Result: `app/app/build/outputs/apk/default/release/app-default-release.apk`, applicationId Result: `app/app/build/outputs/apk/default/release/app-default-release.apk`, applicationId
@@ -99,6 +99,21 @@ in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`
`~/android-keystores/kvaesitso-release.jks`; the phone has its own copy under `~/android-keystores/kvaesitso-release.jks`; the phone has its own copy under
`~/android-keystores/` in the Termux home. `~/android-keystores/` in the Termux home.
The desktop's checkout (jonas@192.168.1.86) is `~/sources/Kvaesitso`. Its SDK is `~/Android/Sdk`
(platforms android-35/37.0, build-tools 34/36/37), so `local.properties` there has
`sdk.dir=/home/jonas/Android/Sdk`, and `keystore.properties` points at the copy of the release key
in `~/android-keystores/`. It has no `~/jdk21`; it uses Arch's `java-21-openjdk`.
After the APK is built (on either machine), the release is tagged and published from the phone:
git tag -a v1.40.2-typing.N -m "Kvaesitso 1.40.2 type-to-search and agenda, Nth build"
git push origin main v1.40.2-typing.N
and a Gitea release for that tag is created with the APK attached as
`Kvaesitso-v<versionName>-Signed.apk` (the token is in `~/.config/gitea/token`; the API is
`POST /api/v1/repos/jonas/Kvaesitso/releases` and then `.../releases/<id>/assets?name=<asset>` with
`-F attachment=@<apk>`).
### Building on the phone itself (Termux) ### Building on the phone itself (Termux)
The phone builds this project without a desktop. `scripts/ondevice.sh` wraps the loop: The phone builds this project without a desktop. `scripts/ondevice.sh` wraps the loop:

View File

@@ -34,8 +34,8 @@ android {
applicationId = "de.mm20.launcher2" applicationId = "de.mm20.launcher2"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091005 versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091201
versionName = "1.40.2-typing.6" versionName = "1.40.2-typing.8"
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }

View File

@@ -17,33 +17,47 @@ import de.mm20.launcher2.ui.launcher.widgets.clock.parts.MusicPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.PartProvider import de.mm20.launcher2.ui.launcher.widgets.clock.parts.PartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.SmartspacerPartProvider import de.mm20.launcher2.ui.launcher.widgets.clock.parts.SmartspacerPartProvider
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
class ClockWidgetVM : ViewModel(), KoinComponent { class ClockWidgetVM : ViewModel(), KoinComponent {
private val settings: ClockWidgetSettings by inject() private val settings: ClockWidgetSettings by inject()
private val partProviders = settings.parts.combine(settings.useSmartspacer) { p, s -> /**
p to s * Kept for the lifetime of the ViewModel instead of being rebuilt whenever the clock widget
}.map { (parts, smartspacer) -> * is subscribed again - which happens on every return to the home screen, because the
if (smartspacer && isAtLeastApiLevel(29)) { * launcher stops while it is in the background. Providers cache the data behind their
return@map listOf(SmartspacerPartProvider()) * ranking, so a fresh instance starts at ranking 0 and makes its part disappear until its
} * query returns; the agenda part would blink on every visit that way.
*/
private val partProviders = MutableStateFlow<List<PartProvider>>(emptyList())
val providers = mutableListOf<PartProvider>() init {
if (parts.date) providers += DatePartProvider() viewModelScope.launch {
if (parts.calendar) providers += CalendarPartProvider() settings.parts.combine(settings.useSmartspacer) { p, s -> p to s }
if (parts.music) providers += MusicPartProvider() .collect { (parts, smartspacer) ->
providers += BatteryPartProvider(parts.battery) partProviders.value = if (smartspacer && isAtLeastApiLevel(29)) {
if (parts.alarm) providers += AlarmPartProvider() listOf(SmartspacerPartProvider())
providers } else {
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) buildList {
if (parts.date) add(DatePartProvider())
if (parts.calendar) add(CalendarPartProvider())
if (parts.music) add(MusicPartProvider())
add(BatteryPartProvider(parts.battery))
if (parts.alarm) add(AlarmPartProvider())
}
}
}
}
}
fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow { fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow {
partProviders.collectLatest { providers -> partProviders.collectLatest { providers ->

View File

@@ -122,6 +122,10 @@ class CalendarPartProvider : PartProvider, KoinComponent {
from = now, from = now,
to = endOfDay(now), to = endOfDay(now),
excludeCalendars = excludedCalendars.toList(), excludeCalendars = excludedCalendars.toList(),
// No debounce: the ranking stays 0 until the first result arrives, and
// the query is only re-run on a calendar change or every 15 minutes, so
// holding the result back would only delay the part's appearance.
debounceMillis = 0,
).first() ).first()
.filter { !it.isTask } .filter { !it.isTask }
.sortedBy { it.startTime ?: it.endTime } .sortedBy { it.startTime ?: it.endTime }

View File

@@ -38,6 +38,13 @@ data class WeatherSettingsData(
val lastLocation: LatLon? = null, val lastLocation: LatLon? = null,
val lastUpdate: Long = 0L, val lastUpdate: Long = 0L,
val providerSettings: Map<String, ProviderSettings> = emptyMap(), val providerSettings: Map<String, ProviderSettings> = emptyMap(),
/**
* Whether the selected provider resolves its own location (e.g. Breezy Weather, which is
* configured with a [WeatherLocation.Managed]). The launcher must then not request a location
* for it.
*/
val managedLocation: Boolean = false,
) )
class WeatherSettings internal constructor( class WeatherSettings internal constructor(
@@ -53,6 +60,7 @@ class WeatherSettings internal constructor(
lastLocation = it.weatherLastLocation, lastLocation = it.weatherLastLocation,
lastUpdate = it.weatherLastUpdate, lastUpdate = it.weatherLastUpdate,
providerSettings = it.weatherProviderSettings, providerSettings = it.weatherProviderSettings,
managedLocation = it.weatherProviderSettings[it.weatherProvider]?.managedLocation == true,
) )
}.distinctUntilChanged() }.distinctUntilChanged()
) { ) {

View File

@@ -22,20 +22,28 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
interface CalendarRepository : SearchableRepository<CalendarEvent> { interface CalendarRepository : SearchableRepository<CalendarEvent> {
/**
* @param debounceMillis how long each result is held back, so bursts of upstream changes
* (plugin or permission updates) cause a single query. [debounce] delays the first result
* too, so callers that must show something immediately pass 0.
*/
fun findMany( fun findMany(
from: Long = System.currentTimeMillis(), from: Long = System.currentTimeMillis(),
to: Long = from + 14 * 24 * 60 * 60 * 1000L, to: Long = from + 14 * 24 * 60 * 60 * 1000L,
excludeCalendars: List<String> = emptyList(), excludeCalendars: List<String> = emptyList(),
excludeAllDayEvents: Boolean = false, excludeAllDayEvents: Boolean = false,
debounceMillis: Long = 500,
): Flow<ImmutableList<CalendarEvent>> ): Flow<ImmutableList<CalendarEvent>>
fun getCalendars(providerId: String? = null): Flow<List<CalendarList>> fun getCalendars(providerId: String? = null): Flow<List<CalendarList>>
@@ -94,6 +102,7 @@ internal class CalendarRepositoryImpl(
to: Long, to: Long,
excludeCalendars: List<String>, excludeCalendars: List<String>,
excludeAllDayEvents: Boolean, excludeAllDayEvents: Boolean,
debounceMillis: Long,
): Flow<ImmutableList<CalendarEvent>> { ): Flow<ImmutableList<CalendarEvent>> {
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks) val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
@@ -112,17 +121,17 @@ internal class CalendarRepositoryImpl(
) )
} }
emitAll( val events = queryCalendarEvents(
queryCalendarEvents( query = null,
query = null, intervalStart = from,
intervalStart = from, intervalEnd = to,
intervalEnd = to, excludeAllDayEvents = excludeAllDayEvents,
excludeAllDayEvents = excludeAllDayEvents, excludeCalendars = excludeCalendars,
excludeCalendars = excludeCalendars, providers = providers,
providers = providers, awaitAllProviders = true,
allowNetwork = false, allowNetwork = false,
).debounce(500)
) )
emitAll(if (debounceMillis > 0L) events.debounce(debounceMillis) else events)
} }
} }
@@ -133,12 +142,17 @@ internal class CalendarRepositoryImpl(
excludeAllDayEvents: Boolean = false, excludeAllDayEvents: Boolean = false,
excludeCalendars: List<String> = emptyList(), excludeCalendars: List<String> = emptyList(),
allowNetwork: Boolean = false, allowNetwork: Boolean = false,
awaitAllProviders: Boolean = false,
providers: List<CalendarProvider>, providers: List<CalendarProvider>,
): Flow<ImmutableList<CalendarEvent>> = flow { ): Flow<ImmutableList<CalendarEvent>> = flow {
supervisorScope { supervisorScope {
val result = MutableStateFlow(persistentListOf<CalendarEvent>()) // Null until the first provider has reported. Emitting the (empty) accumulator right
// away would hand collectors an empty list before any query had run, which callers
// that take the first result - like the agenda in the clock widget - cannot tell
// apart from "no events".
val result = MutableStateFlow<ImmutableList<CalendarEvent>?>(null)
for (provider in providers) { val jobs = providers.map { provider ->
launch { launch {
val r = provider.search( val r = provider.search(
query, query,
@@ -152,11 +166,19 @@ internal class CalendarRepositoryImpl(
allowNetwork = allowNetwork, allowNetwork = allowNetwork,
) )
result.update { result.update {
(it + r).toPersistentList() ((it ?: persistentListOf()) + r).toPersistentList()
} }
} }
} }
emitAll(result)
if (awaitAllProviders) {
// One-shot callers must not receive a partial list, so wait until every
// provider has reported; if there is none, report an empty result.
jobs.joinAll()
emit(result.value ?: persistentListOf())
} else {
emitAll(result.filterNotNull())
}
} }
} }

View File

@@ -12,11 +12,15 @@ import de.mm20.launcher2.search.SearchableRepository
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.flow.timeout
@@ -32,7 +36,7 @@ internal class LocationsRepository(
private val permissionsManager: PermissionsManager, private val permissionsManager: PermissionsManager,
) : SearchableRepository<Location> { ) : SearchableRepository<Location> {
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
override fun search( override fun search(
query: String, query: String,
allowNetwork: Boolean allowNetwork: Boolean
@@ -40,50 +44,71 @@ internal class LocationsRepository(
if (query.isBlank() || query.length <= 1) { if (query.isBlank() || query.length <= 1) {
return flowOf(persistentListOf()) return flowOf(persistentListOf())
} }
return combineTransform( return combine(
poseProvider
.getLocation(minTimeMs = 2000, minDistanceM = 50.0f)
// 1st location: lastCachedLocation of poseProvider, if available
// 2nd location: LocationManager.getLastKnownLocation(), if available and better than lastCachedLocation
// 3rd location: live location from LocationManager.requestLocationUpdates() that is better than any of the previous
.take(3)
// only request locations for 30 seconds
.timeout(30.seconds),
permissionsManager.hasPermission(PermissionGroup.Location), permissionsManager.hasPermission(PermissionGroup.Location),
settings.data settings.data
) { userLocation, hasPermission, settingsData -> ) { hasPermission, settingsData ->
emit(persistentListOf()) !hasPermission || settingsData.providers.isEmpty()
}.distinctUntilChanged().flatMapLatest { unusable ->
if (!hasPermission || settingsData.providers.isEmpty()) { if (unusable) {
return@combineTransform // Bail out before building the location flow below. As an argument to
} // combineTransform it is always collected, which registered GPS and network
// listeners for every search query even when location search was off or not
val providers = settingsData.providers.map { // permitted.
when (it) { flowOf(persistentListOf())
"openstreetmaps" -> OsmLocationProvider(context, settings) } else {
else -> PluginLocationProvider(context, it) locationSearchFlow(query, allowNetwork)
}
}
supervisorScope {
val result = MutableStateFlow(persistentListOf<Location>())
for (provider in providers) {
launch {
val r = provider.search(
query,
userLocation,
allowNetwork,
settingsData.searchRadius,
settingsData.hideUncategorized
)
result.update {
(it + r).toPersistentList()
}
}
}
emitAll(result)
} }
} }
} }
@OptIn(FlowPreview::class)
private fun locationSearchFlow(
query: String,
allowNetwork: Boolean,
): Flow<ImmutableList<Location>> = combineTransform(
poseProvider
.getLocation(minTimeMs = 2000, minDistanceM = 50.0f)
// 1st location: lastCachedLocation of poseProvider, if available
// 2nd location: LocationManager.getLastKnownLocation(), if available and better than lastCachedLocation
// 3rd location: live location from LocationManager.requestLocationUpdates() that is better than any of the previous
.take(3)
// only request locations for 30 seconds
.timeout(30.seconds),
permissionsManager.hasPermission(PermissionGroup.Location),
settings.data
) { userLocation, hasPermission, settingsData ->
emit(persistentListOf())
if (!hasPermission || settingsData.providers.isEmpty()) {
return@combineTransform
}
val providers = settingsData.providers.map {
when (it) {
"openstreetmaps" -> OsmLocationProvider(context, settings)
else -> PluginLocationProvider(context, it)
}
}
supervisorScope {
val result = MutableStateFlow(persistentListOf<Location>())
for (provider in providers) {
launch {
val r = provider.search(
query,
userLocation,
allowNetwork,
settingsData.searchRadius,
settingsData.hideUncategorized
)
result.update {
(it + r).toPersistentList()
}
}
}
emitAll(result)
}
}
} }

View File

@@ -79,6 +79,12 @@ internal class WeatherRepositoryImpl(
} }
scope.launch { scope.launch {
settings.collectLatest { settings.collectLatest {
if (it.managedLocation) {
// The provider resolves its own location and pushes weather updates, so there
// is nothing to poll for.
WorkManager.getInstance(context).cancelUniqueWork("weather")
return@collectLatest
}
val provider = WeatherProvider.getInstance(it.provider) val provider = WeatherProvider.getInstance(it.provider)
val weatherRequest = val weatherRequest =
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMillis(provider.getUpdateInterval())) PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMillis(provider.getUpdateInterval()))
@@ -212,6 +218,18 @@ class WeatherUpdateWorker(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Log.d("WeatherUpdateWorker", "Requesting weather data") Log.d("WeatherUpdateWorker", "Requesting weather data")
val settingsData = settings.first() val settingsData = settings.first()
// A provider with a managed location resolves its position itself and pushes weather
// updates instead of being polled (Breezy Weather does both). There is nothing to pull,
// and its getWeatherData() always returns null - which used to make this worker request a
// location (holding GPS and network listeners for up to ten minutes at a time) and then
// return Result.retry() forever, without ever setting lastUpdate, so the update interval
// check below could never short-circuit it either.
if (settingsData.managedLocation) {
Log.d("WeatherUpdateWorker", "Provider manages its own location, nothing to pull")
return Result.success()
}
val provider = WeatherProvider.getInstance(settingsData.provider) val provider = WeatherProvider.getInstance(settingsData.provider)
val updateInterval = provider.getUpdateInterval() val updateInterval = provider.getUpdateInterval()
@@ -254,7 +272,10 @@ class WeatherUpdateWorker(
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private suspend fun getLastKnownLocation(): LatLon? = locationProvider.getLocation(skipCache = true) private suspend fun getLastKnownLocation(): LatLon? = locationProvider.getLocation(skipCache = true)
.timeout(10.minutes) // Weather does not need a metre-accurate fix; giving up after two minutes instead of ten
// keeps the GPS and network listeners (and the wakelocks they hold) short. The caller
// falls back to the last known location.
.timeout(2.minutes)
.firstOrNull() .firstOrNull()
.or { locationProvider.lastCachedLocation } .or { locationProvider.lastCachedLocation }
?.let { LatLon(it.latitude, it.longitude) } ?.let { LatLon(it.latitude, it.longitude) }