Compare commits
10 Commits
v1.40.2-ty
...
v1.40.2-ty
| Author | SHA1 | Date | |
|---|---|---|---|
| 61f3424de5 | |||
| 45ac29e8a7 | |||
| 291752abcd | |||
| 4836618609 | |||
| c8109d9197 | |||
| 3b3d6473ca | |||
| 6aba977823 | |||
| 229e5d1d32 | |||
| cb07acb577 | |||
| a0f9413d40 |
17
CLAUDE.md
17
CLAUDE.md
@@ -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:
|
||||||
|
|||||||
@@ -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() ?: 2026091004
|
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091201
|
||||||
versionName = "1.40.2-typing.5"
|
versionName = "1.40.2-typing.8"
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -5,22 +5,26 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.icu.text.DateFormat
|
||||||
|
import android.icu.util.ULocale
|
||||||
import android.provider.AlarmClock
|
import android.provider.AlarmClock
|
||||||
import android.text.format.DateUtils
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import de.mm20.launcher2.ktx.tryStartActivity
|
import de.mm20.launcher2.ktx.tryStartActivity
|
||||||
import de.mm20.launcher2.ui.R
|
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.awaitClose
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@@ -51,7 +55,8 @@ class AlarmPartProvider : PartProvider {
|
|||||||
if (alarm > it + AlarmWindow) {
|
if (alarm > it + AlarmWindow) {
|
||||||
send(0)
|
send(0)
|
||||||
} else {
|
} else {
|
||||||
send(60)
|
// Highest ranking, so the alarm always sits directly below the clock.
|
||||||
|
send(100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,11 +82,15 @@ class AlarmPartProvider : PartProvider {
|
|||||||
@Composable
|
@Composable
|
||||||
override fun Component(compactLayout: Boolean) {
|
override fun Component(compactLayout: Boolean) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val timeFormat = LocalTimeFormat.current
|
||||||
|
|
||||||
val alarmTime by nextAlarmTime
|
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) {
|
if (!compactLayout) {
|
||||||
|
|
||||||
@@ -99,12 +108,8 @@ class AlarmPartProvider : PartProvider {
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
text = DateUtils.getRelativeTimeSpanString(
|
text = alarmText,
|
||||||
it,
|
style = MaterialTheme.typography.titleMedium
|
||||||
time,
|
|
||||||
DateUtils.MINUTE_IN_MILLIS
|
|
||||||
)
|
|
||||||
.toString(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -123,13 +128,10 @@ class AlarmPartProvider : PartProvider {
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
text = DateUtils.getRelativeTimeSpanString(
|
text = alarmText,
|
||||||
it,
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
time,
|
fontWeight = FontWeight.Medium
|
||||||
DateUtils.MINUTE_IN_MILLIS
|
|
||||||
)
|
)
|
||||||
.toString(),
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -69,17 +70,14 @@ import java.util.Date
|
|||||||
*/
|
*/
|
||||||
private const val MaxRows = 4
|
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. */
|
/** Re-query interval, so the agenda survives calendar changes that don't notify. */
|
||||||
private const val RefreshInterval = 15 * 60 * 1000L
|
private const val RefreshInterval = 15 * 60 * 1000L
|
||||||
|
|
||||||
private const val RankingToday = 30
|
/** Static ranking: the agenda is shown whenever there are events left today. */
|
||||||
private const val RankingSoon = 70
|
private const val Ranking = 30
|
||||||
|
|
||||||
/** Width of the dot column. Kept fixed so the rows line up with each other. */
|
/** 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". */
|
/** Width of the time column. Wide enough for "11:59 PM" and "all-day". */
|
||||||
private val TimeColumn = 64.dp
|
private val TimeColumn = 64.dp
|
||||||
@@ -100,17 +98,12 @@ class CalendarPartProvider : PartProvider, KoinComponent {
|
|||||||
private val searchSettings: CalendarSearchSettings by inject()
|
private val searchSettings: CalendarSearchSettings by inject()
|
||||||
|
|
||||||
private val agenda = MutableStateFlow<List<CalendarEvent>>(emptyList())
|
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 {
|
override fun getRanking(context: Context): Flow<Int> = channelFlow {
|
||||||
// Load the agenda in the background; the ranking itself only depends on the
|
// 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) }
|
launch { observeAgenda(context) }
|
||||||
combine(agenda, time) { events, now -> ranking(events, now) }
|
agenda.map { ranking(it) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { send(it) }
|
.collect { send(it) }
|
||||||
}
|
}
|
||||||
@@ -129,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 }
|
||||||
@@ -167,13 +164,8 @@ class CalendarPartProvider : PartProvider, KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ranking(events: List<CalendarEvent>, now: Long): Int {
|
private fun ranking(events: List<CalendarEvent>): Int {
|
||||||
if (events.isEmpty()) return 0
|
return if (events.isEmpty()) 0 else Ranking
|
||||||
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 endOfDay(now: Long): Long {
|
private fun endOfDay(now: Long): Long {
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import java.util.*
|
|||||||
|
|
||||||
class DatePartProvider : PartProvider {
|
class DatePartProvider : PartProvider {
|
||||||
override fun getRanking(context: Context): Flow<Int> = flow {
|
override fun getRanking(context: Context): Flow<Int> = flow {
|
||||||
emit(1)
|
// Second highest, so the date follows the alarm below the clock.
|
||||||
|
emit(90)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -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()
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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) }
|
||||||
|
|||||||
Reference in New Issue
Block a user