LICENSE: verbatim copy of android-app-template's (official SPDX text, not retyped) — GPL-3.0 as previously decided. Update checker: check_latest_release()/download_and_install()/ MessagePopup ported from the template, wired to a new "Check for Updates" button next to Settings. Requires certifi added to p4a-recipes/kivy's python_depends (HTTPS on Android needs an explicit CA bundle) and INTERNET/REQUEST_INSTALL_PACKAGES added to buildozer.spec. FORGEJO_TOKEN at the top of main.py ships blank — update checking is inert until it's set to a repo-scoped read-only token. download_and_install() is the standard p4a pattern but not yet verified on real hardware, same caveat as the template it came from. Dropped the previously-agreed config.json split (event list vs. data path) — decided it's not needed after all.
20 KiB
20 KiB
CLAUDE.md
Context for future Claude sessions working on this repo.
Current status (2026-08-22)
- Desktop app (
event_tracker.py) is feature-complete and working on the user's machine: record buttons, stats popup, settings popup, reset, relocatable data path, taskbar icon, "Time Since" column. - Repo is pushed to
repo.tas-tech.net/tas-tech.net/event-tracker(private — will go public once there's a tagged release). - Android build (
android/) works end-to-end AND is confirmed running on-device —./build.sh android debugproducesandroid/bin/eventtracker-0.1-arm64-v8a-debug.apk, installs, and launches clean (adb logcatshowsStart application main loop, no crash). Five real, distinct bugs had to be fixed to get here (all now baked intobuild.sh/buildozer.spec/main.py, nothing left to redo):- p4a's
kivyrecipe pulling in unusedrequests/charset-normalizerthat couldn't actually install —android/p4a-recipes/kivy. - p4a's own internal pymodules venv corrupting itself on every
re-build —
build.shdeletes it before each run. - Gradle's build-script compiler rejecting this machine's default JDK
(java-26) —
build.shpinsJAVA_HOMEtojava-17-openjdkfor just this build. - Fix #1 above was applied too aggressively and dropped
filetypetoo, whichkivy/core/imagegenuinely needs — on-device startup crash (ModuleNotFoundError: No module named 'filetype'). Restored justfiletype. - p4a caches a built dist by recipe-name match and never re-derives
a recipe's
python_dependsfor an existing dist, so fix #4 above silently had zero effect until the stale dist directory (.buildozer/android/platform/build-<arch>/dists/eventtracker) was deleted to force a re-derive. Traced end-to-end throughpythonforandroid/distribution.py/graph.py, not guessed — see the dedicated entry below. See "Android companion — design decisions" below for the full root-cause writeup on each.
- p4a's
- LICENSE added — GPL-3.0, verbatim text copied from
android-app-template's copy (itself fetched from the official SPDX corpus, not reproduced from memory). Matches the distribution plan: free via F-Droid/Aurora, GPL permits charging for build/convenience on the Play Store without restricting what a recipient can do with the source. - In-app update checker ported from
android-app-template—check_latest_release()/download_and_install()/MessagePopupinandroid/main.py, wired to a "Check for Updates" button next to Settings. NeedsFORGEJO_TOKEN(top ofmain.py) set to a read-only token scoped to this repo before it'll do anything — ships blank on purpose, never bake in a real token. Same on-device caveat as the template's original: the download/install half (download_and_install()) is the standard, documented p4a pattern but genuinely NOT YET VERIFIED on real hardware — test it before relying on it. Needed two build-tooling additions to support it:certifiinp4a-recipes/kivy'spython_depends(HTTPS on Android's cross-compiled OpenSSL needs an explicit CA bundle — see the recipe file's comments) andINTERNET/REQUEST_INSTALL_PACKAGESadded tobuildozer.spec'sandroid.permissions. - Repo is now public (was private) — the "outstanding" config.json split idea (event list vs. data-path file) was discussed and dropped; not needed, don't reintroduce it without being asked again.
- Standing rule, unchanged: never
git pushwithout asking the user first, even though this repo now has a remote and history — treat every future push as needing fresh confirmation, not a standing yes.
What this is
Single-file GTK3 + matplotlib desktop app (event_tracker.py,
despite the filename it's now multi-event and the event list is user
editable). One button per entry in self.events records a timestamp to
a CSV file; the main window shows a total/avg-hour/avg-day row per event
and a line graph of today's counts by hour. A stats popup adds
streaks/trends and by-hour/week/month charts; a settings popup renames
or adds events and relocates the data file.
Design decisions
- CSV, not sqlite — dataset is tiny (a handful of rows/day), and a flat file lets the user hand-edit a bad entry without tooling.
- No inline magic numbers — all environment-specific values
(
DEFAULT_EVENTS,COLOR_PALETTE,DEFAULT_DATA_PATH,CONFIG_PATH,WEEKLY_WEEKS,MONTHLY_MONTHS,TIMESTAMP_FORMAT) live in a config block at the top of the file. Change those, not the logic below. - GTK3 over Tkinter/Qt — native widget set on the target desktop (EndeavourOS/GNOME-ish), no pip-installed GUI runtime to drag around.
- Malformed CSV rows are skipped, not fatal —
load_entries()tolerates a hand-edited file rather than crashing the app on startup. - One CSV,
event+timestampcolumns — chosen over one file per event so stats/graph code doesn't duplicate per event.ensure_csv()auto-migrates the original single-column format on first run (existing rows relabelled to the first configured event). - Reset backs up, never deletes outright —
reset_log()copies the current CSV to a timestamped.bak-*file before truncating. The confirmation dialog inTrackerWindow.on_resetis the only path to this, and always warns before calling it. - Integer y-axis ticks —
MaxNLocator(integer=True)on every graph; event counts are always whole numbers, so fractional ticks (e.g. 0.2) would be misleading. compute_stats()/weekly_counts()/monthly_counts()/format_elapsed()are pure — take a sorted list of datetimes (or a timedelta) for one event, return plain dicts/tuples/strings, no GTK/matplotlib references. Keeps the date math testable without a display.- "Time Since" ticks live, not just on action —
GLib.timeout_add_seconds(60, ...)inTrackerWindow.__init__callsrefresh()every 60s so the elapsed time keeps counting up without a new event or button click. This is the one thing in the UI that changes purely with wall-clock time. - Event list is config, not data — stored separately in
config.json(viaload_config()/save_config()), not in the CSV.self.eventsonTrackerWindowis the live, mutable copy; renaming in Settings callsrename_event_in_csv()so historical rows follow the rename rather than becoming orphaned. Colors are derived from an event's position in the list (event_color()), not stored per-event, so there's nothing to keep in sync when the list changes. - Data file location is also config, and is itself relocatable —
every CSV function (
ensure_csv,load_entries,append_entry,rename_event_in_csv,reset_log) takescsv_pathas an explicit argument rather than reading a module constant, because it can change at runtime via Settings.self.data_pathonTrackerWindowis the live value;CONFIG_PATHitself (where that value is recorded) stays fixed — it's a small pointer file, not the user's data, so it isn't exposed as something to relocate.move_data_file()backs up whatever's already at a destination before overwriting it, same belt-and-suspenders approach asreset_log(). - Widget rebuild over dynamic-only display —
rebuild_event_widgets()destroys and recreates the record buttons and stats-grid rows when the event list changes, rather than trying to diff them. Simpler and this only runs on an explicit Settings save, not per-frame. - App icon requires both a window icon and WM_CLASS matching — GTK's
window icon alone isn't enough for KDE/GNOME taskbars to pick the
right icon; without a WM_CLASS matching the
.desktopfile'sStartupWMClass, the taskbar can fall back to an unrelated running app's icon.main()callsGLib.set_prgname(WM_CLASS)before creating any window, andWM_CLASSmust stay identical toStartupWMClassinevent-tracker.desktop.in. - Icon is PNG, not SVG, on purpose —
event-tracker.svgstill ships (source of truth for the design, regenerate the PNG from it if the icon changes), butICON_FILEpoints atevent-tracker.pngand the.desktopfile'sIcon=does too. SVG decoding depends on the system's gdk-pixbuf loader modules (librsvg's loader specifically), which isn't guaranteed present/registered even when librsvg itself is installed — confirmed missing on the dev machine viagdk-pixbuf-query-loaders | grep svgreturning nothing despite librsvg being installed. PNG decoding is core gdk-pixbuf, no optional loader involved, so it isn't a system-dependent single point of failure. Both files are loaded by absolute path — no icon-theme install/cache-refresh step required either way.
Repo layout
event_tracker.py # the desktop GTK app
event-tracker.desktop.in # launcher template (no machine-specific path baked in)
install-launcher.sh # generates event-tracker.desktop for this clone's actual path and installs it
event-tracker.svg # icon source (edit this if the design changes)
event-tracker.png # icon actually referenced by the app + launcher, regenerate from the .svg
README.md # install/run instructions for the desktop app
CLAUDE.md # this file
android/
main.py # minimal Kivy touchscreen app, v1 — see its own README for scope
buildozer.spec # Android build config
README.md # build/install/permission instructions specific to Android
setup-build-env.sh # creates the isolated venv for buildozer/cython
build.sh # runs buildozer through that venv (sets PATH + VIRTUAL_ENV by hand)
p4a-recipes/kivy/ # local override of p4a's built-in kivy recipe — see design decisions below
Android companion — design decisions
- Same CSV format, deliberately zero coupling otherwise — the
Android app only knows the
event,timestampcolumn format andTIMESTAMP_FORMAT; it has no code path that talks to the desktop app or assumes anything about it. The two apps stay in sync purely because they read/write the same file, which some third-party sync tool (FolderSync/DAVx5) mirrors between devices — that sync is out of scope for both apps and is the user's own infrastructure. - v1 is intentionally thin (record buttons, today's totals, one bar)
— Android build tooling (buildozer/SDK/NDK) is the actual risk on a
first build, not app logic. Shipping the smallest thing that proves
the pipeline works, before porting the stats popup / settings / rename
/ charts from the desktop app, was a deliberate choice — see
android/README.md. - No matplotlib on Android — unreliable through Kivy's garden
packaging.
BarRowinmain.pyhand-draws a proportional bar with Kivy's ownColor/Rectanglecanvas primitives instead. - Wrapping
Labels bindtext_sizeto their ownwidth, never set it once fromself.widthin aPopup.__init__—SettingsPopup's info label originally didtext_size=(self.width, None)at construction time, but the Popup hasn't been laid out yet at that point, soself.widthis Kivy's ~100px pre-layout default rather than the popup's real size. That locked the label into wrapping at ~100px forever — rendered on-device as a 1-character-wide vertical column of text. Fixed by binding instead:label.bind(width=lambda inst, w: setattr(inst, "text_size", (w, None)))after construction. Same bug, same fix, also applied toandroid-app-template'sSettingsPopup/LegalPopup(that repo had it in four places) — see that repo'sCLAUDE.md. - Config path is local-only, same principle as desktop's
CONFIG_PATH— each device's app-private storage holds only that device's CSV path; it is never itself synced. Only the CSV — the actual data — goes through the user's sync tool. MANAGE_EXTERNAL_STORAGEpermission — the CSV can live in an arbitrary user-chosen folder (wherever the sync tool puts it), so scoped storage / SAF alone isn't enough; broad file access is required and must additionally be granted manually in Android settings post-install (seeandroid/README.mdstep 5) — Android doesn't allow silently requesting this one like the legacy permissions.- The APK is built by the user, on their own machine — this was
never attempted in the cloud sandbox that authored the code: no
reliable way to fetch a multi-GB Android SDK/NDK there, and "run
buildozer android debugon your own box" is the correct place for this regardless. - Known gap, not silently glossed over: whole-file sync tools don't
merge concurrent CSV writes from two devices — logging on both within
the same sync window risks a conflicted duplicate file rather than
merged rows. Documented in
android/README.md, not solved in code. - Build tooling lives in a venv, never
--break-system-packages—setup-build-env.shcreates a plainpython3 -m venv(no--system-site-packages— that was tried and reverted, see next point) atandroid/build/venvand installsbuildozer/cythoninto it.build.shruns buildozer by absolute path rather thansource-ing the venv, so it manually replicates the two things activation would do: putsvenv/binonPATH(buildozer shells out to findcython) and setsVIRTUAL_ENV(buildozer's owntargets/android.pychecks this env var to decide whether its bootstrappip installneeds--user; without it, it wrongly assumes it's not in a venv, uses--user, and pip refuses since there's no user site-packages inside an isolated venv). p4a-recipes/kivy— local override, not a workaround — p4a's own built-inkivyrecipe declarespython_depends = ['certifi', 'chardet', 'idna', 'requests', 'urllib3', 'filetype'](most of that list only forkivy.network.UrlRequest, which this app never calls).requeststransitively needscharset-normalizer, whose newest release is the first to ship a prebuilt Android-tagged wheel (new, tied to CPython 3.14's official Android build target). p4a's own dependency resolver (process_python_modulesinpythonforandroid/build.py) finds that wheel correctly during itspip --dry-run --platform=android_24_arm64_v8apass and pins the exact URL into a generatedrequirements.txt— but the next stage (run_pymodules_install) installs from that file with a plain host pip and no--platformoverride, so it rejects its own pinned wheel as "not a supported wheel on this platform." That's a real upstream p4a bug, confirmed by readingpythonforandroid/build.pydirectly (not guessed) — andpython_dependsisn't somethingbuildozer.speccan override, sop4a-recipes/kivy/__init__.pyis a full copy of the upstream recipe (patches included) keeping onlyfiletype—kivy/core/image/__init__.pyimports it directly for image format sniffing, confirmed by an on-device startup crash (ModuleNotFoundError: No module named 'filetype') the one time it got dropped along with the rest.filetypeis pure Python with zero dependencies of its own, so it doesn't reintroduce the requests/charset-normalizer chain.buildozer.specpoints at the override viap4a.local_recipes = p4a-recipes. If a future Kivy release actually needs one of the other four back, re-diff against p4a's currentpythonforandroid/recipes/kivy/__init__.pybefore blindly re-adding them.- Editing
p4a-recipes/kivy/__init__.pyalone is not enough — p4a caches the built dist and may never re-read it.Distribution.get_ distribution()(pythonforandroid/distribution.py) matches an existing dist purely by name (eventtracker) and recipe-name list (python3,kivy, etc. — frombuildozer.spec'srequirements =line).python_dependsisn't a recipe name, so it's never consulted during that match; if a dist with the same name+recipes already exists, it's reused wholesale withneeds_build = False, andbuild_dist_from_args()— the only function that readsrecipe.python_dependsand pip-installs pymodules (graph.py:334→run_pymodules_install) — never runs at all. This is exactly what happened restoringfiletype: the recipe file was correct on disk, but the next build reused the stale dist and the APK still crashed identically. Confirmed two ways: unpacked the built APK'slibpybundle.soand found nofiletypeanywhere in it, then traced the caching logic indistribution.py/graph.pyline by line. Fix: delete the stale dist directory soDistribution.get_distributions()'s glob finds nothing andneeds_buildfalls back toTrue—rm -rf android/.buildozer/android/platform/build-<arch>/dists/<package.name>— then rebuild. This only redoes packaging (pymodule install + Gradle assembly), not the expensive native recipe compilation, so it's fast (single-digit seconds of Gradle work in practice). Anytime a recipe'spython_dependschanges (notbuildozer.spec'srequirements =line, which p4a does check), delete the dist before the next build or the change silently won't take effect. - Previously-tried and reverted:
--system-site-packageson the build venv. It fixed the--user/pip-in-venv error above, but leaked the real system Python's installed packages into what p4a decided the app needed to bundle (confirmed viapip show requestsshowing a system-wide install), which is a worse, harder-to-diagnose failure mode than the one it fixed. Don't reintroduce it as a quick fix for a future build error without checking whether it's actually leaking something first. build.shdeletes p4a's internal pymodules venv before every run — separate fromandroid/build/venv(ours, for buildozer/cython) and separate from thep4a-recipesbug above. p4a creates its own throwaway venv per arch at.buildozer/android/platform/build-<arch>/build/venvto pip-install pure-Python deps, and unconditionally re-runspython -m venv venvover it on every build without--clear. First build: ensurepip bootstraps whatever pip version shipped with this system's Python (25.3). p4a then runspip install -U pipinside it (26.2.1). Next build:python -m venvre-invokes ensurepip, which unpacks its older bundled pip back over the newer one without removing the newer version's now-orphaned files — confirmed by hand, bothpip-25.3.dist-infoandpip-26.2.1.dist-infopresent in the same site-packages afterward, producing exactly the kind of mismatched-file crash you'd expect (ImportError: cannot import name 'BuildDependencyInstallError' from 'pip._internal.exceptions'— a newer file expecting a class only the newer exceptions.py defines).build.shjust deletes that venv before every buildozer invocation; costs a few seconds (pip + Cython reinstall in a tiny throwaway venv), leaves the expensive SDK/NDK/recipe build state untouched.build.shpinsJAVA_HOMEtojava-17-openjdkfor the build only — this machine'sarchlinux-javadefault is java-26 (rolling release), and Gradle 8.14.3 (pulled in by this p4a version) ships a Groovy/ASM build-script compiler that can't parse that new a class file:BUG! ... Unsupported class file major version 70. java-17 is installed alongside the default (archlinux-java statuslists both);JAVA_HOME_FOR_BUILDat the top ofbuild.shpoints at it without touching the system-wide default JDK used by everything else. If this ever needs to change (different machine, JDK package renamed), that's the one line to edit —build.shfails loudly with a clear message if the path doesn't exist rather than silently falling through to whateverjavaresolves to on PATH.
Conventions for changes
- Keep the desktop app single-file unless it grows enough to justify splitting (e.g. a separate stats module) — ask before restructuring.
- Any new tunable value goes in the config block at the top of whichever file it belongs to, never buried in a function body.
- Keep the CSV format (
event,timestamp,TIMESTAMP_FORMAT) identical acrossevent_tracker.pyandandroid/main.py— changing one without the other breaks cross-device sync silently. - Git remote is
repo.tas-tech.net(Forgejo), auth via~/.netrc, userAI_Assistant— never push without asking the user first.