12 Commits

Author SHA1 Message Date
c56ea58fc5 Use || instead of comma conditions in the subjectless when
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Build Nightly APK / build (push) Has been cancelled
2026-09-12 15:44:06 +02:00
b0072f0fc2 Key the weather worker's location skip on the provider, not on a setting
typing.8 checked WeatherSettingsData.managedLocation, but that only exists once the
user has explicitly picked "managed location" for the provider, so
weatherProviderSettings stayed empty and the guard never fired: the worker kept
requesting a location and retrying every 30s.

The real signal is the provider's own capability. BreezyWeatherProvider.getWeatherData()
is a hardcoded noop that also manages its own location, so add WeatherProvider.isPushBased
and check that instead.

Also stop treating a managed location as "pull nothing": those providers still have data
to fetch, they just resolve the position themselves, so they now take the
WeatherLocation path instead of the auto-location one.

Bump to 1.40.2-typing.9 (versionCode 2026091202).
2026-09-12 15:43:10 +02:00
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
6aba977823 Bump version to 1.40.2-typing.6
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Alarm shows its trigger time and matches the date part's typography, static
dynamic-zone rankings (alarm 100, date 90, calendar 30), and a wider agenda
dot column.
2026-09-11 13:23:13 +02:00
229e5d1d32 Widen the agenda dot column to 24dp for more dot/time spacing 2026-09-11 13:22:59 +02:00
cb07acb577 Match the alarm part's typography to the date part
The alarm text used the button's default labelLarge in the vertical layout
and titleMedium in the compact one. Use titleMedium / titleLarge(Medium),
the same styles the date part uses, so both parts look alike.
2026-09-11 13:22:59 +02:00
a0f9413d40 Clock dynamic zone: alarm shows trigger time, static rankings, wider dot gap
- Alarm part now shows the time the alarm goes off instead of a relative
  "in X hours" span, formatted per the device's 12/24h setting.
- Reorder the dynamic zone: alarm (100) always sits below the clock, date
  (90) follows it.
- Calendar part ranking is now static (30) instead of boosting events that
  start within 30 minutes; drop the now-unused time plumbing.
- Widen the agenda dot column from 12dp to 20dp for more dot/time spacing.
2026-09-11 13:13:35 +02:00
12 changed files with 240 additions and 126 deletions

View File

