""" 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 + 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()