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.
181 lines
11 KiB
Markdown
181 lines
11 KiB
Markdown
# 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 4 rows — if there are more, the last row is a "+N more events"
|
||
hint that opens the calendar app. Tapping a row opens the event
|
||
(`CalendarEvent.launch`, i.e. `ACTION_VIEW` on `content://com.android.calendar/events/<id>`,
|
||
which KashCal handles). Rows reuse `CalendarEvent.getSummary()` from
|
||
`ui/launcher/search/calendar/CalendarItem.kt` (made `internal` for this), so an all-day event
|
||
reads "All day" and a timed one reads e.g. "09:00 – 09:30".
|
||
|
||
Rankings, in the order the zone prefers them: `CalendarPartProvider` 70 when an event is running or
|
||
starts within 30 minutes, else 30 while there are events left today, else 0 (so the date part keeps
|
||
the slot on an empty day); alarms 60 (ringing within 8 h); media 80 while playing; battery 55 when
|
||
low and 10 when charging or set to "always"; date 1. Tasks (`CalendarEvent.isTask`) are filtered out
|
||
of the agenda; a tasks part is planned but not implemented.
|
||
|
||
`CalendarPartProvider` keeps itself up to date with a `ContentObserver` on
|
||
`CalendarContract.Events.CONTENT_URI` plus a 15-minute ticker (the other calendar consumers in the
|
||
launcher only re-query when they become visible). `PartProvider.setTime()` is called every second
|
||
by the clock widget; the provider folds that into its ranking instead of re-querying.
|
||
|
||
The clock settings themselves are in `core/preferences`: `LauncherSettingsData` (new fields
|
||
`clockWidgetCalendarPart`, `clockWidgetDynamicZoneSlots`), `ClockWidgetParts` and the
|
||
`ClockWidgetSettings` accessors in `core/preferences/.../ui/ClockWidgetSettings.kt`, and the UI
|
||
state in `ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt`. New strings are English-only in
|
||
`core/i18n/src/main/res/values/strings.xml`; translations come from Crowdin upstream.
|
||
|
||
## Building
|
||
|
||
On the desktop:
|
||
|
||
JAVA_HOME=~/jdk21 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.
|
||
|
||
### 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.
|