12 Commits

Author SHA1 Message Date
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
05c7f0f60d Show the next alarm 24 hours ahead, not 8
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
An alarm set for the next morning left the dynamic zone before it was visible:
at 22:00 an alarm at 06:00 is eight hours away, so the part only appeared while
the phone was unlikely to be looked at. The window is now 24 hours, and the
setting summary says so (also in Danish; the other translations still say 8 and
will follow upstream).

Verified on the Titan 2 that Chrono (com.vicolo.chrono) registers its alarms the
way this part reads them: dumpsys alarm lists them as "Alarm clock" entries with
a showIntent pointing at Chrono, and AlarmManager.nextAlarmClock returns the
earliest one, so ACTION_NEXT_ALARM_CLOCK_CHANGED keeps the part up to date.
Tapping it opens Chrono as well - SHOW_ALARMS, SET_ALARM and SHOW_TIMERS all
resolve to com.vicolo.chrono/.MainActivity.
2026-09-11 12:30:05 +02:00
6c6cbe33fa Bump version to 1.40.2-typing.5
The agenda part and the configurable number of dynamic zone slots, with the
time column left-aligned.
2026-09-11 12:25:17 +02:00
6ae31fe92b Show an agenda in the clock widget's dynamic zone, with multiple slots
The dynamic zone showed exactly one part: the highest ranked of the enabled
ones. It now shows up to `clockWidgetDynamicZoneSlots` of them (default 1),
adjustable with a slider in the clock widget settings whose maximum is the
number of enabled parts. `ClockWidgetVM.getActiveParts()` returns that sorted
list, and both clock layouts render it (stacked under the clock, or next to it
in the horizontal layout).

On top of that the zone has an "Events" part (`clockWidgetCalendarPart`, off by
default) that shows today's agenda: events that are still running or upcoming
today, plus today's all-day events. At most four rows; when there are more, the
last row is a "+N more events" hint that opens the calendar app. Tapping a row
opens the event through `CalendarEvent.launch()`.

A row is three fixed-width columns that are pure layout, not drawn: a dot in the
calendar's colour, the time, and the title (ellipsized when it does not fit).
Times are always times, never dates - an all-day event renders "all-day" - and
the block is centred under the clock. The columns line up across rows, and the
time is left-aligned within its column.

The part ranks 70 when an event is running or starts within half an hour, 30
while events are left today and 0 on an empty day, so the date keeps the slot
when there is nothing to show. It reloads itself through a ContentObserver on
CalendarContract.Events and a 15 minute ticker, because unlike the calendar
widget it is on screen all the time. Tasks (`CalendarEvent.isTask`) are filtered
out for now; a tasks part can reuse this once tasks are configured.

