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:
parent
4a617469b8
commit
d7e5569dda
5 changed files with 499 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
203
android/main.py
203
android/main.py
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue