Add GPL-3.0 LICENSE, port update checker from android-app-template

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.
This commit is contained in:
AI_Assistant 2026-08-22 13:31:03 +00:00
commit d7e5569dda
No known key found for this signature in database
5 changed files with 499 additions and 40 deletions

View file

@ -11,25 +11,39 @@ requirements = python3,kivy
# Upstream p4a's kivy recipe declares python_depends on requests/certifi/
# chardet/idna/urllib3/filetype — mostly kivy.network.UrlRequest extras we
# never call, EXCEPT filetype, which kivy/core/image imports directly for
# real (confirmed by a startup crash when it was dropped too). p4a-recipes/
# kivy is a copy of that recipe keeping only filetype. It's not just
# cleanup: requests pulls in charset-normalizer, whose newest release
# ships an Android-tagged wheel; p4a's dependency resolver pins that exact
# wheel into requirements.txt, then installs it with a plain host pip that
# has no idea it's an Android wheel, and the build dies on "not a
# supported wheel on this platform". Dropping the unused requests chain
# avoids the bug entirely instead of chasing version pins — filetype has
# no such transitive dependency and installs cleanly on its own.
# never call, EXCEPT filetype (kivy/core/image imports it directly for
# real, confirmed by a startup crash when it was dropped too) and certifi
# (needed by main.py's update checker for HTTPS on Android — see the
# comment on check_latest_release() in main.py). p4a-recipes/kivy is a
# copy of that recipe keeping only those two. It's not just cleanup:
# requests pulls in charset-normalizer, whose newest release ships an
# Android-tagged wheel; p4a's dependency resolver pins that exact wheel
# into requirements.txt, then installs it with a plain host pip that has
# no idea it's an Android wheel, and the build dies on "not a supported
# wheel on this platform". Dropping the unused requests chain avoids the
# bug entirely — filetype and certifi are both pure Python with no such
# transitive dependency and install cleanly on their own.
#
# Reminder: editing p4a-recipes/kivy's python_depends alone doesn't take
# effect on an existing build — p4a caches the built dist by name+recipe
# list and won't re-derive python_depends for a dist that already
# exists. Delete .buildozer/android/platform/build-<arch>/dists/eventtracker
# after any python_depends change, or the change silently won't apply.
# See CLAUDE.md for the full trace of this gotcha.
p4a.local_recipes = p4a-recipes
orientation = portrait
fullscreen = 0
# broad file access is required to reach whatever folder FolderSync/DAVx5
# is syncing into — that folder is picked by the user at runtime (Settings),
# not fixed at build time, so scoped storage APIs alone aren't enough
android.permissions = READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE,MANAGE_EXTERNAL_STORAGE
# READ/WRITE/MANAGE_EXTERNAL_STORAGE: broad file access is required to
# reach whatever folder FolderSync/DAVx5 is syncing into — that folder is
# picked by the user at runtime (Settings), not fixed at build time, so
# scoped storage APIs alone aren't enough. INTERNET: the in-app update
# checker (main.py) needs this to reach Forgejo's API and download a
# release .apk. REQUEST_INSTALL_PACKAGES: lets Android prompt to install
# that downloaded .apk in one tap instead of also making the user
# manually enable "install unknown apps" first.
android.permissions = READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE,MANAGE_EXTERNAL_STORAGE,INTERNET,REQUEST_INSTALL_PACKAGES
android.api = 33
android.minapi = 24

View file