Verified on the Titan 2: with the part enabled and three slots, the home screen
shows the agenda (two all-day events and a timed one) above the date part.
2026-09-11 12:25:09 +02:00
3f677e2c87 Build debug APKs on the phone itself
docs: the Termux home now has a working aarch64 Android SDK, so record
how to build here, how the debug build differs from the release one
(separate package, own data directory, so it cannot touch the installed
launcher's config), and warn about the destructive uninstall path.

scripts/ondevice.sh wraps the loop: build, install over the debug
package, launch, follow logcat, read the newest crash report, and build
the signed release APK.
2026-09-11 09:58:08 +02:00
14 changed files with 678 additions and 67 deletions

View File

@@ -1,4 +1,4 @@
# Kvaesitso (fork with type-to-search) # Kvaesitso (fork with type-to-search and a home-screen agenda)
Fork of [Kvaesitso](https://github.com/MM2-0/Kvaesitso) that adds type-to-search for phones with a Fork of [Kvaesitso](https://github.com/MM2-0/Kvaesitso) that adds type-to-search for phones with a
physical keyboard. Built for, and installed on, a Unihertz Titan 2 Elite. physical keyboard. Built for, and installed on, a Unihertz Titan 2 Elite.
@@ -45,8 +45,48 @@ text.
The search is also cleared whenever the launcher is left, even for a short app switch — upstream The search is also cleared whenever the launcher is left, even for a short app switch — upstream
keeps it for returns within five seconds, which leaves a stale query on screen. keeps it for returns within five seconds, which leaves a stale query on screen.
## The clock widget's dynamic zone (agenda part and multiple slots)
Upstream's home-screen clock has one "dynamic zone" below (or beside) the clock. Every enabled
*part* (`DatePartProvider`, `MusicPartProvider`, `BatteryPartProvider`, `AlarmPartProvider`) reports
a `getRanking()`; only the single highest-ranked part with a ranking above zero is shown. Two
changes on top of that:
* **The zone can show more than one part.** The setting `clockWidgetDynamicZoneSlots` (1..number of
enabled parts) caps how many of the highest-ranked parts are rendered;
`ClockWidgetVM.getActiveParts()` returns that sorted list instead of a single provider. The
slider lives in `ConfigureClockWidgetSheet` next to the part toggles.
* **A new part shows today's agenda.** `CalendarPartProvider`
(`app/ui/.../widgets/clock/parts/`) is toggled with the *Events* switch
(`clockWidgetCalendarPart`). It lists the events that are still running or upcoming today, plus
today's all-day events, capped at 4 rows — if there are more, the last row is a "+N more events"
hint that opens the calendar app. Tapping a row opens the event
(`CalendarEvent.launch`, i.e. `ACTION_VIEW` on `content://com.android.calendar/events/<id>`,
which KashCal handles). Rows reuse `CalendarEvent.getSummary()` from
`ui/launcher/search/calendar/CalendarItem.kt` (made `internal` for this), so an all-day event
reads "All day" and a timed one reads e.g. "09:00 09:30".
Rankings, in the order the zone prefers them: `CalendarPartProvider` 70 when an event is running or
starts within 30 minutes, else 30 while there are events left today, else 0 (so the date part keeps
the slot on an empty day); alarms 60 (ringing within 8 h); media 80 while playing; battery 55 when
low and 10 when charging or set to "always"; date 1. Tasks (`CalendarEvent.isTask`) are filtered out
of the agenda; a tasks part is planned but not implemented.
`CalendarPartProvider` keeps itself up to date with a `ContentObserver` on
`CalendarContract.Events.CONTENT_URI` plus a 15-minute ticker (the other calendar consumers in the
launcher only re-query when they become visible). `PartProvider.setTime()` is called every second
by the clock widget; the provider folds that into its ranking instead of re-querying.
The clock settings themselves are in `core/preferences`: `LauncherSettingsData` (new fields
`clockWidgetCalendarPart`, `clockWidgetDynamicZoneSlots`), `ClockWidgetParts` and the
`ClockWidgetSettings` accessors in `core/preferences/.../ui/ClockWidgetSettings.kt`, and the UI
state in `ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt`. New strings are English-only in
`core/i18n/src/main/res/values/strings.xml`; translations come from Crowdin upstream.
## Building ## Building
On the desktop:
JAVA_HOME=~/jdk21 ANDROID_HOME=~/android-sdk \ JAVA_HOME=~/jdk21 ANDROID_HOME=~/android-sdk \
./gradlew :app:app:assembleDefaultRelease ./gradlew :app:app:assembleDefaultRelease
@@ -57,8 +97,56 @@ The `release` build type signs with the `local` signing config, which reads `key
in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS` and in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS` and
`KEY_PASSWORD` environment variables. The desktop key is `KEY_PASSWORD` environment variables. The desktop key is
`~/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. Termux itself has no Android SDK, so build there only `~/android-keystores/` in the Termux home.
after installing one (or build on the desktop and copy the APK over).
### Building on the phone itself (Termux)
The phone builds this project without a desktop. `scripts/ondevice.sh` wraps the loop:
scripts/ondevice.sh build # assemble the debug APK
scripts/ondevice.sh install # build, then pm install it (root)
scripts/ondevice.sh start # launch the debug build
scripts/ondevice.sh logs # follow logcat of the debug build (Ctrl-C to stop)
scripts/ondevice.sh crash # newest crash report for the debug build
scripts/ondevice.sh release # assemble the signed release APK
Or call Gradle directly: `./gradlew :app:app:assembleDefaultDebug`. A full build takes about 13
minutes (984 tasks), later ones 20-30 seconds; the release build (R8 plus `lintVital`) takes about
11 minutes. Run `termux-wake-lock` first so the screen lock does not throttle it.
The **debug** build is `de.mm20.launcher2.debug` (`isDebuggable`, its own empty data directory),
so it installs next to the real launcher and can be logged with
`su -c 'logcat --pid=$(pidof de.mm20.launcher2.debug)'`. Installing, reinstalling or uninstalling
that package cannot affect the real launcher: different package, different data directory.
> **Never uninstall `de.mm20.launcher2.release`.** It is the launcher the phone actually runs, and
> the config (widgets, pages, tags, search settings) lives in its data directory
> `/data/user/0/de.mm20.launcher2.release`, mostly under `files/datastore`. `pm install -r` of a
> build signed with the same key keeps that data, but it does replace the running launcher, so do it
> deliberately, after backing the data directory up. Reinstalling the *debug* build is always the
> safe way to try a change.
To try a fix in the real launcher with the real data, build the release APK and `pm install -r` it:
same package (`de.mm20.launcher2.release`) and same key, so it replaces the installed build in
place. The release APK is minified by R8, so this is the slow build. `scripts/ondevice.sh release`
only *builds* it; it never installs anything.
#### The SDK on the phone
`~/android-sdk` (see `~/CLAUDE.md`) provides everything Gradle needs:
* `platforms/android-37.0` — the compileSdk 37 platform, installed with `sdkmanager`.
* `build-tools/36.0.0` — the version AGP 9.3.1 defaults to. Google only ships x86_64 build-tools,
so the three native tools are symlinks into `build-tools/34.0.4`, which is AndroidIDE's aarch64
build of the AOSP tools: `aapt2`, `zipalign` and `aidl`. Everything else in build-tools
(`d8`, `apksigner`, jars) is Java and runs as is.
* `~/.gradle/gradle.properties` points `android.aapt2FromMavenOverride` at Termux's own aarch64
`aapt2` (2.20, newer than 34.0.4's) and lowers the Gradle/Kotlin daemon heaps to fit the phone.
* `local.properties` in the repo root sets `sdk.dir`; it is git-ignored. The SDK itself is
machine-wide, not project-specific: another project just needs `termux-sdk init <repo>`, and
`termux-sdk install "platforms;android-35" "build-tools;35.0.0"` adds other SDK versions (it
patches their x86_64 native tools to aarch64, which plain `sdkmanager` cannot do). See
`~/CLAUDE.md` for what does and does not build on this phone.
## Releasing (the phone updates through Obtanium) ## Releasing (the phone updates through Obtanium)
@@ -77,6 +165,11 @@ after installing one (or build on the desktop and copy the APK over).
## Installing on the phone by hand ## Installing on the phone by hand
> **This is the destructive path: it costs the launcher config unless the backup is restored
> correctly. Do not do it without an explicit reason and a backup.** The current config lives in
> `/data/user/0/de.mm20.launcher2.release` (uid of the release build, currently `u0_a311`); copy it
> out with root before touching anything.
The phone already runs this build. The official build is signed with the upstream key, so it cannot The phone already runs this build. The official build is signed with the upstream key, so it cannot
be updated in place and Obtanium cannot install over it either. The procedure is: back up be updated in place and Obtanium cannot install over it either. The procedure is: back up
`/data/user/0/de.mm20.launcher2.release` with root, `pm uninstall` (a `-k` uninstall keeps the `/data/user/0/de.mm20.launcher2.release` with root, `pm uninstall` (a `-k` uninstall keeps the

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() ?: 2026091003 versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091006
versionName = "1.40.2-typing.4" versionName = "1.40.2-typing.7"
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }

View File

@@ -63,6 +63,7 @@ import de.mm20.launcher2.ui.component.DismissableBottomSheet
import de.mm20.launcher2.ui.component.preferences.Preference import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.SwitchPreference import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.component.preferences.ListPreference import de.mm20.launcher2.ui.component.preferences.ListPreference
import de.mm20.launcher2.ui.component.preferences.SliderPreference
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.AnalogClock import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.AnalogClock
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.BinaryClock import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.BinaryClock
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.CustomClock import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.CustomClock
@@ -105,8 +106,8 @@ fun ClockWidget(
viewModel.updateTime(time) viewModel.updateTime(time)
} }
val partProvider by remember { viewModel.getActivePart(context) }.collectAsStateWithLifecycle( val partProviders by remember { viewModel.getActiveParts(context) }.collectAsStateWithLifecycle(
null emptyList()
) )
AnimatedContent(editMode, label = "ClockWidget") { AnimatedContent(editMode, label = "ClockWidget") {
@@ -181,11 +182,11 @@ fun ClockWidget(
Clock(clockStyle, false, darkColors) Clock(clockStyle, false, darkColors)
} }
if (partProvider != null) { partProviders.forEach { provider ->
DynamicZone( DynamicZone(
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
compact = false, compact = false,
provider = partProvider, provider = provider,
) )
} }
} }
@@ -198,12 +199,13 @@ fun ClockWidget(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
if (partProvider != null) { Column(modifier = Modifier.weight(1f)) {
DynamicZone( partProviders.forEach { provider ->
modifier = Modifier.weight(1f), DynamicZone(
compact = true, compact = true,
provider = partProvider, provider = provider,
) )
}
} }
if (clockStyle !is ClockWidgetStyle.Empty) { if (clockStyle !is ClockWidgetStyle.Empty) {
Box( Box(
@@ -362,6 +364,8 @@ fun ConfigureClockWidgetSheet(
val useAccentColor by viewModel.useThemeColor.collectAsState() val useAccentColor by viewModel.useThemeColor.collectAsState()
val parts by viewModel.parts.collectAsState() val parts by viewModel.parts.collectAsState()
val smartspacer by viewModel.useSmartspacer.collectAsState() val smartspacer by viewModel.useSmartspacer.collectAsState()
val slots by viewModel.slots.collectAsState()
val partCount by viewModel.partCount.collectAsState()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -668,6 +672,15 @@ fun ConfigureClockWidgetSheet(
viewModel.setAlarmPart(it) viewModel.setAlarmPart(it)
} }
) )
SwitchPreference(
title = stringResource(R.string.preference_clockwidget_calendar_part),
summary = stringResource(R.string.preference_clockwidget_calendar_part_summary),
icon = R.drawable.event_24px,
value = parts?.calendar == true,
onValueChanged = {
viewModel.setCalendarPart(it)
}
)
ListPreference( ListPreference(
title = stringResource(R.string.preference_clockwidget_battery_part), title = stringResource(R.string.preference_clockwidget_battery_part),
icon = R.drawable.battery_full_24px, icon = R.drawable.battery_full_24px,
@@ -681,6 +694,18 @@ fun ConfigureClockWidgetSheet(
stringResource(R.string.preference_clockwidget_battery_part_always_show) to BatteryStatusVisibility.Always stringResource(R.string.preference_clockwidget_battery_part_always_show) to BatteryStatusVisibility.Always
) )
) )
SliderPreference(
title = stringResource(R.string.preference_clockwidget_dynamic_zone_slots),
icon = R.drawable.dashboard_2_24px,
value = slots,
min = 1,
max = maxOf(1, partCount),
step = 1,
enabled = partCount > 1,
onValueChanged = {
viewModel.setSlots(it)
}
)
} }
} }
} }

