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
- 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
12 KiB
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
- The installed app id is
app.easy.launcher— NOT upstream'scom.github.droidworksstudio.launcher. Every fork build must keep this id. - Release APKs are signed with
~/android-keystores/easylauncher-release.jks(aliaseasylauncher, pass ineasylauncher-release.pass). Never commit an unsigned or differently-signed APK todist/. adb install -r dist/...-Signed.apkupdates the phone in place (same signature). A debug build cannot be installed over it (signature mismatch).- 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.
- 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") andwithoutInternet. - 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)
- Bump
versionCode/versionNameinapp/build.gradle.kts(currently 38 / 0.3.8). - 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.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
apksigner verify --print-certsshould show the keystore SHA-256.- Commit as
release build vX.Y.Z(includes the dist APKs + .idsig files), annotated tagvX.Y.Z, pushmain+ tag toorigin(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 viauiautomator 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:
- Pull the file, edit locally, push back via stdin:
adb shell "su -c 'cat > <path>'" < localfile - 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. - Run
restorecon -F <file>— files created viasu catget the wrong SELinux context (s0instead ofs0:c18,c257,c512,c768) and the app can't read them (silent empty map). am force-stop app.easy.launcherthen 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 opensACTION_NOTIFICATION_LISTENER_SETTINGSwhen 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 withcmd notification post, see the log tagBadgeService—publish counts=lines). Re-posts overwrite the stored entry (in-placeNotification.numberupdates, 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_DOTSmust NOT be re-pushed fromobserveUserInterfaceSettings— the othersetShowXcalls do write prefs, and adding this one silently clobbered an on-disktrueback tofalseat startup (in-memory cache won). HomeFragment only observesshowNotificationDotsLiveDatato 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):
Expect a
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\"'"BadgeService: publish counts={... 0/com.termux.api=1}log and a red dot on the Termux:API icon in the drawer. Verify pixels withpython3/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-weatherlibrary (dk.haugesenspil:met-weatherfrom the Gitea maven registry, source in~/sources/met-weather):MetWeatherClient(met-weather 0.1.0)hitshttps://api.met.no/weatherapi/locationforecast/2.0/compact— no API key, but REQUIRES a descriptiveUser-Agentheader (passed inAppHelper.MET_USER_AGENT). Same library feedsmet-weather-providerand therefore the GadgetBridge/Skagen watch. AppHelper.fetchMetWeather(context, lat, lon)(suspend) returns Celsius (rounded) + METsymbol_codeviaMetWeatherClient; 15-minute cache inmet_weather_prefs. Bumpdk.haugesenspil:met-weatherversion here when the library is updated.- The icon is a Nerd Font weather glyph (
nf-weather-*, U+E300+ PUA), mapped from the symbol code inMetSymbolMapper. It renders with the bundledR.font.jetbrains_mono_nf_weather— a ~95KB fontTools subset of JetBrainsMonoNerdFont (ASCII + 38 weather glyphs + °). The phone's system font ISJetBrainsMonoNerdFont(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.xmlkeysLATITUDE/LONGITUDE(floats). MainActivity saves the real fix there; for testing you can seed e.g. Haugesund 59.4138 / 5.2680 (remember the.xmlsuffix +restorecon!).
App widgets on the Widgets screen
- Manager:
service/WidgetHostManager.kt(@Singleton) wrapsAppWidgetHost+AppWidgetManager. Host id is a fixed constant (0x4554); placed widgets are persisted inEasyLauncher.pref.xmlas a JSON array of[appWidgetId, flattenedComponent]pairs under theHOSTED_WIDGETSkey. - The launcher is the host only — it does NOT hold
BIND_APPWIDGET. On Android 12+bindAppWidgetIdIfAllowedrequires the caller to holdBIND_APPWIDGETor be in the system bind-widget allowlist stored in/data/system/users/<user>/appwidgets.xmlas<b packageName="..."/>(loaded intomPackagesWithBindWidgetPermission). Just being the default home app is NOT enough.launcher3works because it is a priv-app withBIND_APPWIDGET; a third-party launcher needs the grant. On this rooted phone that grant was added manually forapp.easy.launcher(see below). - Feature toggle:
SHOW_HOME_WIDGETS(default OFF, label "App widgets" in Settings → Features). Widgets render on the Widgets screen (theWidgetFragmentreached by the swipe gesture,ShowWidgets), not the home screen.WidgetFragment.setupHostedWidgets()restores persisted views andstartListening/stopListeningrun ononStart/onStop. Note:WidgetFragment.orderWidgetsBySettings()doesremoveAllViews()on the scroll container and re-adds only the self-drawn widgets —widgetHostAreamust be re-added there or it silently vanishes. - Add flow: "Add widget" → provider picker →
requestAddWidget(allocate + bind + launch the provider's configure activity viastartAppWidgetConfigureActivityForResult). Result arrives inMainActivity.onActivityResult(request codeWidgetHostManager.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
getAppWidgetInfois 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.launchergot bindable:adb shell "su -c 'stop'"(so the running service can't overwrite the edit on save).abx2xml /data/system/users/0/appwidgets.xml /data/local/tmp/aw.xml, add<b packageName="app.easy.launcher" />,xml2abxback,restorecon -F.adb shell "su -c 'start'"; verify withdumpsys 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 activityapp.k9mail.feature.widget.unread.UnreadWidgetConfigurationActivity. Both are enabled by default on this K-9 build (enabled=0manifests 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
ActionServiceisn't running. Enable the service in system settings to suppress it during testing. Application.setCustomFontpatches theTypefaceDEFAULT/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:
appListTouchAreaforwards vertical drags/ flings throughOnSwipeTouchListenerhooks (onVerticalScroll/onVerticalFling). Signs were verified against AOSP source:scrollBy(0, distanceY)is pass-through; fling takes the NEGATED pointer velocity.