Files
EasyLauncher/CLAUDE.md
jonas a42f913875
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
refactor: use shared met-weather library for home-screen MET weather (v0.3.7)
- Replace internal MetApiService/MetForecastResponse + Retrofit MET fetch in
  AppHelper with dk.haugesenspil:met-weather (Gitea maven).
- fetchMetWeather now delegates to MetWeatherClient; 15-min cache kept.
- Drop MetApiService.kt and MetForecastResponse.kt.
release build v0.3.7
2026-08-20 12:58:52 +02:00

12 KiB

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

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 35 / 0.3.5).
  2. Build both flavors, then sign each:
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.5-Signed.apk \
  app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.5-Release.apk
# repeat for withoutInternet -> dist/EasyLauncher-v0.3.5-Signed.apk
  1. apksigner verify --print-certs should show the keystore SHA-256.
  2. 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 BadgeServicepublish 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)

  • Source: https://api.met.no/weatherapi/locationforecast/2.0/compactno API key, but REQUIRES a descriptive User-Agent header (see MetApiService). Verified working from PC and phone network.
  • AppHelper.fetchMetWeather(context, lat, lon) returns Celsius (rounded) + MET symbol_code; 15-minute cache in met_weather_prefs.
  • 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 Grantsuser=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.