View File

@@ -10,48 +10,69 @@ import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.preferences.ui.ClockWidgetSettings import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.AlarmPartProvider import de.mm20.launcher2.ui.launcher.widgets.clock.parts.AlarmPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.BatteryPartProvider import de.mm20.launcher2.ui.launcher.widgets.clock.parts.BatteryPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.CalendarPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.DatePartProvider import de.mm20.launcher2.ui.launcher.widgets.clock.parts.DatePartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.FavoritesPartProvider import de.mm20.launcher2.ui.launcher.widgets.clock.parts.FavoritesPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.MusicPartProvider 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())
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())
}
}
}
} }
}
val providers = mutableListOf<PartProvider>() fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow {
if (parts.date) providers += DatePartProvider()
if (parts.music) providers += MusicPartProvider()
providers += BatteryPartProvider(parts.battery)
if (parts.alarm) providers += AlarmPartProvider()
providers
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
fun getActivePart(context: Context): Flow<PartProvider?> = channelFlow {
partProviders.collectLatest { providers -> partProviders.collectLatest { providers ->
if (providers.isEmpty()) { if (providers.isEmpty()) {
send(null) send(emptyList())
return@collectLatest return@collectLatest
} }
val rankings = providers.map { it.getRanking(context).map { r -> r to it } } val rankings = providers.map { it.getRanking(context).map { r -> r to it } }
combine(rankings) { r -> val ranked = combine(rankings) { r ->
r.filter { it.first > 0 }.maxByOrNull { it.first }?.second r.filter { it.first > 0 }
.sortedByDescending { it.first }
.map { it.second }
}
combine(ranked, settings.slots) { r, slots ->
r.take(slots.coerceAtLeast(1))
}.collectLatest { }.collectLatest {
send(it) send(it)
} }

View File

@@ -5,26 +5,35 @@ 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.*
/**
* How far ahead the next alarm is shown.
*/
private const val AlarmWindow = 24 * 60 * 60 * 1000L
class AlarmPartProvider : PartProvider { class AlarmPartProvider : PartProvider {
private val nextAlarmTime = mutableStateOf<Long?>(null) private val nextAlarmTime = mutableStateOf<Long?>(null)
@@ -43,10 +52,11 @@ class AlarmPartProvider : PartProvider {
send(0) send(0)
} else { } else {
time.collectLatest { time.collectLatest {
if (alarm > it + 8 * 60 * 60 * 1000) { 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)
} }
} }
} }
@@ -72,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) {
@@ -94,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 {
@@ -118,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
) )
} }
} }

