Files
Kvaesitso/CLAUDE.md
Jonas Haugesen 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

13 KiB
Raw Permalink Blame History

Kvaesitso (fork with type-to-search and a home-screen agenda)

Fork of 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.