@@ -87,7 +87,7 @@ state in `ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt`. New strings a
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
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/` 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)
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"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091004
versionName = "1.40.2-typing.5"
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091202
versionName = "1.40.2-typing.9"
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.SmartspacerPartProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class ClockWidgetVM : ViewModel(), KoinComponent {
private val settings: ClockWidgetSettings by inject()
private val partProviders = settings.parts.combine(settings.useSmartspacer) { p, s ->
p to s
}.map { (parts, smartspacer) ->
if (smartspacer && isAtLeastApiLevel(29)) {
return@map listOf(SmartspacerPartProvider())
}
/**
* Kept for the lifetime of the ViewModel instead of being rebuilt whenever the clock widget
* is subscribed again - which happens on every return to the home screen, because the
* launcher stops while it is in the background. Providers cache the data behind their
* 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>()
if (parts.date) providers += DatePartProvider()
if (parts.calendar) providers += CalendarPartProvider()
if (parts.music) providers += MusicPartProvider()
providers += BatteryPartProvider(parts.battery)
if (parts.alarm) providers += AlarmPartProvider()
providers
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
init {
viewModelScope.launch {
settings.parts.combine(settings.useSmartspacer) { p, s -> p to s }
.collect { (parts, smartspacer) ->
partProviders.value = if (smartspacer && isAtLeastApiLevel(29)) {
listOf(SmartspacerPartProvider())
} else {
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 {
partProviders.collectLatest { providers ->

View File

@@ -5,22 +5,26 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.icu.text.DateFormat
import android.icu.util.ULocale
import android.provider.AlarmClock
import android.text.format.DateUtils
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.locals.LocalTimeFormat
import de.mm20.launcher2.ui.utils.isTwentyFourHours
import java.util.Date
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.*
@@ -51,7 +55,8 @@ class AlarmPartProvider : PartProvider {
if (alarm > it + AlarmWindow) {
send(0)
} else {
send(60)
// Highest ranking, so the alarm always sits directly below the clock.
send(100)
}
}
}
@@ -77,11 +82,15 @@ class AlarmPartProvider : PartProvider {
@Composable
override fun Component(compactLayout: Boolean) {
val context = LocalContext.current
val timeFormat = LocalTimeFormat.current
val alarmTime by nextAlarmTime
val time by this.time.collectAsState(System.currentTimeMillis())
alarmTime?.let {
alarmTime?.let { alarm ->
val skeleton = if (timeFormat.isTwentyFourHours(context)) "HH:mm" else "hh:mm a"
val alarmText = DateFormat.getInstanceForSkeleton(skeleton, ULocale.getDefault())
.format(Date(alarm))
if (!compactLayout) {
@@ -99,12 +108,8 @@ class AlarmPartProvider : PartProvider {
)
Text(
modifier = Modifier.padding(start = 12.dp),
text = DateUtils.getRelativeTimeSpanString(
it,
time,
DateUtils.MINUTE_IN_MILLIS
)
.toString(),
text = alarmText,
style = MaterialTheme.typography.titleMedium
)
}
} else {
@@ -123,13 +128,10 @@ class AlarmPartProvider : PartProvider {
)
Text(
modifier = Modifier.padding(start = 12.dp),
text = DateUtils.getRelativeTimeSpanString(
it,
time,
DateUtils.MINUTE_IN_MILLIS
text = alarmText,
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Medium
)
.toString(),
style = MaterialTheme.typography.titleMedium
)
}
}

View File

@@ -54,6 +54,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -69,17 +70,14 @@ import java.util.Date
*/
private const val MaxRows = 4
/** An event that starts within this time frame (or is running) raises the ranking. */
private const val SoonThreshold = 30 * 60 * 1000L
/** Re-query interval, so the agenda survives calendar changes that don't notify. */
private const val RefreshInterval = 15 * 60 * 1000L
private const val RankingToday = 30
private const val RankingSoon = 70
/** Static ranking: the agenda is shown whenever there are events left today. */
private const val Ranking = 30
/** Width of the dot column. Kept fixed so the rows line up with each other. */
private val DotColumn = 12.dp
private val DotColumn = 24.dp
/** Width of the time column. Wide enough for "11:59 PM" and "all-day". */
private val TimeColumn = 64.dp
@@ -100,17 +98,12 @@ class CalendarPartProvider : PartProvider, KoinComponent {
private val searchSettings: CalendarSearchSettings by inject()
private val agenda = MutableStateFlow<List<CalendarEvent>>(emptyList())
private val time = MutableStateFlow(System.currentTimeMillis())
override fun setTime(time: Long) {
this.time.value = time
}
override fun getRanking(context: Context): Flow<Int> = channelFlow {
// Load the agenda in the background; the ranking itself only depends on the
// already known agenda and the current time, so it can be emitted right away.
// already known agenda, so it can be emitted right away.
launch { observeAgenda(context) }
combine(agenda, time) { events, now -> ranking(events, now) }
agenda.map { ranking(it) }
.distinctUntilChanged()
.collect { send(it) }
}
@@ -129,6 +122,10 @@ class CalendarPartProvider : PartProvider, KoinComponent {
from = now,
to = endOfDay(now),
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()
.filter { !it.isTask }
.sortedBy { it.startTime ?: it.endTime }
@@ -167,13 +164,8 @@ class CalendarPartProvider : PartProvider, KoinComponent {
}
}
private fun ranking(events: List<CalendarEvent>, now: Long): Int {
if (events.isEmpty()) return 0
val soon = events.any { event ->
val start = event.startTime
!event.allDay && start != null && start <= now + SoonThreshold && event.endTime > now
}
return if (soon) RankingSoon else RankingToday
private fun ranking(events: List<CalendarEvent>): Int {
return if (events.isEmpty()) 0 else Ranking
}
private fun endOfDay(now: Long): Long {

View File

@@ -24,7 +24,8 @@ import java.util.*
class DatePartProvider : PartProvider {
override fun getRanking(context: Context): Flow<Int> = flow {
emit(1)
// Second highest, so the date follows the alarm below the clock.
emit(90)
}
@Composable

View File

@@ -38,6 +38,13 @@ data class WeatherSettingsData(
val lastLocation: LatLon? = null,
val lastUpdate: Long = 0L,
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(
@@ -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()
) {

View File

@@ -22,20 +22,28 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlin.time.Duration.Companion.days
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(
from: Long = System.currentTimeMillis(),
to: Long = from + 14 * 24 * 60 * 60 * 1000L,
excludeCalendars: List<String> = emptyList(),
excludeAllDayEvents: Boolean = false,
debounceMillis: Long = 500,
): Flow<ImmutableList<CalendarEvent>>
fun getCalendars(providerId: String? = null): Flow<List<CalendarList>>
@@ -94,6 +102,7 @@ internal class CalendarRepositoryImpl(
to: Long,
excludeCalendars: List<String>,
excludeAllDayEvents: Boolean,
debounceMillis: Long,
): Flow<ImmutableList<CalendarEvent>> {
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
@@ -112,17 +121,17 @@ internal class CalendarRepositoryImpl(
)
}
emitAll(
queryCalendarEvents(
query = null,
intervalStart = from,
intervalEnd = to,
excludeAllDayEvents = excludeAllDayEvents,
excludeCalendars = excludeCalendars,
providers = providers,
allowNetwork = false,
).debounce(500)
val events = queryCalendarEvents(
query = null,
intervalStart = from,
intervalEnd = to,
excludeAllDayEvents = excludeAllDayEvents,
excludeCalendars = excludeCalendars,
providers = providers,
awaitAllProviders = true,
allowNetwork = false,
)
emitAll(if (debounceMillis > 0L) events.debounce(debounceMillis) else events)
}
}
@@ -133,12 +142,17 @@ internal class CalendarRepositoryImpl(
excludeAllDayEvents: Boolean = false,
excludeCalendars: List<String> = emptyList(),
allowNetwork: Boolean = false,
awaitAllProviders: Boolean = false,
providers: List<CalendarProvider>,
): Flow<ImmutableList<CalendarEvent>> = flow {
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 {
val r = provider.search(
query,
@@ -152,11 +166,19 @@ internal class CalendarRepositoryImpl(
allowNetwork = allowNetwork,
)
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.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<Location> {
@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<Location>())
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<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

@@ -11,6 +11,14 @@ interface WeatherProvider {
return 1000 * 60 * 60L
}
/**
* Whether this provider pushes weather updates on its own instead of being polled, and
* resolves its location itself. [WeatherUpdateWorker] then has nothing to pull and must not
* request a location for it.
*/
val isPushBased: Boolean
get() = false
suspend fun getWeatherData(location: WeatherLocation): List<Forecast>?
suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>?
suspend fun findLocation(query: String): List<WeatherLocation>

View File

@@ -79,7 +79,12 @@ internal class WeatherRepositoryImpl(
}
scope.launch {
settings.collectLatest {
val provider = WeatherProvider.getInstance(it.provider)
val provider = WeatherProvider.getInstance(it.provider)
if (provider.isPushBased) {
// The provider sends its updates itself, so there is nothing to poll for.
WorkManager.getInstance(context).cancelUniqueWork("weather")
return@collectLatest
}
val weatherRequest =
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMillis(provider.getUpdateInterval()))
.build()
@@ -214,6 +219,17 @@ class WeatherUpdateWorker(
val settingsData = settings.first()
val provider = WeatherProvider.getInstance(settingsData.provider)
// A push-based provider sends its updates on its own and resolves its own location
// (Breezy Weather does both), so there is nothing to pull here. Its getWeatherData()
// always returns null, which used to make this worker request a location - registering
// GPS and network listeners and holding them for up to ten minutes - and then return
// Result.retry() forever, without ever setting lastUpdate, so the interval check below
// could never short-circuit it either.
if (provider.isPushBased) {
Log.d("WeatherUpdateWorker", "Provider pushes updates, nothing to pull")
return Result.success()
}
val updateInterval = provider.getUpdateInterval()
val lastUpdate = settingsData.lastUpdate
@@ -222,21 +238,26 @@ class WeatherUpdateWorker(
return Result.failure()
}
val weatherData = if (settingsData.autoLocation) {
val latLon = getLastKnownLocation() ?: settingsData.lastLocation
if (latLon == null) {
Log.e("WeatherUpdateWorker", "Could not get location")
return Result.failure()
val weatherData = when {
// A managed location means the provider resolves the position itself, so requesting
// one here would be wasted work that holds the location stack open.
settingsData.managedLocation || !settingsData.autoLocation -> {
val location = settings.location.first()
if (location == null) {
Log.e("WeatherUpdateWorker", "Location not set")
return Result.failure()
}
provider.getWeatherData(location)
}
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()
else -> {
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)
}
provider.getWeatherData(location)
}
return if (weatherData == null) {
@@ -254,7 +275,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) }

View File

@@ -20,6 +20,9 @@ class BreezyWeatherProvider(
) : WeatherProvider, KoinComponent {
private val database: AppDatabase by inject()
// Breezy sends its weather data to us (see pushWeatherData) and manages its own location.
override val isPushBased: Boolean = true
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
// Noop implementation, because Breezy weather is handled in a special way
return null