25 Commits

Author SHA1 Message Date
c7d3a64c34 Bump version to 1.40.2-typing.10
Some checks are pending
Trigger F-Droid repository rebuild / trigger (release) Waiting to run
2026-09-17 08:56:57 +02:00
c824b100b6 Make the clock agenda's calendars and row count configurable
The agenda part filtered calendars through the search settings and always
collapsed after four rows. It now has its own selection: an exclusion set
(`clockWidgetCalendarPartExcludedCalendars`, empty = all) that is added to the
search settings' exclusions, because the picker only offers calendars those
settings leave enabled.

The row cap is `clockWidgetCalendarPartMaxRows` (1..8, default 4); the agenda
shows that many events and then a "+N more events" row if more are left, so
with the default five events now render as four rows plus "+1 more event"
instead of three plus "+2".

Both are configured in `ConfigureCalendarPart`, shown by the clock sheet while
the Events part is enabled: a slider, a Calendars row that expands into a
colour-tinted checkbox per calendar, and a button to Settings -> Search ->
Calendar (new `ROUTE_SEARCH_CALENDARS`) for calendars the search settings
disable. A missing calendar permission shows a banner with a Grant button.

New setting fields default to the previous behaviour, so no datastore
migration is needed.
2026-09-17 08:56:55 +02:00
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
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
5c1e9c5bf2 Handle physical keyboard keys on the scaffold state that is rendered
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Build Nightly APK / build (push) Has been cancelled
The key event handler was installed on the activity together with the state
instance it captured, so it could keep talking to a state that is no longer the
one that is drawn. That happens in practice: the scaffold state is recreated
when one of its rememberSaveable keys changes, which it does right after the
launcher starts (the window is measured, and the state is restored after the
configuration change), and the activity then still holds the handler of the
previous state. Typing on the home screen set the search query and started the
page transition on the detached state, so the search page never appeared, while
the query showed up in the search bar.

The handler is now created without the state as a key and asks for the current
state through a provider, and the activity's handler is refreshed on every
composition instead of only when the handler instance changes.

Verified on the Titan 2: typing on the home screen opens the search page with
the character inserted, further characters append, the text field stays
unfocused and the on-screen keyboard stays hidden.
2026-09-10 21:44:46 +02:00
e004862b00 Bump version to 1.40.2-typing.3
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Fixes on top of -typing.2: the widget configure activity crash, the search bar
keeping the keyboard focus (and the IME) on the home screen after an app switch,
and the TextFieldValue update in SearchBar moving out of the composition.
2026-09-10 20:46:11 +02:00
ff3cab9531 Keep the search bar from holding the keyboard focus on the home screen
Returning to the launcher from another app left the search bar's text field
focused, with the on-screen keyboard up. Typing on the physical keyboard then
went into the search bar - the IME commits characters to the focused text field
- while the search results never appeared, because the key event handler
deliberately leaves keys alone while the text field has the focus. Coming home
looked like the launcher had stopped responding to the keyboard; it happens with
a freshly started launcher and a plain app switch, with no search ever opened.

Give that focus up again one frame after the launcher is resumed, and hide the
on-screen keyboard, as long as the home screen is what is shown. Only on a
resume (pauseTime > 0), so that a search component used as the home screen can
still focus itself when the launcher starts.

Also set isSearchBarFocused back to false when the text field loses the focus,
like upstream does. LauncherSearchBar requests focus whenever that flag is true,
so only ever setting it to true made the text field focus itself again after it
had been unfocused - which is what kept the on-screen keyboard around.

Clear the search query before resetting the page in the resume path, so that a
stale query is gone even if the page transition is interrupted.

Note: the focus fix is a workaround for the framework/IME handing the focus back
to the text field; it is not verified on the device yet.
2026-09-10 20:41:05 +02:00
79f229de6d SearchBar: adopt outside text changes in a side effect
SearchBar keeps a TextFieldValue so that the cursor ends up behind text that was
changed from the outside (a search started by typing on a physical keyboard, or
a reset search). That value was updated during composition, which is an
anti-pattern: the value read earlier in the same composition is what gets drawn,
so the write only schedules another recomposition pass of the search bar - and
it can be dropped entirely if the composition does not complete.

