From 61f3424de5f36d5f3fa0d0bf7558bc02820f3687 Mon Sep 17 00:00:00 2001 From: Jonas Haugesen Date: Sat, 12 Sep 2026 15:19:10 +0200 Subject: [PATCH] Stop the weather worker pinning location for providers that manage it 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). --- app/app/build.gradle.kts | 4 +- .../preferences/weather/WeatherSettings.kt | 8 ++ .../locations/LocationsRepository.kt | 109 +++++++++++------- .../launcher2/weather/WeatherRepository.kt | 23 +++- 4 files changed, 99 insertions(+), 45 deletions(-) diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index fb488cc0d..f48015c2c 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -34,8 +34,8 @@ android { applicationId = "de.mm20.launcher2" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091006 - versionName = "1.40.2-typing.7" + versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091201 + versionName = "1.40.2-typing.8" signingConfig = signingConfigs.getByName("debug") } diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/weather/WeatherSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/weather/WeatherSettings.kt index 52da5fb26..9e29da460 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/weather/WeatherSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/weather/WeatherSettings.kt @@ -38,6 +38,13 @@ data class WeatherSettingsData( val lastLocation: LatLon? = null, val lastUpdate: Long = 0L, val providerSettings: Map = 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( @@ -53,6 +60,7 @@ class WeatherSettings internal constructor( lastLocation = it.weatherLastLocation, lastUpdate = it.weatherLastUpdate, providerSettings = it.weatherProviderSettings, + managedLocation = it.weatherProviderSettings[it.weatherProvider]?.managedLocation == true, ) }.distinctUntilChanged() ) { diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/LocationsRepository.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/LocationsRepository.kt index 1db1de83d..4daa11346 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/LocationsRepository.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/LocationsRepository.kt @@ -12,11 +12,15 @@ import de.mm20.launcher2.search.SearchableRepository import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.timeout @@ -32,7 +36,7 @@ internal class LocationsRepository( private val permissionsManager: PermissionsManager, ) : SearchableRepository { - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) override fun search( query: String, allowNetwork: Boolean @@ -40,50 +44,71 @@ internal class LocationsRepository( if (query.isBlank() || query.length <= 1) { return flowOf(persistentListOf()) } - return 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), + return combine( 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()) - - for (provider in providers) { - launch { - val r = provider.search( - query, - userLocation, - allowNetwork, - settingsData.searchRadius, - settingsData.hideUncategorized - ) - result.update { - (it + r).toPersistentList() - } - } - } - emitAll(result) + ) { hasPermission, settingsData -> + !hasPermission || settingsData.providers.isEmpty() + }.distinctUntilChanged().flatMapLatest { unusable -> + if (unusable) { + // 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 + // permitted. + flowOf(persistentListOf()) + } else { + locationSearchFlow(query, allowNetwork) } } } + + @OptIn(FlowPreview::class) + private fun locationSearchFlow( + query: String, + allowNetwork: Boolean, + ): Flow> = 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()) + + for (provider in providers) { + launch { + val r = provider.search( + query, + userLocation, + allowNetwork, + settingsData.searchRadius, + settingsData.hideUncategorized + ) + result.update { + (it + r).toPersistentList() + } + } + } + emitAll(result) + } + } } \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt index ec0884336..2170037f9 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt @@ -79,6 +79,12 @@ internal class WeatherRepositoryImpl( } scope.launch { 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 weatherRequest = PeriodicWorkRequestBuilder(Duration.ofMillis(provider.getUpdateInterval())) @@ -212,6 +218,18 @@ class WeatherUpdateWorker( override suspend fun doWork(): Result { Log.d("WeatherUpdateWorker", "Requesting weather data") 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 updateInterval = provider.getUpdateInterval() @@ -254,7 +272,10 @@ class WeatherUpdateWorker( @OptIn(FlowPreview::class) 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() .or { locationProvider.lastCachedLocation } ?.let { LatLon(it.latitude, it.longitude) }