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.
196 lines
12 KiB
Markdown
196 lines
12 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=/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.
|