@ -6,17 +6,23 @@ consistent log — this app never needs to know about the desktop app at
all, only about the same file format.
v1 scope, deliberately: record buttons + today's totals + a simple
proportional bar. No stats popup, no event rename/add, no charts beyond
the one bar row. That's the desktop app's job see ../README.md. This
keeps the first Android build small enough to actually get working
before piling on features.
proportional bar, plus Settings and an in-app update checker (ported
from android-app-template). No stats popup, no event rename/add, no
charts beyond the one bar row. That's the desktop app's job see
../README.md. This keeps the Android build small enough to actually
get working before piling on features.
No matplotlib: unreliable on Android through Kivy's garden packaging, so
"today's totals" render as a hand-drawn bar using Kivy's own canvas.
"""
import csv
import json
import os
import ssl
import threading
import urllib.error
import urllib.request
from collections import Counter
from datetime import datetime
@ -42,6 +48,18 @@ DEFAULT_EVENTS = ["Cigarette", "B-event"] # must match the de
COLOR_PALETTE = [(0.30, 0.45, 0.69, 1), (0.77, 0.31, 0.32, 1), (0.33, 0.66, 0.41, 1)] # RGBA, cycles per event index
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S" # must match the desktop app's format — same file, same parser
APP_TITLE = "Event Tracker"
APP_VERSION = "0.1" # keep in sync with buildozer.spec's `version =` by hand — nothing wires the two together
# Forgejo repo this app checks for updates against (ported from
# android-app-template — see that repo for the original writeup on each
# piece of this). Leave FORGEJO_TOKEN blank to disable update checking —
# the button still shows, but tells the user clearly that updates aren't
# configured rather than failing silently or crashing. Use a read-only
# token scoped to just this repo, never a general account token.
FORGEJO_BASE_URL = "https://repo.tas-tech.net"
FORGEJO_OWNER = "tas-tech.net"
FORGEJO_REPO = "event-tracker"
FORGEJO_TOKEN = ""
def event_color(index):
@ -59,14 +77,12 @@ def config_path(user_data_dir):
def load_data_path(user_data_dir):
path = config_path(user_data_dir)
if os.path.exists(path):
import json
with open(path) as f:
return json.load(f).get("data_path", "")
return ""
def save_data_path(user_data_dir, data_path):
import json
os.makedirs(user_data_dir, exist_ok=True)
with open(config_path(user_data_dir), "w") as f:
json.dump({"data_path": data_path}, f, indent=2)
@ -104,6 +120,134 @@ def append_entry(csv_path, event, ts):
csv.writer(f).writerow([event, ts.strftime(TIMESTAMP_FORMAT)])
# ---- update checker (ported from android-app-template) ----
def _version_tuple(v):
"""'v0.2' / '0.2' -> (0, 2) for a plain numeric comparison. Not full
semver enough for a personal tool tagging plain X.Y[.Z] releases.
Keep release tags formatted with the same number of dot-segments as
APP_VERSION above (e.g. don't tag '0.1.0' while APP_VERSION is
'0.1') tuple comparison treats a shorter prefix as *older* than a
longer one even when the numbers agree, e.g. (0, 1) < (0, 1, 0)."""
v = v.lstrip("vV")
parts = []
for piece in v.split("."):
digits = "".join(ch for ch in piece if ch.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts)
def check_latest_release():
"""Blocking network call — always run in a background thread, never
on Kivy's UI thread. Returns (tag_name, assets_list) on success;
raises on any failure, caller reports it.
Uses certifi's CA bundle explicitly instead of ssl's platform
default. python-for-android's cross-compiled OpenSSL doesn't have
Android's system trust store wired up the way desktop OpenSSL does
ssl.create_default_context() with no cafile reliably fails
on-device with "unable to get local issuer certificate". certifi is
safe to bundle: pure Python, zero dependencies, no compiled wheel
see p4a-recipes/kivy/__init__.py, it doesn't touch the requests/
charset-normalizer bug documented there, that's a separate package
family entirely.
"""
import certifi
if not FORGEJO_TOKEN:
raise RuntimeError(
"Update checking isn't configured — set FORGEJO_TOKEN at "
"the top of main.py."
)
url = f"{FORGEJO_BASE_URL}/api/v1/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/releases/latest"
req = urllib.request.Request(url, headers={"Authorization": f"token {FORGEJO_TOKEN}"})
ctx = ssl.create_default_context(cafile=certifi.where())
with urllib.request.urlopen(req, context=ctx, timeout=15) as resp:
data = json.load(resp)
return data.get("tag_name", ""), data.get("assets", [])
def download_and_install(url, filename):
"""Hands the APK to Android's own DownloadManager, then on
completion fires the system package installer via the content://
URI DownloadManager itself hands back.
Deliberately NOT using a custom FileProvider + <provider> manifest
entry: DownloadManager.getUriForDownloadedFile() has returned a
proper content:// URI since API 24 specifically so apps don't need
one for this exact case. Requires
android.permission.REQUEST_INSTALL_PACKAGES in buildozer.spec for a
smooth one-tap install; without it Android still works, it just
also makes the user enable "install unknown apps" for this app the
first time (a one-off, not a bug).
NOT YET VERIFIED ON A REAL DEVICE same status as when this was
first written for android-app-template. This is the standard,
documented pattern for p4a apps, but test it for real before
relying on it; if it misbehaves, `adb logcat` while tapping the
update button is the first place to look, same as any other crash
on this project.
"""
from jnius import autoclass, cast
Context = autoclass("android.content.Context")
DownloadManager = autoclass("android.app.DownloadManager")
Request = autoclass("android.app.DownloadManager$Request")
Uri = autoclass("android.net.Uri")
Intent = autoclass("android.content.Intent")
PythonActivity = autoclass("org.kivy.android.PythonActivity")
activity = PythonActivity.mActivity
dm = cast(DownloadManager, activity.getSystemService(Context.DOWNLOAD_SERVICE))
request = Request(Uri.parse(url))
if FORGEJO_TOKEN:
request.addRequestHeader("Authorization", f"token {FORGEJO_TOKEN}")
request.setTitle(f"{APP_TITLE} update")
request.setNotificationVisibility(Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
request.setDestinationInExternalFilesDir(activity, None, filename)
download_id = dm.enqueue(request)
def poll(_dt):
query = DownloadManager.Query()
query.setFilterById(download_id)
cursor = dm.query(query)
try:
if not cursor.moveToFirst():
return
status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
if status == DownloadManager.STATUS_SUCCESSFUL:
Clock.unschedule(poll)
uri = dm.getUriForDownloadedFile(download_id)
intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(uri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION)
activity.startActivity(intent)
elif status == DownloadManager.STATUS_FAILED:
Clock.unschedule(poll)
finally:
cursor.close()
Clock.schedule_interval(poll, 1.0)
class MessagePopup(Popup):
"""Small reusable popup for status/error messages — update-check
results, config errors, anything that's just "show the user a
sentence and an OK button"."""
def __init__(self, title, message, **kwargs):
super().__init__(title=title, size_hint=(0.85, 0.4), **kwargs)
box = BoxLayout(orientation="vertical", spacing=10, padding=10)
box.add_widget(Label(text=message, halign="center", valign="middle"))
close_button = Button(text="OK", size_hint_y=None, height=48)
close_button.bind(on_release=lambda *_: self.dismiss())
box.add_widget(close_button)
self.add_widget(box)
class BarRow(Widget):
"""One proportional bar per event — today's count relative to
whichever event has the highest count today. No chart library."""
@ -184,9 +328,14 @@ class TrackerRoot(BoxLayout):
self.bar_row = BarRow(size_hint_y=0.35)
self.add_widget(self.bar_row)
settings_button = Button(text="Settings", size_hint_y=0.1)
menu_row = BoxLayout(orientation="horizontal", spacing=10, size_hint_y=0.1)
settings_button = Button(text="Settings")
settings_button.bind(on_release=self.open_settings)
self.add_widget(settings_button)
menu_row.add_widget(settings_button)
update_button = Button(text="Check for Updates")
update_button.bind(on_release=self.check_updates)
menu_row.add_widget(update_button)
self.add_widget(menu_row)
self.build_buttons()
self.refresh()
@ -213,6 +362,43 @@ class TrackerRoot(BoxLayout):
save_data_path(self.user_data_dir, new_path)
self.refresh()
def check_updates(self, *_args):
checking_popup = MessagePopup("Checking…", "Checking for updates.")
checking_popup.open()
def show(title, message):
checking_popup.dismiss()
MessagePopup(title, message).open()
def worker():
try:
tag, assets = check_latest_release()
latest = _version_tuple(tag)
current = _version_tuple(APP_VERSION)
if latest <= current:
Clock.schedule_once(lambda *_: show(
"Up to date", f"You're on the latest version ({APP_VERSION})."
))
return
apk_asset = next((a for a in assets if a.get("name", "").endswith(".apk")), None)
if not apk_asset:
Clock.schedule_once(lambda *_: show(
"Update found", f"{tag} is available, but the release has no .apk asset attached."
))
return
def do_install(*_):
if ON_ANDROID:
download_and_install(apk_asset["browser_download_url"], apk_asset["name"])
show("Downloading", "Downloading the update — you'll get an install prompt when it's ready.")
Clock.schedule_once(do_install)
except Exception as exc: # noqa: BLE001 — surfacing any failure to the user is the point here
Clock.schedule_once(lambda *_: show("Update check failed", str(exc)))
threading.Thread(target=worker, daemon=True).start()
def refresh(self):
entries = load_entries(self.data_path)
today = datetime.now().date()
@ -249,6 +435,7 @@ class EventTrackerApp(App):
request_permissions([
Permission.READ_EXTERNAL_STORAGE,
Permission.WRITE_EXTERNAL_STORAGE,
Permission.INTERNET,
])
return TrackerRoot()