View File

@@ -0,0 +1,297 @@
package de.mm20.launcher2.ui.launcher.widgets.clock.parts
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.database.ContentObserver
import android.icu.text.DateFormat
import android.icu.util.ULocale
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.TimeFormat
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.locals.LocalTimeFormat
import de.mm20.launcher2.ui.utils.isTwentyFourHours
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.channelFlow
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
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.time.LocalDate
import java.time.OffsetDateTime
import java.util.Date
/**
* Highest number of rows the agenda shows. If there are more events than this, the last
* row is a "+N more events" hint.
*/
private const val MaxRows = 4
/** Re-query interval, so the agenda survives calendar changes that don't notify. */
private const val RefreshInterval = 15 * 60 * 1000L
/** 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 = 24.dp
/** Width of the time column. Wide enough for "11:59 PM" and "all-day". */
private val TimeColumn = 64.dp
/** Width of the title column. Titles longer than this are ellipsized. */
private val TitleColumn = 176.dp
/**
* Dynamic zone part that shows today's agenda.
*
* Only events that are still running or upcoming today are shown, plus all-day events of
* today. Tasks are ignored for now.
*/
class CalendarPartProvider : PartProvider, KoinComponent {
private val calendarRepository: CalendarRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val searchSettings: CalendarSearchSettings by inject()
private val agenda = MutableStateFlow<List<CalendarEvent>>(emptyList())
override fun getRanking(context: Context): Flow<Int> = channelFlow {
// Load the agenda in the background; the ranking itself only depends on the
// already known agenda, so it can be emitted right away.
launch { observeAgenda(context) }
agenda.map { ranking(it) }
.distinctUntilChanged()
.collect { send(it) }
}
private suspend fun observeAgenda(context: Context) {
combine(
dayChanges(context),
permissionsManager.hasPermission(PermissionGroup.Calendar),
searchSettings.excludedCalendars,
) { now, hasPermission, excludedCalendars -> Triple(now, hasPermission, excludedCalendars) }
.collectLatest { (now, hasPermission, excludedCalendars) ->
agenda.value = if (!hasPermission) {
emptyList()
} else withContext(Dispatchers.IO) {
calendarRepository.findMany(
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 }
}
}
}
/**
* Emits the current time when the calendar database changes and periodically, so the
* agenda is re-queried when events change or the day rolls over.
*/
private fun dayChanges(context: Context): Flow<Long> = callbackFlow {
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
trySend(System.currentTimeMillis())
}
}
val registered = try {
context.contentResolver.registerContentObserver(
CalendarContract.Events.CONTENT_URI, true, observer
)
true
} catch (e: SecurityException) {
false
}
trySend(System.currentTimeMillis())
val ticker = launch {
while (isActive) {
delay(RefreshInterval)
trySend(System.currentTimeMillis())
}
}
awaitClose {
ticker.cancel()
if (registered) context.contentResolver.unregisterContentObserver(observer)
}
}
private fun ranking(events: List<CalendarEvent>): Int {
return if (events.isEmpty()) 0 else Ranking
}
private fun endOfDay(now: Long): Long {
val offset = OffsetDateTime.now().offset
return LocalDate.now().plusDays(1).atStartOfDay().toEpochSecond(offset) * 1000
}
@Composable
override fun Component(compactLayout: Boolean) {
val events by agenda.collectAsState()
val context = LocalContext.current
if (events.isEmpty()) return
val timeFormat = LocalTimeFormat.current
val contentColor = LocalContentColor.current
val visibleCount = if (events.size > MaxRows) MaxRows - 1 else events.size
val hiddenCount = events.size - visibleCount
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
for (event in events.take(visibleCount)) {
AgendaRow(
color = event.color?.let { Color(it) } ?: contentColor,
time = event.timeLabel(context, timeFormat),
title = event.label,
onClick = { event.launch(context, null) },
)
}
if (hiddenCount > 0) {
AgendaRow(
color = null,
time = null,
title = pluralStringResource(
R.plurals.clockwidget_calendar_part_more_events,
hiddenCount,
hiddenCount
),
secondary = true,
onClick = { openCalendar(context) },
)
}
}
}
}
/**
* The time of an event, or "all-day" for all-day events. Never a date.
*/
private fun CalendarEvent.timeLabel(context: Context, timeFormat: TimeFormat): String {
if (allDay) return context.getString(R.string.clockwidget_calendar_part_all_day)
val start = startTime ?: return ""
val skeleton = if (timeFormat.isTwentyFourHours(context)) "HH:mm" else "hh:mm a"
return DateFormat.getInstanceForSkeleton(skeleton, ULocale.getDefault()).format(Date(start))
}
/**
* One agenda row: a dot in the calendar color, the time and the title. The three columns are
* plain layout, they are not drawn.
*/
@Composable
private fun AgendaRow(
color: Color?,
time: String?,
title: String,
secondary: Boolean = false,
onClick: () -> Unit,
) {
val contentColor = LocalContentColor.current
val secondaryColor = contentColor.copy(alpha = contentColor.alpha * 0.7f)
Row(
modifier = Modifier
.clickable(onClick = onClick)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.width(DotColumn),
contentAlignment = Alignment.Center,
) {
if (color != null) {
Box(
modifier = Modifier
.size(8.dp)
.background(color, CircleShape)
)
}
}
Box(
modifier = Modifier.width(TimeColumn),
contentAlignment = Alignment.CenterStart,
) {
if (time != null) {
Text(
text = time,
style = MaterialTheme.typography.labelLarge,
color = secondaryColor,
maxLines = 1,
)
}
}
Box(
modifier = Modifier.width(TitleColumn),
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = if (secondary) secondaryColor else contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun openCalendar(context: Context) {
val builder = CalendarContract.CONTENT_URI.buildUpon()
builder.appendPath("time")
ContentUris.appendId(builder, System.currentTimeMillis())
context.tryStartActivity(
Intent(Intent.ACTION_VIEW)
.setData(builder.build())
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}

View File

@@ -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

View File

@@ -11,6 +11,7 @@ import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.preferences.ui.UiSettings import de.mm20.launcher2.preferences.ui.UiSettings
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@@ -101,6 +102,32 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
settings.setAlarmPart(alarmPart) settings.setAlarmPart(alarmPart)
} }
fun setCalendarPart(calendarPart: Boolean) {
settings.setCalendarPart(calendarPart)
}
val slots = settings.slots
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
fun setSlots(slots: Int) {
settings.setSlots(slots)
}
/**
* Number of enabled parts. This is the upper bound for the dynamic zone's slot count.
*/
val partCount = settings.parts
.map { parts ->
var count = 0
if (parts.date) count++
if (parts.music) count++
if (parts.alarm) count++
if (parts.calendar) count++
if (parts.battery != BatteryStatusVisibility.Hide) count++
count
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
val alignment = settings.alignment val alignment = settings.alignment
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)

View File

@@ -415,7 +415,7 @@
<string name="preference_enforce_icon_shape_summary">Anvend form på alle ikoner, inklusive dem, der normalt ikke understøtter det</string> <string name="preference_enforce_icon_shape_summary">Anvend form på alle ikoner, inklusive dem, der normalt ikke understøtter det</string>
<string name="preference_about_telegram">Telegram-gruppe</string> <string name="preference_about_telegram">Telegram-gruppe</string>
<string name="preference_about_license_summary">Licenseret under GNU General Public License 3.0</string> <string name="preference_about_license_summary">Licenseret under GNU General Public License 3.0</string>
<string name="preference_clockwidget_alarm_part_summary">Vis alarmer, der ringer inden for de næste 8 timer</string> <string name="preference_clockwidget_alarm_part_summary">Vis alarmer, der ringer inden for de næste 24 timer</string>
<string name="preference_search_favorites_summary">Vis fastgjorte og ofte brugte elementer over app-gitter</string> <string name="preference_search_favorites_summary">Vis fastgjorte og ofte brugte elementer over app-gitter</string>
<string name="preference_search_unitconverter_summary">Anvendelse: 1,5 kg eller 4 cm &gt;&gt; in</string> <string name="preference_search_unitconverter_summary">Anvendelse: 1,5 kg eller 4 cm &gt;&gt; in</string>
<string name="preference_favorites_edit_button_summary">Vis en knap for at omarrangere favoritterne</string> <string name="preference_favorites_edit_button_summary">Vis en knap for at omarrangere favoritterne</string>

View File

@@ -598,6 +598,7 @@
<string name="preference_clock_widget_alignment_center">Center</string> <string name="preference_clock_widget_alignment_center">Center</string>
<string name="preference_clock_widget_alignment_bottom">Bottom</string> <string name="preference_clock_widget_alignment_bottom">Bottom</string>
<string name="preference_clockwidget_dynamic_zone">Dynamic zone</string> <string name="preference_clockwidget_dynamic_zone">Dynamic zone</string>
<string name="preference_clockwidget_dynamic_zone_slots">Visible parts</string>
<string name="preference_clockwidget_smartspacer">Managed by Smartspacer</string> <string name="preference_clockwidget_smartspacer">Managed by Smartspacer</string>
<string name="preference_clockwidget_date_part">Date</string> <string name="preference_clockwidget_date_part">Date</string>
<string name="preference_clockwidget_date_part_summary">Show the current date</string> <string name="preference_clockwidget_date_part_summary">Show the current date</string>
@@ -611,7 +612,14 @@
<string name="preference_clockwidget_battery_part_show">Show when low or charging</string> <string name="preference_clockwidget_battery_part_show">Show when low or charging</string>
<string name="preference_clockwidget_battery_part_hide">Off</string> <string name="preference_clockwidget_battery_part_hide">Off</string>
<string name="preference_clockwidget_alarm_part">Alarms</string> <string name="preference_clockwidget_alarm_part">Alarms</string>
<string name="preference_clockwidget_alarm_part_summary">Show alarms that will ring within the next 8 hours</string> <string name="preference_clockwidget_alarm_part_summary">Show alarms that will ring within the next 24 hours</string>
<string name="preference_clockwidget_calendar_part">Events</string>
<string name="preference_clockwidget_calendar_part_summary">Show today\'s agenda</string>
<string name="clockwidget_calendar_part_all_day">all-day</string>
<plurals name="clockwidget_calendar_part_more_events">
<item quantity="one">+%d more event</item>
<item quantity="other">+%d more events</item>
</plurals>
<string name="preference_screen_backup">Backup and restore</string> <string name="preference_screen_backup">Backup and restore</string>
<string name="preference_screen_backup_summary">Export and import launcher data</string> <string name="preference_screen_backup_summary">Export and import launcher data</string>
<string name="preference_backup">Backup</string> <string name="preference_backup">Backup</string>

View File

@@ -58,6 +58,8 @@ data class LauncherSettingsData internal constructor(
val clockWidgetBatteryPart: BatteryStatusVisibility = BatteryStatusVisibility.Show, val clockWidgetBatteryPart: BatteryStatusVisibility = BatteryStatusVisibility.Show,
val clockWidgetMusicPart: Boolean = true, val clockWidgetMusicPart: Boolean = true,
val clockWidgetDatePart: Boolean = true, val clockWidgetDatePart: Boolean = true,
val clockWidgetCalendarPart: Boolean = false,
val clockWidgetDynamicZoneSlots: Int = 1,
val clockWidgetFillHeight: Boolean = false, val clockWidgetFillHeight: Boolean = false,
val clockWidgetAlignment: ClockWidgetAlignment = ClockWidgetAlignment.Bottom, val clockWidgetAlignment: ClockWidgetAlignment = ClockWidgetAlignment.Bottom,

View File

@@ -16,6 +16,7 @@ data class ClockWidgetParts(
val music: Boolean = false, val music: Boolean = false,
val battery: BatteryStatusVisibility = BatteryStatusVisibility.Hide, val battery: BatteryStatusVisibility = BatteryStatusVisibility.Hide,
val alarm: Boolean = false, val alarm: Boolean = false,
val calendar: Boolean = false,
) )
class ClockWidgetSettings internal constructor( class ClockWidgetSettings internal constructor(
@@ -37,9 +38,29 @@ class ClockWidgetSettings internal constructor(
music = it.clockWidgetMusicPart, music = it.clockWidgetMusicPart,
battery = it.clockWidgetBatteryPart, battery = it.clockWidgetBatteryPart,
alarm = it.clockWidgetAlarmPart, alarm = it.clockWidgetAlarmPart,
calendar = it.clockWidgetCalendarPart,
) )
}.distinctUntilChanged() }.distinctUntilChanged()
/**
* Maximum number of parts the dynamic zone shows at once.
*/
val slots
get() = launcherDataStore.data.map { it.clockWidgetDynamicZoneSlots.coerceAtLeast(1) }
.distinctUntilChanged()
fun setSlots(slots: Int) {
launcherDataStore.update {
it.copy(clockWidgetDynamicZoneSlots = slots.coerceAtLeast(1))
}
}
fun setCalendarPart(calendarPart: Boolean) {
launcherDataStore.update {
it.copy(clockWidgetCalendarPart = calendarPart)
}
}
fun setDatePart(datePart: Boolean) { fun setDatePart(datePart: Boolean) {
launcherDataStore.update { launcherDataStore.update {
it.copy(clockWidgetDatePart = datePart) it.copy(clockWidgetDatePart = datePart)

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())
}
} }
} }