Do the same update in a SideEffect, after the composition. No behaviour change
for the user.
2026-09-10 20:41:01 +02:00
73c3683740 Don't crash when a widget has no configure activity
A widget can advertise WIDGET_FEATURE_RECONFIGURABLE without providing a
configure activity (or the app providing it was uninstalled since). Tapping
"Configure widget" then made
AppWidgetHost.startAppWidgetConfigureActivityForResult throw, which killed the
launcher:

  android.content.ActivityNotFoundException: No Activity found to handle null
    at android.app.Activity.startIntentSenderForResult
    at android.appwidget.AppWidgetHost.startAppWidgetConfigureActivityForResult
    at ConfigureWidgetSheetKt.ConfigureAppWidget$lambda$10$1$0(ConfigureWidgetSheet.kt:812)

This happened three times on the Titan 2 on 2026-09-10 (19:55, 19:57 and 20:03,
the last one 28 seconds after boot). Catch ActivityNotFoundException, and
SecurityException for a configure activity that refuses to be started, and log
instead. Upstream main still has the same unguarded call.
2026-09-10 20:40:57 +02:00
e17903f40f Clear the search when the launcher is left, end keyboard search on dismiss
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Two fixes on top of the type-to-search feature:

- The search no longer survives leaving the launcher. Upstream only resets the
  search when the launcher was in the background for more than five seconds, so
  a quick app switch or a fast return from a launched app left the old query on
  screen. The search page is now always reset, whatever the pause was.
- Dismissing the search page ends the keyboard driven search. Without that the
  flag stayed set, so opening the search later (gesture or search bar) did not
  focus the text field and the soft keyboard stayed away.

Version 1.40.2-typing.2.
2026-09-10 12:03:00 +02:00
75f869c094 Add notes for this fork (type-to-search design, build, release, install) 2026-09-10 11:59:00 +02:00
27 changed files with 1311 additions and 150 deletions

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

206
CLAUDE.md Normal file
View File

@@ -0,0 +1,206 @@
# 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
physical keyboard. Built for, and installed on, a Unihertz Titan 2 Elite.
origin = https://gitea.haugesenspil.dk/jonas/Kvaesitso (our fork — push here)
upstream = https://github.com/MM2-0/Kvaesitso (pull upstream changes from here)
The phone also has this repository checked out in its Termux home: `~/sources/Kvaesitso`.
## The feature
Pressing a printable key on the physical keyboard while the launcher home screen is shown opens the
search page and inserts the character. Backspace deletes, enter launches the highlighted result.
Only the home screen (and the open search page) reacts; the widgets, notifications and recents
pages do not. Setting: *Settings → Search → "Search on typing"* (on by default, stored as
`searchOnTyping`).
## How it works, and why it is built this way
`SharedLauncherActivity.dispatchKeyEvent` is the only place that sees hardware key events while no
view has focus, so the handler is installed there (`launcherKeyEventHandler`, registered by
`LauncherScaffold` while it is shown). The handler itself is
`app/ui/.../ui/launcher/searchbar/TypeToSearchHandler.kt`.
Two constraints shape the design:
1. **The search bar's text field stays unfocused while the keyboard drives the search.** A focused
Compose text field makes the system show the on-screen keyboard. There is no public API to
prevent that: `View.setShowSoftInputOnFocus` is a hidden API (`TextView`'s version is public) and
reflection fails with `NoSuchMethodException`, and hiding the IME reactively makes it flash.
Leaving the field unfocused keeps the IME away completely — which is why the handler inserts and
deletes the characters itself instead of relying on the text field.
2. **No key may be lost.** Characters can arrive before the text field would have focus, so the
handler keeps handling keys until the field reports real focus
(`LauncherScaffoldState.isSearchBarTextFieldFocused`). `LauncherScaffoldState.isTypingSearch`
marks a keyboard-driven search: while it is set, `SearchComponent.onPreActivate` does not focus
the field and the handler owns the keys, including backspace and enter. Tapping the search bar
clears the flag and hands over to the normal editable field.
`SearchBar` keeps a `TextFieldValue` instead of a `String` so that changing the text from the
outside moves the cursor to the end. Without that, the next typed character landed in front of the
text.
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.
## 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 `clockWidgetCalendarPartMaxRows` rows (1..8, default 4) — if
there are more, the rows are followed by 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".
* **The agenda picks its calendars.** `clockWidgetCalendarPartExcludedCalendars` (empty = all)
hides individual calendars; the query's exclusion set is that set plus the search settings'
exclusions, because the picker only ever offers calendars the search settings leave enabled
(`ClockWidgetSettingsScreenVM.calendars` filters them out of `CalendarRepository.getCalendars()`,
and a "Manage calendars" button opens `Settings → Search → Calendar` via the
`ROUTE_SEARCH_CALENDARS` route in `SettingsActivity`). The whole calendar configuration lives in
`ConfigureCalendarPart.kt`, shown by `ConfigureClockWidgetSheet` while the Events part is on —
the agenda deliberately ignores the calendar *widget*'s own `excludedCalendarIds`.
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`, `clockWidgetCalendarPartExcludedCalendars`,
`clockWidgetCalendarPartMaxRows`, `clockWidgetDynamicZoneSlots`), `ClockWidgetParts` and the
`ClockWidgetSettings` accessors in `core/preferences/.../ui/ClockWidgetSettings.kt` (including the
`MinCalendarPartRows`/`MaxCalendarPartRows` bounds), 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
On the desktop:
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
`de.mm20.launcher2.release`.
The `release` build type signs with the `local` signing config, which reads `keystore.properties`
in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS` and
`KEY_PASSWORD` environment variables. The desktop key is
`~/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:
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)
1. Raise `versionCode` above the installed one and set a new `versionName` in
`app/app/build.gradle.kts` (`versionName` may keep the upstream version, e.g. `1.40.2-typing.2`).
2. Build the release APK (see above) and check the signature: `apksigner verify --verbose`.
3. Tag it and push `main` and the tag to `origin` (Gitea needs a token: the desktop keeps it in
`~/.config/gitea/token`).
4. Create a Gitea release for that tag and attach the APK as `Kvaesitso-v<versionName>-Signed.apk`.
The repository is public so Obtanium can read it anonymously.
5. The phone's Obtanium entry for `de.mm20.launcher2.release` points at this Gitea repo with
`overrideSource: "Codeberg"`, `versionDetection: true` and `apkFilterRegEx: "Kvaesitso-v"`. It
picks the release up on the next check. The config file lives on the phone at
`/sdcard/Android/data/dev.imranr.obtainium.fdroid/files/app_data/de.mm20.launcher2.release.json`
(force-stop Obtanium before editing it as root).
## 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
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
package record *and* its signature, so the next install then fails with
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`), install this APK, then copy the data back — `chown` to the
new app uid and `chcon` the label that installd created for the fresh data directory. The rest of
the phone (bootloader, Magisk, microG, rescue steps) is documented in `~/titan2-elite/CLAUDE.md` on
the desktop.

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