View file

@ -5,13 +5,26 @@
# release ships an Android-tagged wheel that p4a's own install step
# can't actually install.
#
# 'filetype' is kept — it's NOT part of that broken chain (zero deps of
# its own, no requests/charset-normalizer involved) and it's a genuine
# runtime dependency: kivy/core/image/__init__.py imports it directly
# for image format sniffing, unlike the other five which are only for
# kivy.network.UrlRequest (which this app never calls). Confirmed the
# hard way — dropping it too caused a startup crash on-device:
# ModuleNotFoundError: No module named 'filetype'
# 'filetype' and 'certifi' are kept — neither is part of that broken
# chain (both pure Python, zero deps of their own, no requests/
# charset-normalizer involved):
# - filetype: kivy/core/image/__init__.py imports it directly for
# image format sniffing. Confirmed the hard way — dropping it too
# caused a startup crash on-device: ModuleNotFoundError: No module
# named 'filetype'.
# - certifi: main.py's update checker needs it for HTTPS on Android
# (see the comment on check_latest_release() in main.py) — the
# desktop-facing v1 of this app never made a network call, so it
# never needed this until the update checker was ported over.
# The remaining three (chardet, idna, requests, urllib3 minus what's
# listed above) are only for kivy.network.UrlRequest, which this app
# still never calls — reminder: editing python_depends here alone isn't
# enough for the change to take effect on an existing build. p4a caches
# the built dist by name+recipe-list and won't re-derive python_depends
# for a dist that already exists — delete
# .buildozer/android/platform/build-<arch>/dists/<package.name> after
# any python_depends change or the fix will silently not apply. See
# CLAUDE.md for the full trace of that gotcha.
from os.path import join
import sys
import packaging.version
@ -48,7 +61,7 @@ class KivyRecipe(PyProjectRecipe):
name = 'kivy'
depends = [('sdl2', 'sdl3'), 'pyjnius', 'setuptools', 'android']
python_depends = ['filetype'] # upstream also had: 'certifi', 'chardet', 'idna', 'requests', 'urllib3' — unused, see buildozer.spec
python_depends = ['filetype', 'certifi'] # upstream also had: 'chardet', 'idna', 'requests', 'urllib3' — unused, see buildozer.spec
hostpython_prerequisites = ["cython>=0.29.1,<=3.0.12"]
# sdl-gl-swapwindow-nogil.patch is needed to avoid a deadlock.