87
scripts/ondevice.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
#
# Build and test this fork directly on the phone, from Termux.
# See CLAUDE.md ("Building on the phone itself") for the SDK setup this expects.
#
# The debug build is the package "de.mm20.launcher2.debug": a separate install from the
# real launcher ("de.mm20.launcher2.release") with its own, initially empty, data directory.
set -euo pipefail
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
PKG=de.mm20.launcher2.debug
ACTIVITY=de.mm20.launcher2.ui.launcher.LauncherActivity
DEBUG_APK=$REPO/app/app/build/outputs/apk/default/debug/app-default-debug.apk
RELEASE_APK=$REPO/app/app/build/outputs/apk/default/release/app-default-release.apk
usage() {
cat <<'EOF'
Usage: scripts/ondevice.sh <command>
build assemble the debug APK
install build, then install the debug APK (needs root)
start launch the installed debug build
logs follow logcat of the running debug build
crash print the newest crash report for the debug build
release assemble the signed release APK: same package and key as the launcher
installed from Obtanium, so it installs over it and keeps all data
EOF
}
# Termux ships JDK 21 in $PREFIX; on other machines JAVA_HOME has to be set already.
if [ -z "${JAVA_HOME:-}" ] && [ -n "${PREFIX:-}" ] && [ -d "$PREFIX/lib/jvm/java-21-openjdk" ]; then
export JAVA_HOME=$PREFIX/lib/jvm/java-21-openjdk
fi
gradle() {
( cd "$REPO" && ./gradlew --console=plain "$@" )
}
# Long builds outlive the screen lock; keep the CPU awake until they are done.
wake() {
command -v termux-wake-lock >/dev/null && termux-wake-lock || true
}
build_debug() {
wake
gradle :app:app:assembleDefaultDebug
ls -l "$DEBUG_APK"
}
# Every su -c argument below mixes two quoting levels: the single quoted parts are handed
# to the root shell as-is (so $( ) and $pid are expanded there), the "..." parts are
# expanded here.
case "${1:-}" in
build)
build_debug
;;
install)
build_debug
# Termux cannot use pm itself; root can, and can read the APK from ~/sources.
su -c "pm install -r -d '$DEBUG_APK'"
;;
start)
su -c "am start -n $PKG/$ACTIVITY"
;;
logs)
su -c 'pid=$(pidof '"$PKG"')
if [ -z "$pid" ]; then
echo "not running - start it first (scripts/ondevice.sh start)" >&2
exit 1
fi
exec logcat --pid=$pid'
;;
crash)
su -c 'for f in $(ls -t /data/system/dropbox/data_app_crash@*.txt 2>/dev/null | head -30); do
if grep -q '"$PKG"' "$f"; then echo "== $f"; cat "$f"; break; fi
done'
;;
release)
wake
gradle :app:app:assembleDefaultRelease
ls -l "$RELEASE_APK"
;;
*)
usage
exit 1
;;
esac