event-tracker/android/main.py
AI_Assistant d7e5569dda
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.
2026-08-22 13:31:03 +00:00

444 lines
17 KiB
Python

"""
event-tracker-android: minimal Kivy touchscreen companion to the desktop
GTK app. Reads/writes the exact same CSV format (event,timestamp), so a
file kept in sync by FolderSync/DAVx5/etc. between devices is one shared,
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, 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
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.uix.textinput import TextInput
from kivy.uix.widget import Widget
from kivy.graphics import Color, Rectangle
from kivy.clock import Clock
try:
from android.permissions import request_permissions, Permission # noqa: F401
ON_ANDROID = True
except ImportError:
ON_ANDROID = False
# ---- Configuration — everything environment/event-specific lives here ----
DEFAULT_EVENTS = ["Cigarette", "B-event"] # must match the desktop app's config.json events to line up
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):
return COLOR_PALETTE[index % len(COLOR_PALETTE)]
# ---- local app settings: just the CSV path, stored in this app's own
# private storage (never synced — every device points at its own local
# mirror of the shared file, same design as the desktop app's CONFIG_PATH) ----
def config_path(user_data_dir):
return os.path.join(user_data_dir, "config.json")
def load_data_path(user_data_dir):
path = config_path(user_data_dir)
if os.path.exists(path):
with open(path) as f:
return json.load(f).get("data_path", "")
return ""
def save_data_path(user_data_dir, data_path):
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)
# ---- CSV log — same format/columns as the desktop app, on purpose ----
def ensure_csv(csv_path):
os.makedirs(os.path.dirname(csv_path), exist_ok=True)
if not os.path.exists(csv_path):
with open(csv_path, "w", newline="") as f:
csv.writer(f).writerow(["event", "timestamp"])
def load_entries(csv_path):
if not csv_path or not os.path.exists(csv_path):
return []
entries = []
with open(csv_path, newline="") as f:
reader = csv.reader(f)
next(reader, None) # skip header
for row in reader:
if len(row) < 2:
continue
try:
entries.append((row[0], datetime.strptime(row[1], TIMESTAMP_FORMAT)))
except ValueError:
continue # tolerate a malformed row (e.g. a half-written sync) rather than crashing
return sorted(entries, key=lambda e: e[1])
def append_entry(csv_path, event, ts):
ensure_csv(csv_path)
with open(csv_path, "a", newline="") as f:
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."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.values = [] # list of (count, color)
self.bind(size=self.redraw, pos=self.redraw)
def set_values(self, values):
self.values = values
self.redraw()
def redraw(self, *_args):
self.canvas.clear()
if not self.values:
return
max_count = max((count for count, _ in self.values), default=0) or 1
n = len(self.values)
bar_width = self.width / (n * 2)
gap = bar_width
x = self.x + gap / 2
with self.canvas:
for count, color in self.values:
Color(*color)
bar_height = (count / max_count) * self.height
Rectangle(pos=(x, self.y), size=(bar_width, bar_height))
x += bar_width + gap
class SettingsPopup(Popup):
def __init__(self, current_path, on_save, **kwargs):
super().__init__(title="Settings", size_hint=(0.9, 0.5), **kwargs)
self.on_save = on_save
box = BoxLayout(orientation="vertical", spacing=10, padding=10)
info_label = Label(
text="Path to the synced event_log.csv on this device "
"(check your sync app's configured destination folder):",
size_hint_y=None, height=80, halign="left", valign="top",
)
# text_size can't just be set once here: at __init__ time this Popup
# hasn't been laid out yet, so self.width is Kivy's default ~100px
# widget width, not the popup's real on-screen size — that's what
# was squeezing this label into a thin vertical column. Binding to
# the label's own `width` keeps text_size in sync as the box (and
# therefore this label, which fills it) actually gets sized.
info_label.bind(width=lambda inst, w: setattr(inst, "text_size", (w, None)))
box.add_widget(info_label)
self.path_input = TextInput(text=current_path, multiline=False, size_hint_y=None, height=48)
box.add_widget(self.path_input)
save_button = Button(text="Save", size_hint_y=None, height=48)
save_button.bind(on_release=self.do_save)
box.add_widget(save_button)
self.add_widget(box)
def do_save(self, *_args):
self.on_save(self.path_input.text.strip())
self.dismiss()
class TrackerRoot(BoxLayout):
def __init__(self, **kwargs):
super().__init__(orientation="vertical", spacing=10, padding=10, **kwargs)
app = App.get_running_app()
self.user_data_dir = app.user_data_dir
self.events = list(DEFAULT_EVENTS)
self.data_path = load_data_path(self.user_data_dir)
self.button_row = BoxLayout(orientation="horizontal", spacing=10, size_hint_y=0.25)
self.add_widget(self.button_row)
self.stats_grid = GridLayout(cols=4, size_hint_y=0.2)
self.add_widget(self.stats_grid)
self.bar_row = BarRow(size_hint_y=0.35)
self.add_widget(self.bar_row)
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)
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()
def build_buttons(self):
self.button_row.clear_widgets()
for event in self.events:
button = Button(text=f"Record\n{event}")
button.bind(on_release=lambda _w, ev=event: self.record(ev))
self.button_row.add_widget(button)
def record(self, event):
if not self.data_path:
self.open_settings()
return
append_entry(self.data_path, event, datetime.now())
self.refresh()
def open_settings(self, *_args):
SettingsPopup(self.data_path, self.on_settings_saved).open()
def on_settings_saved(self, new_path):
self.data_path = new_path
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()
self.stats_grid.clear_widgets()
for header in ("Event", "Total", "Avg/hr", "Today"):
self.stats_grid.add_widget(Label(text=header, bold=True))
bar_values = []
for index, event in enumerate(self.events):
stamps = sorted(ts for ev, ts in entries if ev == event)
total = len(stamps)
today_count = sum(1 for ts in stamps if ts.date() == today)
if total:
span_hours = max((stamps[-1] - stamps[0]).total_seconds() / 3600, 1)
avg_hour = f"{total / span_hours:.2f}"
else:
avg_hour = "-"
self.stats_grid.add_widget(Label(text=event))
self.stats_grid.add_widget(Label(text=str(total)))
self.stats_grid.add_widget(Label(text=avg_hour))
self.stats_grid.add_widget(Label(text=str(today_count)))
bar_values.append((today_count, event_color(index)))
self.bar_row.set_values(bar_values)
class EventTrackerApp(App):
def build(self):
self.title = APP_TITLE
if ON_ANDROID:
request_permissions([
Permission.READ_EXTERNAL_STORAGE,
Permission.WRITE_EXTERNAL_STORAGE,
Permission.INTERNET,
])
return TrackerRoot()
if __name__ == "__main__":
EventTrackerApp().run()