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.
334 lines
20 KiB
Markdown
334 lines
20 KiB
Markdown
# 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 debug` produces
|
|
`android/bin/eventtracker-0.1-arm64-v8a-debug.apk`, installs, and
|
|
launches clean (`adb logcat` shows `Start application main loop`, no
|
|
crash). Five real, distinct bugs had to be fixed to get here (all now
|
|
baked into `build.sh`/`buildozer.spec`/`main.py`, nothing left to
|
|
redo):
|
|
1. p4a's `kivy` recipe pulling in unused `requests`/`charset-normalizer`
|
|
that couldn't actually install — `android/p4a-recipes/kivy`.
|
|
2. p4a's own internal pymodules venv corrupting itself on every
|
|
re-build — `build.sh` deletes it before each run.
|
|
3. Gradle's build-script compiler rejecting this machine's default JDK
|
|
(java-26) — `build.sh` pins `JAVA_HOME` to `java-17-openjdk` for
|
|
just this build.
|
|
4. Fix #1 above was applied too aggressively and dropped `filetype`
|
|
too, which `kivy/core/image` genuinely needs — on-device startup
|
|
crash (`ModuleNotFoundError: No module named 'filetype'`). Restored
|
|
just `filetype`.
|
|
5. p4a caches a built dist by recipe-name match and never re-derives
|
|
a recipe's `python_depends` for 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 through
|
|
`pythonforandroid/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.
|
|
- **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()`/`MessagePopup` in
|
|
`android/main.py`, wired to a "Check for Updates" button next to
|
|
Settings. Needs `FORGEJO_TOKEN` (top of `main.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:
|
|
`certifi` in `p4a-recipes/kivy`'s `python_depends` (HTTPS on
|
|
Android's cross-compiled OpenSSL needs an explicit CA bundle — see
|
|
the recipe file's comments) and `INTERNET`/`REQUEST_INSTALL_PACKAGES`
|
|
added to `buildozer.spec`'s `android.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 push` without 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` + `timestamp` columns** — 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 in `TrackerWindow.on_reset` is 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, ...)`
|
|
in `TrackerWindow.__init__` calls `refresh()` 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` (via `load_config()`/`save_config()`), not in the CSV.
|
|
`self.events` on `TrackerWindow` is the live, mutable copy; renaming
|
|
in Settings calls `rename_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`) takes `csv_path` as an explicit
|
|
argument rather than reading a module constant, because it can change
|
|
at runtime via Settings. `self.data_path` on `TrackerWindow` is the
|
|
live value; `CONFIG_PATH` itself (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 as `reset_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 `.desktop` file's
|
|
`StartupWMClass`, the taskbar can fall back to an unrelated running
|
|
app's icon. `main()` calls `GLib.set_prgname(WM_CLASS)` before creating
|
|
any window, and `WM_CLASS` must stay identical to `StartupWMClass` in
|
|
`event-tracker.desktop.in`.
|
|
- **Icon is PNG, not SVG, on purpose** — `event-tracker.svg` still ships
|
|
(source of truth for the design, regenerate the PNG from it if the
|
|
icon changes), but `ICON_FILE` points at `event-tracker.png` and the
|
|
`.desktop` file's `Icon=` 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 via
|
|
`gdk-pixbuf-query-loaders | grep svg` returning 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,timestamp` column format and
|
|
`TIMESTAMP_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. `BarRow` in `main.py` hand-draws a proportional bar with
|
|
Kivy's own `Color`/`Rectangle` canvas primitives instead.
|
|
- **Wrapping `Label`s bind `text_size` to their own `width`, never set
|
|
it once from `self.width` in a `Popup.__init__`** — `SettingsPopup`'s
|
|
info label originally did `text_size=(self.width, None)` at
|
|
construction time, but the Popup hasn't been laid out yet at that
|
|
point, so `self.width` is 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 to
|
|
`android-app-template`'s `SettingsPopup`/`LegalPopup` (that repo had
|
|
it in four places) — see that repo's `CLAUDE.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_STORAGE` permission** — 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
|
|
(see `android/README.md` step 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 debug` on 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.sh` creates a plain `python3 -m venv` (no
|
|
`--system-site-packages` — that was tried and reverted, see next
|
|
point) at `android/build/venv` and installs `buildozer`/`cython` into
|
|
it. `build.sh` runs buildozer by absolute path rather than
|
|
`source`-ing the venv, so it manually replicates the two things
|
|
activation would do: puts `venv/bin` on `PATH` (buildozer shells out
|
|
to find `cython`) and sets `VIRTUAL_ENV` (buildozer's own
|
|
`targets/android.py` checks this env var to decide whether its
|
|
bootstrap `pip install` needs `--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-in `kivy` recipe declares
|
|
`python_depends = ['certifi', 'chardet', 'idna', 'requests', 'urllib3', 'filetype']`
|
|
(most of that list only for `kivy.network.UrlRequest`, which this app
|
|
never calls). `requests` transitively needs `charset-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_modules` in
|
|
`pythonforandroid/build.py`) finds that wheel correctly during its
|
|
`pip --dry-run --platform=android_24_arm64_v8a` pass and pins the
|
|
exact URL into a generated `requirements.txt` — but the *next* stage
|
|
(`run_pymodules_install`) installs from that file with a plain host
|
|
pip and no `--platform` override, so it rejects its own pinned wheel
|
|
as "not a supported wheel on this platform." That's a real upstream
|
|
p4a bug, confirmed by reading `pythonforandroid/build.py` directly
|
|
(not guessed) — and `python_depends` isn't something `buildozer.spec`
|
|
can override, so `p4a-recipes/kivy/__init__.py` is a full copy of the
|
|
upstream recipe (patches included) keeping only `filetype` —
|
|
`kivy/core/image/__init__.py` imports 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. `filetype` is pure Python with zero
|
|
dependencies of its own, so it doesn't reintroduce the
|
|
requests/charset-normalizer chain. `buildozer.spec` points at the
|
|
override via `p4a.local_recipes = p4a-recipes`. If a future Kivy
|
|
release actually needs one of the other four back, re-diff against
|
|
p4a's current `pythonforandroid/recipes/kivy/__init__.py` before
|
|
blindly re-adding them.
|
|
- **Editing `p4a-recipes/kivy/__init__.py` alone 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. — from `buildozer.spec`'s `requirements =`
|
|
line). `python_depends` isn'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 with `needs_build = False`, and
|
|
`build_dist_from_args()` — the only function that reads
|
|
`recipe.python_depends` and pip-installs pymodules
|
|
(`graph.py:334` → `run_pymodules_install`) — never runs at all. This
|
|
is exactly what happened restoring `filetype`: 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's `libpybundle.so` and found no `filetype` anywhere in it, then
|
|
traced the caching logic in `distribution.py`/`graph.py` line by
|
|
line. Fix: delete the stale dist directory so
|
|
`Distribution.get_distributions()`'s glob finds nothing and
|
|
`needs_build` falls back to `True` —
|
|
`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's* `python_depends` changes (not `buildozer.spec`'s
|
|
`requirements =` 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-packages` on 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 via `pip show requests` showing 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.sh` deletes p4a's internal pymodules venv before every run** —
|
|
separate from `android/build/venv` (ours, for buildozer/cython) and
|
|
separate from the `p4a-recipes` bug above. p4a creates its own
|
|
throwaway venv per arch at
|
|
`.buildozer/android/platform/build-<arch>/build/venv` to pip-install
|
|
pure-Python deps, and unconditionally re-runs `python -m venv venv`
|
|
over it on *every* build without `--clear`. First build: ensurepip
|
|
bootstraps whatever pip version shipped with this system's Python
|
|
(25.3). p4a then runs `pip install -U pip` inside it (26.2.1). Next
|
|
build: `python -m venv` re-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, both
|
|
`pip-25.3.dist-info` and `pip-26.2.1.dist-info` present 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.sh` just 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.sh` pins `JAVA_HOME` to `java-17-openjdk` for the build only**
|
|
— this machine's `archlinux-java` default 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 status` lists both);
|
|
`JAVA_HOME_FOR_BUILD` at the top of `build.sh` points 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.sh` fails loudly with a clear message if
|
|
the path doesn't exist rather than silently falling through to
|
|
whatever `java` resolves 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
|
|
across `event_tracker.py` and `android/main.py` — changing one
|
|
without the other breaks cross-device sync silently.
|
|
- Git remote is `repo.tas-tech.net` (Forgejo), auth via `~/.netrc`,
|
|
user `AI_Assistant` — **never push without asking the user first.**
|