Forgejo's releases API and asset downloads both work unauthenticated for a public repo. check_latest_release() no longer raises if FORGEJO_TOKEN is blank; it only attaches the Authorization header when one is actually set (e.g. if this repo ever goes private again).
440 lines
17 KiB
Python
440 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). This repo is public, so FORGEJO_TOKEN is left blank —
|
|
# Forgejo's releases API and asset downloads both work unauthenticated
|
|
# for a public repo. Only set it if this repo ever goes private again;
|
|
# if so, 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
|
|
|
|
url = f"{FORGEJO_BASE_URL}/api/v1/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/releases/latest"
|
|
headers = {"Authorization": f"token {FORGEJO_TOKEN}"} if FORGEJO_TOKEN else {}
|
|
req = urllib.request.Request(url, headers=headers)
|
|
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()
|