Files
EasyLauncher/CLAUDE.md
jonas 44d7e81801
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Android Release CI / Build, Sign & Release (push) Has been cancelled
Nightly Release / release (push) Has been cancelled
Nightly Release / Build, Sign & Release (push) Has been cancelled
Delete Unused Caches / delete (push) Has been cancelled
Close Inactive Issues & Pull Requests / close-issues (push) Has been cancelled
GitHub Actions Version Updater / build (push) Has been cancelled
fix(weather): keep met-weather DTOs from R8 so Gson works; add +4h reading (v0.3.8)
- ProGuard keep rule for dk.haugesenspil.metweather.** (R8 was obfuscating the
  Gson DTOs -> ClassCastException, weather glyph vanished).
- Home current-weather now shows 'current  ->  ~4h' (nearest MET hourly point),
  cached in met_weather_prefs.
release build v0.3.8
2026-08-20 13:19:21 +02:00

220 lines
12 KiB
Markdown

# Working with this EasyLauncher fork
Quick reference for agents working on this fork (Gitea: `jonas/EasyLauncher`,
installed on the Unihertz Titan 2 Elite as `app.easy.launcher`).
**CLAUDE.md in ~/titan2-elite is the source of truth for the phone itself.**
## Golden rules
1. The installed app id is **`app.easy.launcher`** — NOT upstream's
`com.github.droidworksstudio.launcher`. Every fork build must keep this id.
2. Release APKs are signed with `~/android-keystores/easylauncher-release.jks`
(alias `easylauncher`, pass in `easylauncher-release.pass`). Never commit an
unsigned or differently-signed APK to `dist/`.
3. `adb install -r dist/...-Signed.apk` updates the phone in place (same
signature). A debug build cannot be installed over it (signature mismatch).
4. After a phone reboot the phone is CE-locked until the user enters the PIN;
app prefs/location are not visible to the app until then. Ask the user to
unlock instead of debugging it.
5. Never fetch weather without a real location (lat/lon 0,0 must be treated as
"no location").
## Build
```bash
export JAVA_HOME=~/jdk21 # Gradle 8.13, compileSdk/targetSdk 36, minSdk 24
./gradlew :app:compileWithInternetReleaseKotlin :app:compileWithoutInternetReleaseKotlin --offline
./gradlew :app:assembleWithInternetRelease :app:assembleWithoutInternetRelease --offline
```
- Two flavors: `withInternet` (INTERNET + location permissions, "Easy Launcher")
and `withoutInternet`.
- Outputs: `app/build/outputs/apk/{withInternet,withoutInternet}/release/`.
- `weather.properties` (OpenWeatherMap key) is optional and absent here — the
OWM weather widget is dead without it. The home-screen current-weather
element uses the MET/Yr API instead (no key needed, see below).
## Release flow (see git history for the v0.3.x pattern)
1. Bump `versionCode`/`versionName` in `app/build.gradle.kts`
(currently 38 / 0.3.8).
2. Build both flavors, then sign each:
```bash
PASS=$(cat ~/android-keystores/easylauncher-release.pass)
~/android-sdk/build-tools/36.0.0/apksigner sign --v4-signing-enabled true \
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
--out dist/EasyLauncher-Internet-v0.3.8-Signed.apk \
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.7-Release.apk
# repeat for withoutInternet -> dist/EasyLauncher-v0.3.8-Signed.apk
```
3. `apksigner verify --print-certs` should show the keystore SHA-256.
4. Commit as `release build vX.Y.Z` (includes the dist APKs + .idsig files),
annotated tag `vX.Y.Z`, push `main` + tag to `origin` (Gitea), create a
Gitea release with both APKs as assets.
## On-device testing (Titan 2 Elite via adb)
- Wake: `input keyevent KEYCODE_WAKEUP`; go home: `input keyevent KEYCODE_HOME`.
- Open the app drawer: `input keyevent KEYCODE_SPACE` (the physical-keyboard
"key press -> app list" trigger; requires MainActivity focused).
- Scroll the drawer from the right edge: `input swipe 1000 850 1000 250 250`.
- Screenshot: `adb exec-out screencap -p > s.png`; view text via
`uiautomator dump`.
- Inspect the launcher's own state with `su -c '...'` (root via Magisk).
### Editing app prefs from the host (fragile — read carefully)
Prefs live in `/data/data/app.easy.launcher/shared_prefs/`. To change them:
1. Pull the file, edit locally, push back via stdin:
`adb shell "su -c 'cat > <path>'" < localfile`
2. **The file name must end in `.xml`** (`EasyLauncher.pref.xml`,
`EasyWeather.pref.xml`) — `getSharedPreferences("name")` appends `.xml`.
A missing suffix silently reads as an EMPTY map.
3. **Run `restorecon -F <file>`** — files created via `su cat` get the wrong
SELinux context (`s0` instead of `s0:c18,c257,c512,c768`) and the app can't
read them (silent empty map).
4. `am force-stop app.easy.launcher` then press HOME so the process restarts
and re-reads the file.
### Notification dots
- Service: `.service.NotificationBadgeService` (NotificationListenerService).
Users must grant notification access; the "Notification Dots" settings toggle
opens `ACTION_NOTIFICATION_LISTENER_SETTINGS` when missing.
- Badge semantics: per package the service sums `Notification.number` (or 1
when unset) over COUNTED notifications — same as AOSP Launcher3. Counted
= clearable (swipe-away), NON-ongoing, NON group-summary, userId >= 0.
Group summaries and USER_ALL (-1) records are excluded (verified with
`cmd notification post`, see the log tag `BadgeService``publish counts=`
lines). Re-posts overwrite the stored entry (in-place `Notification.number`
updates, e.g. K-9 unread count), and a notification that becomes
non-counted is dropped.
- The dot is a plain red circle (NO white outline), drawn tangent to the
icon's top-right corner so it is never clipped (`NotificationDotHelper`).
- NOTE: the dot follows ACTIVE notifications (AOSP/Pixel behavior). Apps that
cancel their notification when opened — K-9 Mail does this on every open —
make the dot disappear even when unread mail remains. There is NO Android
API to read an app's unread count (K-9 v22 exposes no provider and no
AccountManager accounts; counts live in its private Room DBs). This is
standard Android behavior, not a bug in this launcher.
- Pref writes: `SHOW_NOTIFICATION_DOTS` must NOT be re-pushed from
`observeUserInterfaceSettings` — the other `setShowX` calls do write prefs,
and adding this one silently clobbered an on-disk `true` back to `false`
at startup (in-memory cache won). HomeFragment only observes
`showNotificationDotsLiveData` to rebind visible rows on toggle.
- Grant from adb:
`cmd notification allow_listener app.easy.launcher/com.github.droidworksstudio.launcher.service.NotificationBadgeService`
- End-to-end validation trick (the shell can't spoof other packages): run
Termux's termux-notification as the user that owns com.termux.api (has a
launcher icon + posts a clearable notification):
```
adb shell "su -c 'su 10261 -c \"PATH=/data/data/com.termux/files/usr/bin:\$PATH \
termux-notification --id reddot-test --title RedDotTest --content Clearable\"'"
```
Expect a `BadgeService: publish counts={... 0/com.termux.api=1}` log and a
red dot on the Termux:API icon in the drawer. Verify pixels with
`python3`/PIL (dot = pure red, top-right of the icon box).
- Counts live in `NotificationBadgeService.notificationCounts`
(StateFlow, key `"userId/packageName"`); fragments rebind visible rows.
### Home current-weather element (MET/Yr)
- Fetched via the **shared `met-weather` library** (`dk.haugesenspil:met-weather`
from the Gitea maven registry, source in `~/sources/met-weather`):
`MetWeatherClient(`met-weather 0.1.0`)` hits
`https://api.met.no/weatherapi/locationforecast/2.0/compact` — **no API
key**, but REQUIRES a descriptive `User-Agent` header (passed in
`AppHelper.MET_USER_AGENT`). Same library feeds `met-weather-provider` and
therefore the GadgetBridge/Skagen watch.
- `AppHelper.fetchMetWeather(context, lat, lon)` (suspend) returns Celsius
(rounded) + MET `symbol_code` via `MetWeatherClient`; 15-minute cache in
`met_weather_prefs`. Bump `dk.haugesenspil:met-weather` version here when the
library is updated.
- The icon is a **Nerd Font weather glyph** (`nf-weather-*`, U+E300+ PUA),
mapped from the symbol code in `MetSymbolMapper`. It renders with the bundled
`R.font.jetbrains_mono_nf_weather` — a ~95KB fontTools subset of
JetBrainsMonoNerdFont (ASCII + 38 weather glyphs + °). The phone's system
font IS `JetBrainsMonoNerdFont` (fonts.xml default), but the bundled subset
guarantees rendering even if the launcher-font setting changes. Regenerate
the subset with fontTools if more glyphs are ever needed (source:
`/system/fonts/JetBrainsMonoNerdFont-Regular.ttf`).
- Display mirrors the daily word (uses the daily-word color/size/alignment
prefs); text = `"$glyph $temp°"` (two spaces).
- Needs a real saved location: `EasyWeather.pref.xml` keys `LATITUDE`/
`LONGITUDE` (floats). MainActivity saves the real fix there; for testing you
can seed e.g. Haugesund 59.4138 / 5.2680 (remember the `.xml` suffix +
`restorecon`!).
### App widgets on the Widgets screen
- Manager: `service/WidgetHostManager.kt` (`@Singleton`) wraps
`AppWidgetHost` + `AppWidgetManager`. Host id is a fixed constant
(0x4554); placed widgets are persisted in `EasyLauncher.pref.xml` as a JSON
array of `[appWidgetId, flattenedComponent]` pairs under the `HOSTED_WIDGETS`
key.
- The launcher is the *host* only — it does NOT hold `BIND_APPWIDGET`. On
Android 12+ `bindAppWidgetIdIfAllowed` requires the caller to hold
`BIND_APPWIDGET` **or** be in the system bind-widget allowlist stored in
`/data/system/users/<user>/appwidgets.xml` as `<b packageName="..."/>`
(loaded into `mPackagesWithBindWidgetPermission`). Just being the default
home app is NOT enough. `launcher3` works because it is a priv-app with
`BIND_APPWIDGET`; a third-party launcher needs the grant. On this rooted
phone that grant was added manually for `app.easy.launcher` (see below).
- Feature toggle: `SHOW_HOME_WIDGETS` (default OFF, label "App widgets" in
Settings → Features). Widgets render on the **Widgets** screen (the
`WidgetFragment` reached by the swipe gesture, `ShowWidgets`), not the home
screen. `WidgetFragment.setupHostedWidgets()` restores persisted views and
`startListening`/`stopListening` run on `onStart`/`onStop`. **Note:**
`WidgetFragment.orderWidgetsBySettings()` does `removeAllViews()` on the
scroll container and re-adds only the self-drawn widgets — `widgetHostArea`
must be re-added there or it silently vanishes.
- Add flow: "Add widget" → provider picker → `requestAddWidget` (allocate +
bind + launch the provider's configure activity via
`startAppWidgetConfigureActivityForResult`). Result arrives in
`MainActivity.onActivityResult` (request code `WidgetHostManager.REQUEST_ADD_WIDGET`)
→ `onConfigureResult` → view created + persisted. The picker is dismissed on
selection.
- Remove: each hosted widget gets a small "✕" overlay owned by us (top-right
of the wrapped view) plus a long-press handler. Long-press may not fire on
fully interactive widgets (e.g. K-9's counts widget opens the app on
touch), so the "✕" is the reliable path — both call `removeWidget`.
- Restore is **non-destructive under CE-lock**: if `getAppWidgetInfo` is null
while the user is locked it keeps the persisted record and skips (widgets
reappear after the PIN is entered and the effect is revisited); it only
drops a record when the user is unlocked. This is what makes widgets survive
a reboot.
- Grant on this phone (root), i.e. how `app.easy.launcher` got bindable:
1. `adb shell "su -c 'stop'"` (so the running service can't overwrite the
edit on save).
2. `abx2xml /data/system/users/0/appwidgets.xml /data/local/tmp/aw.xml`, add
`<b packageName="app.easy.launcher" />`, `xml2abx` back, `restorecon -F`.
3. `adb shell "su -c 'start'"`; verify with
`dumpsys appwidget | grep -A2 Grants` → `user=0 package=app.easy.launcher`.
Editing the file while the service is running is useless: the shutdown save
overwrites it.
- K-9's "Unread count" widget: provider
`com.fsck.k9/com.fsck.k9.provider.UnreadWidgetProvider`, configure activity
`app.k9mail.feature.widget.unread.UnreadWidgetConfigurationActivity`. Both
are enabled by default on this K-9 build (`enabled=0` manifests as
resolvable). Placing it asks for an account/folder ("Unified Inbox" = all
accounts) and then renders a persistent live unread count.
## Known behavior quirks
- The accessibility-service dialog ("Please turn on accessibility service to
use double tap to lock") pops on home when `ActionService` isn't running.
Enable the service in system settings to suppress it during testing.
- `Application.setCustomFont` patches the `Typeface` DEFAULT/MONO/SERIF/SANS
static fields when a non-System launcher font is selected.
- With "Disable Animations" on, the drawer RecyclerView item animator is null
and navigation transitions are skipped.
- Drawer whole-screen scrolling: `appListTouchArea` forwards vertical drags/
flings through `OnSwipeTouchListener` hooks (`onVerticalScroll`/`onVerticalFling`).
Signs were verified against AOSP source: `scrollBy(0, distanceY)` is
pass-through; fling takes the NEGATED pointer velocity.