View File

@@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -143,8 +144,14 @@ fun SearchBar(
val textFieldValue = remember {
mutableStateOf(TextFieldValue(value, TextRange(value.length)))
}
if (textFieldValue.value.text != value) {
textFieldValue.value = TextFieldValue(value, TextRange(value.length))
// Characters the user typed are already in this value; onValueChange below keeps the cursor
// where the user put it. Text that changed from the outside - the search was reset, or a
// character was inserted by the physical keyboard handler - is adopted here, with the cursor at
// the end. This must not write state during composition, so it runs in a side effect after it.
SideEffect {
if (textFieldValue.value.text != value) {
textFieldValue.value = TextFieldValue(value, TextRange(value.length))
}
}
LauncherCard(

View File

@@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -63,6 +64,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
@@ -79,6 +81,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalViewConfiguration
@@ -988,6 +991,7 @@ internal fun LauncherScaffold(
val lifecycleOwner = LocalLifecycleOwner.current
val activity = LocalActivity.current as AppCompatActivity
val view = LocalView.current
val focusManager = LocalFocusManager.current
val wallpaperManager = remember(activity) { WallpaperManager.getInstance(activity) }
@@ -1071,14 +1075,25 @@ internal fun LauncherScaffold(
// Open the search when the user starts typing on a physical keyboard. The handler is
// installed on the activity, so it also receives key events while no text field is focused.
val typeToSearchHandler = rememberTypeToSearchHandler(state, searchVM)
DisposableEffect(typeToSearchHandler) {
val launcherActivity = activity as? SharedLauncherActivity
launcherActivity?.launcherKeyEventHandler = typeToSearchHandler
//
// The scaffold state is recreated when one of its keys changes - the window size after a
// rotation, or the state that rememberSaveable restores after the activity was recreated -
// so the handler must not keep a reference to one particular instance: it reads the current
// state through this holder, which the composition keeps up to date, and the activity's
// handler is refreshed on every composition. Without that, the handler can end up opening
// the search on a state that is no longer the one that is rendered, which looks as if the
// key press did nothing.
val currentState = remember { mutableStateOf(state) }
SideEffect {
if (currentState.value !== state) currentState.value = state
}
val typeToSearchHandler = rememberTypeToSearchHandler({ currentState.value }, searchVM)
SideEffect {
(activity as? SharedLauncherActivity)?.launcherKeyEventHandler = typeToSearchHandler
}
DisposableEffect(Unit) {
onDispose {
if (launcherActivity?.launcherKeyEventHandler === typeToSearchHandler) {
launcherActivity.launcherKeyEventHandler = null
}
(activity as? SharedLauncherActivity)?.launcherKeyEventHandler = null
}
}
@@ -1132,12 +1147,31 @@ internal fun LauncherScaffold(
} else {
activity.onBackPressedDispatcher.onBackPressed()
}
} else if (activity.pauseTime > 0L && System.currentTimeMillis() - activity.pauseTime > 5000L) {
} else if (activity.pauseTime > 0L && (System.currentTimeMillis() - activity.pauseTime > 5000L || state.currentComponent is SearchComponent)) {
// The search does not survive leaving the launcher, not even for a short app
// switch: coming back should always show a fresh search.
if (!state.isLocked) {
// Clear the query first: the page transition below animates and must not
// be what keeps a stale query around.
searchVM.reset()
if (state.currentComponent?.survivesPause != true) {
state.reset()
}
searchVM.reset()
}
}
// The framework hands the keyboard focus - and with it the on-screen keyboard -
// back to the search bar's text field whenever the launcher's window regains
// focus, even though no search is open. The home screen would then sit behind an
// on-screen keyboard and the physical keyboard would type into the search bar
// without the search results ever opening, because the characters are delivered
// to the focused text field instead of the key event handler. Give that focus up
// again, one frame after the resume, so that it also works when the framework
// grants the focus while the window is coming back.
if (activity.pauseTime > 0L) {
withFrameNanos { }
if (state.currentComponent !is SearchComponent) {
focusManager.clearFocus()
activity.hideSoftKeyboard()
}
}
awaitCancellation()
@@ -1407,8 +1441,11 @@ internal fun LauncherScaffold(
// as usual from now on.
state.isTypingSearch = false
scope.launch { state.onSearchBarTap() }
state.isSearchBarFocused = true
}
// Keep this in sync with the real focus state. It must go back to false when
// the text field loses focus, otherwise the launcher keeps asking for it
// (LauncherSearchBar requests focus whenever this is true).
state.isSearchBarFocused = it
},
onKeyboardActionGo = if (launchOnEnter) {
{ searchVM.launchBestMatchOrAction(activity) }

View File

@@ -115,5 +115,8 @@ internal class SearchComponent(
override fun onPreDismiss(state: LauncherScaffoldState) {
super.onPreDismiss(state)
state.isSearchBarFocused = false
// Leaving the search page ends the keyboard driven search, so that opening the search
// again focuses the text field as usual.
state.isTypingSearch = false
}
}

View File

@@ -58,6 +58,9 @@ internal fun KeyEvent.isEnter(): Boolean {
* [SharedLauncherActivity.launcherKeyEventHandler]. It is called for every key event before the
* event is dispatched to the view hierarchy, so it also works while no text field has focus.
*
* The scaffold state the handler works on is passed as a provider rather than as a value, because
* the state is replaced when the window size changes or the launcher is recreated.
*
* While the search is driven by the physical keyboard, the search bar's text field is deliberately
* left unfocused: a focused text field would make the system show the on-screen keyboard, and the
* user is typing on a physical keyboard anyway. The handler therefore inserts and deletes the
@@ -68,7 +71,7 @@ internal fun KeyEvent.isEnter(): Boolean {
*/
@Composable
internal fun rememberTypeToSearchHandler(
state: LauncherScaffoldState,
stateProvider: () -> LauncherScaffoldState,
searchVM: SearchVM,
): (KeyEvent) -> Boolean {
val scope = rememberCoroutineScope()
@@ -76,8 +79,12 @@ internal fun rememberTypeToSearchHandler(
val sheetManager = LocalBottomSheetManager.current
val overlayManager = LocalOverlayManager.current
return remember(state, searchVM, activity, sheetManager, overlayManager) {
// Deliberately not keyed on the scaffold state: the state is recreated when the window is
// resized or the launcher is recreated, and a key must always be handled by the state that is
// rendered at that moment. stateProvider hands out that state.
return remember(searchVM, activity, sheetManager, overlayManager) {
fun handle(event: KeyEvent): Boolean {
val state = stateProvider()
// Read the preference here rather than capturing it in the composition, so that the
// current value is always used.
if (!searchVM.searchOnTyping.value || state.isLocked) return false

View File

@@ -4,9 +4,11 @@ import android.app.Activity
import android.app.ActivityOptions
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
@@ -809,21 +811,32 @@ fun ColumnScope.ConfigureAppWidget(
bottom = 8.dp,
),
onClick = {
appWidgetHost.startAppWidgetConfigureActivityForResult(
lifecycleOwner as Activity,
widget.config.widgetId,
0,
0,
if (Build.VERSION.SDK_INT < 34) {
null
} else {
ActivityOptions.makeBasic()
.setPendingIntentBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
)
.toBundle()
}
)
try {
appWidgetHost.startAppWidgetConfigureActivityForResult(
lifecycleOwner as Activity,
widget.config.widgetId,
0,
0,
if (Build.VERSION.SDK_INT < 34) {
null
} else {
ActivityOptions.makeBasic()
.setPendingIntentBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
)
.toBundle()
}
)
} catch (e: ActivityNotFoundException) {
// A widget can advertise WIDGET_FEATURE_RECONFIGURABLE without providing a
// configure activity (or the app providing it was uninstalled). Starting it
// then throws, which would crash the launcher.
Log.w("MM20", "No configure activity for widget ${widget.config.widgetId}", e)
} catch (e: SecurityException) {
// The configure activity refused to be started, e.g. because the app owning
// the widget is not allowed to start activities from the background.
Log.w("MM20", "Widget configure activity refused to start", e)
}
}) {
Text(
stringResource(id = R.string.widget_config_appwidget_configure)

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.SwitchPreference
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.BinaryClock
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.CustomClock
@@ -105,8 +106,8 @@ fun ClockWidget(
viewModel.updateTime(time)
}
val partProvider by remember { viewModel.getActivePart(context) }.collectAsStateWithLifecycle(
null
val partProviders by remember { viewModel.getActiveParts(context) }.collectAsStateWithLifecycle(
emptyList()
)
AnimatedContent(editMode, label = "ClockWidget") {
@@ -181,11 +182,11 @@ fun ClockWidget(
Clock(clockStyle, false, darkColors)
}
if (partProvider != null) {
partProviders.forEach { provider ->
DynamicZone(
modifier = Modifier.padding(bottom = 8.dp),
compact = false,
provider = partProvider,
provider = provider,
)
}
}
@@ -198,12 +199,13 @@ fun ClockWidget(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (partProvider != null) {
DynamicZone(
modifier = Modifier.weight(1f),
compact = true,
provider = partProvider,
)
Column(modifier = Modifier.weight(1f)) {
partProviders.forEach { provider ->
DynamicZone(
compact = true,
provider = provider,
)
}
}
if (clockStyle !is ClockWidgetStyle.Empty) {
Box(
@@ -362,6 +364,8 @@ fun ConfigureClockWidgetSheet(
val useAccentColor by viewModel.useThemeColor.collectAsState()
val parts by viewModel.parts.collectAsState()
val smartspacer by viewModel.useSmartspacer.collectAsState()
val slots by viewModel.slots.collectAsState()
val partCount by viewModel.partCount.collectAsState()
Column(
modifier = Modifier
@@ -668,6 +672,15 @@ fun ConfigureClockWidgetSheet(
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(
title = stringResource(R.string.preference_clockwidget_battery_part),
icon = R.drawable.battery_full_24px,
@@ -681,9 +694,29 @@ fun ConfigureClockWidgetSheet(
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)
}
)
}
}
}
if (smartspacer == false) {
AnimatedVisibility(visible = parts?.calendar == true) {
ConfigureCalendarPart(
modifier = Modifier.padding(top = 16.dp),
viewModel = viewModel,
)
}
}
}
}
}

View File

@@ -10,48 +10,69 @@ import de.mm20.launcher2.ktx.tryStartActivity
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.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.FavoritesPartProvider
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())
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>()
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 {
fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow {
partProviders.collectLatest { providers ->
if (providers.isEmpty()) {
send(null)
send(emptyList())
return@collectLatest
}
val rankings = providers.map { it.getRanking(context).map { r -> r to it } }
combine(rankings) { r ->
r.filter { it.first > 0 }.maxByOrNull { it.first }?.second
val ranked = combine(rankings) { r ->
r.filter { it.first > 0 }
.sortedByDescending { it.first }
.map { it.second }
}
combine(ranked, settings.slots) { r, slots ->
r.take(slots.coerceAtLeast(1))
}.collectLatest {
send(it)
}

View File

@@ -0,0 +1,181 @@
package de.mm20.launcher2.ui.launcher.widgets.clock
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.ui.MaxCalendarPartRows
import de.mm20.launcher2.preferences.ui.MinCalendarPartRows
import de.mm20.launcher2.themes.colors.atTone
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.SliderPreference
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.settings.SettingsActivity
import de.mm20.launcher2.ui.settings.clockwidget.ClockWidgetSettingsScreenVM
import org.koin.compose.koinInject
/**
* Settings for the agenda in the clock widget's dynamic zone: how many events are shown
* before they collapse into a "+N more events" row, and which calendars they come from.
*/
@Composable
internal fun ConfigureCalendarPart(
viewModel: ClockWidgetSettingsScreenVM,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val activity = LocalLifecycleOwner.current as? AppCompatActivity
val permissionsManager: PermissionsManager = koinInject()
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val excludedCalendars by viewModel.calendarPartExcludedCalendars.collectAsStateWithLifecycle()
val maxRows by viewModel.calendarPartMaxRows.collectAsStateWithLifecycle()
val hasPermission by viewModel.hasCalendarPermission.collectAsStateWithLifecycle()
var showCalendars by remember { mutableStateOf(false) }
val shownCalendars = calendars.count { it.id !in excludedCalendars }
Column(modifier = modifier) {
if (!hasPermission) {
MissingPermissionBanner(
modifier = Modifier.padding(bottom = 8.dp),
text = stringResource(R.string.missing_permission_calendar_widget_settings),
onClick = {
activity?.let {
permissionsManager.requestPermission(it, PermissionGroup.Calendar)
}
},
)
}
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
SliderPreference(
title = stringResource(R.string.preference_clockwidget_calendar_part_events),
icon = R.drawable.event_24px,
value = maxRows,
min = MinCalendarPartRows,
max = MaxCalendarPartRows,
step = 1,
onValueChanged = {
viewModel.setCalendarPartMaxRows(it)
}
)
HorizontalDivider()
Preference(
title = stringResource(R.string.preference_clockwidget_calendar_part_calendars),
summary = when {
calendars.isEmpty() -> stringResource(R.string.preference_clockwidget_calendar_part_no_calendars)
shownCalendars == calendars.size -> stringResource(R.string.preference_clockwidget_calendar_part_all_calendars)
else -> stringResource(
R.string.preference_clockwidget_calendar_part_calendars_summary,
shownCalendars,
calendars.size,
)
},
icon = R.drawable.calendar_today_24px,
onClick = { showCalendars = !showCalendars },
enabled = calendars.isNotEmpty(),
controls = {
Icon(
painter = painterResource(
if (showCalendars) R.drawable.keyboard_arrow_up_24px
else R.drawable.keyboard_arrow_down_24px
),
contentDescription = null,
)
},
)
AnimatedVisibility(
visible = showCalendars && calendars.isNotEmpty(),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
HorizontalDivider()
for (calendar in calendars) {
CheckboxPreference(
title = calendar.name,
summary = calendar.owner,
iconPadding = false,
value = calendar.id !in excludedCalendars,
onValueChanged = {
viewModel.setCalendarExcluded(calendar.id, !it)
},
checkboxColors = CheckboxDefaults.colors(
checkedColor = if (calendar.color == 0) MaterialTheme.colorScheme.primary
else Color(
calendar.color.atTone(if (LocalDarkTheme.current) 80 else 40)
),
checkmarkColor = if (calendar.color == 0) MaterialTheme.colorScheme.onPrimary
else Color(
calendar.color.atTone(if (LocalDarkTheme.current) 20 else 100)
)
)
)
}
}
}
}
}
TextButton(
modifier = Modifier
.padding(top = 8.dp)
.align(Alignment.CenterHorizontally),
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
onClick = {
context.startActivity(
Intent(context, SettingsActivity::class.java).apply {
putExtra(
SettingsActivity.EXTRA_ROUTE,
SettingsActivity.ROUTE_SEARCH_CALENDARS,
)
}
)
},
) {
Text(stringResource(R.string.preference_clockwidget_calendar_part_manage_calendars))
Icon(
modifier = Modifier
.padding(start = ButtonDefaults.IconSpacing)
.requiredSize(ButtonDefaults.IconSize),
painter = painterResource(R.drawable.open_in_new_20px),
contentDescription = null,
)
}
}
}

View File

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

@@ -0,0 +1,320 @@
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.preferences.ui.ClockWidgetSettings
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
/**
* Number of rows the agenda shows before it collapses into a "+N more events" row, until the
* setting has been read.
*/
private const val DefaultMaxRows = 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 clockWidgetSettings: ClockWidgetSettings by inject()
private val agenda = MutableStateFlow<List<CalendarEvent>>(emptyList())
private val maxRows = MutableStateFlow(DefaultMaxRows)
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) }
launch { clockWidgetSettings.calendarPartMaxRows.collect { maxRows.value = it } }
agenda.map { ranking(it) }
.distinctUntilChanged()
.collect { send(it) }
}
private suspend fun observeAgenda(context: Context) {
combine(
dayChanges(context),
permissionsManager.hasPermission(PermissionGroup.Calendar),
clockWidgetSettings.calendarPartExcludedCalendars,
searchSettings.excludedCalendars,
) { now, hasPermission, hiddenInWidget, excludedInSearch ->
AgendaInputs(
now = now,
hasCalendarPermission = hasPermission,
// Calendars the user disabled in the search settings cannot be picked in the
// widget either, so the two sets just add up.
excludedCalendars = if (hasPermission) hiddenInWidget + excludedInSearch else emptySet(),
)
}
.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 maxRows by this.maxRows.collectAsState()
val context = LocalContext.current
if (events.isEmpty()) return
val timeFormat = LocalTimeFormat.current
val contentColor = LocalContentColor.current
// The configured number of events, then a "+N more events" row if there are more.
val visibleCount = minOf(events.size, maxRows)
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 three inputs the agenda query depends on. */
private data class AgendaInputs(
val now: Long,
val hasCalendarPermission: Boolean,
val excludedCalendars: Set<String>,
)
/**
* 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 {
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

@@ -422,6 +422,7 @@ class SettingsActivity : BaseActivity() {
ROUTE_MEDIA_INTEGRATION -> MediaIntegrationSettingsRoute
ROUTE_SEARCH_ACTIONS -> SearchActionsSettingsRoute
ROUTE_HIDDEN_ITEMS -> HiddenItemsSettingsRoute
ROUTE_SEARCH_CALENDARS -> CalendarSearchSettingsRoute
ROUTE_CRASH_REPORT if (intent.hasExtra(EXTRA_CRASH_REPORT_PATH)) -> {
CrashReportRoute(intent.getStringExtra(EXTRA_CRASH_REPORT_PATH)!!)
}
@@ -435,6 +436,7 @@ class SettingsActivity : BaseActivity() {
const val ROUTE_MEDIA_INTEGRATION = "settings/integrations/media"
const val ROUTE_SEARCH_ACTIONS = "settings/search/searchactions"
const val ROUTE_HIDDEN_ITEMS = "settings/search/hiddenitems"
const val ROUTE_SEARCH_CALENDARS = "settings/search/calendar"
const val ROUTE_CRASH_REPORT = "settings/debug/crashreport"
const val EXTRA_CRASH_REPORT_PATH = "crash_report_path"
}

View File

@@ -2,15 +2,21 @@ package de.mm20.launcher2.ui.settings.clockwidget
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.calendar.providers.CalendarList
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.BatteryStatusVisibility
import de.mm20.launcher2.preferences.ClockWidgetAlignment
import de.mm20.launcher2.preferences.ClockWidgetColors
import de.mm20.launcher2.preferences.ClockWidgetStyle
import de.mm20.launcher2.preferences.TimeFormat
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.preferences.ui.UiSettings
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -18,6 +24,9 @@ import org.koin.core.component.inject
class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
private val settings: ClockWidgetSettings by inject()
private val uiSettings: UiSettings by inject()
private val calendarRepository: CalendarRepository by inject()
private val calendarSearchSettings: CalendarSearchSettings by inject()
private val permissionsManager: PermissionsManager by inject()
val compact = settings.compact
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
@@ -101,6 +110,66 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
settings.setAlarmPart(alarmPart)
}
fun setCalendarPart(calendarPart: Boolean) {
settings.setCalendarPart(calendarPart)
}
/**
* Number of event rows in the agenda before it collapses into a "+N more events" row.
*/
val calendarPartMaxRows = settings.calendarPartMaxRows
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 4)
fun setCalendarPartMaxRows(rows: Int) {
settings.setCalendarPartMaxRows(rows)
}
val calendarPartExcludedCalendars = settings.calendarPartExcludedCalendars
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptySet<String>())
fun setCalendarExcluded(calendarId: String, excluded: Boolean) {
settings.setCalendarExcluded(calendarId, excluded)
}
/**
* Calendars the agenda can show, i.e. every calendar the search settings make available.
* Calendars disabled there are missing here on purpose.
*/
val calendars = combine(
calendarRepository.getCalendars(),
calendarSearchSettings.excludedCalendars,
) { calendars, excluded ->
calendars
.filter { it.id !in excluded }
.sortedWith(compareBy({ it.owner ?: "" }, { it.name }))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList<CalendarList>())
/** Whether the agenda can show anything at all. */
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true)
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
.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_about_telegram">Telegram-gruppe</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_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>

View File

@@ -598,6 +598,7 @@
<string name="preference_clock_widget_alignment_center">Center</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_slots">Visible parts</string>
<string name="preference_clockwidget_smartspacer">Managed by Smartspacer</string>
<string name="preference_clockwidget_date_part">Date</string>
<string name="preference_clockwidget_date_part_summary">Show the current date</string>
@@ -611,7 +612,20 @@
<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_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="preference_clockwidget_calendar_part_events">Visible events</string>
<string name="preference_clockwidget_calendar_part_calendars">Calendars</string>
<string name="preference_clockwidget_calendar_part_all_calendars">All calendars</string>
<string name="preference_clockwidget_calendar_part_calendars_summary">%1$d of %2$d calendars</string>
<string name="preference_clockwidget_calendar_part_no_calendars">No calendars found</string>
<string name="preference_clockwidget_calendar_part_manage_calendars">Manage calendars</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_summary">Export and import launcher data</string>
<string name="preference_backup">Backup</string>

View File

@@ -58,6 +58,10 @@ data class LauncherSettingsData internal constructor(
val clockWidgetBatteryPart: BatteryStatusVisibility = BatteryStatusVisibility.Show,
val clockWidgetMusicPart: Boolean = true,
val clockWidgetDatePart: Boolean = true,
val clockWidgetCalendarPart: Boolean = false,
val clockWidgetCalendarPartExcludedCalendars: Set<String> = emptySet(),
val clockWidgetCalendarPartMaxRows: Int = 4,
val clockWidgetDynamicZoneSlots: Int = 1,
val clockWidgetFillHeight: Boolean = false,
val clockWidgetAlignment: ClockWidgetAlignment = ClockWidgetAlignment.Bottom,

View File

@@ -11,11 +11,16 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/** Bounds for the number of event rows the agenda shows before collapsing. */
const val MinCalendarPartRows = 1
const val MaxCalendarPartRows = 8
data class ClockWidgetParts(
val date: Boolean,
val music: Boolean = false,
val battery: BatteryStatusVisibility = BatteryStatusVisibility.Hide,
val alarm: Boolean = false,
val calendar: Boolean = false,
)
class ClockWidgetSettings internal constructor(
@@ -37,9 +42,62 @@ class ClockWidgetSettings internal constructor(
music = it.clockWidgetMusicPart,
battery = it.clockWidgetBatteryPart,
alarm = it.clockWidgetAlarmPart,
calendar = it.clockWidgetCalendarPart,
)
}.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)
}
}
/**
* Calendars the agenda does not show. Empty means "all calendars".
*/
val calendarPartExcludedCalendars
get() = launcherDataStore.data.map { it.clockWidgetCalendarPartExcludedCalendars }
.distinctUntilChanged()
fun setCalendarExcluded(calendarId: String, excluded: Boolean) {
launcherDataStore.update {
it.copy(
clockWidgetCalendarPartExcludedCalendars =
if (excluded) it.clockWidgetCalendarPartExcludedCalendars + calendarId
else it.clockWidgetCalendarPartExcludedCalendars - calendarId
)
}
}
/**
* Number of event rows in the agenda before it collapses into a "+N more events" row.
*/
val calendarPartMaxRows
get() = launcherDataStore.data
.map { it.clockWidgetCalendarPartMaxRows.coerceIn(MinCalendarPartRows, MaxCalendarPartRows) }
.distinctUntilChanged()
fun setCalendarPartMaxRows(rows: Int) {
launcherDataStore.update {
it.copy(
clockWidgetCalendarPartMaxRows = rows.coerceIn(MinCalendarPartRows, MaxCalendarPartRows)
)
}
}
fun setDatePart(datePart: Boolean) {
launcherDataStore.update {
it.copy(clockWidgetDatePart = datePart)

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

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