diff --git a/README.md b/README.md index 0650ed9..3893d07 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,36 @@ DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runs Sessions last a year, so your phone stays logged in. +## Installing it on Android (PWA) + +The Today page is a progressive web app: Chrome will offer *Add to home screen* +(menu → Install app), and it then launches full-screen with its own icon, no +browser chrome, and a year-long session. No APK, no Play Store, no Flutter. + +**It must be served over HTTPS** — service workers and installability are +disabled on plain `http://` (except `localhost`). Over LAN HTTP you still get a +usable mobile site, just a bookmark rather than an installed app. The easy ways +to get a real certificate: + +```bash +# Tailscale (private to your devices, no public exposure): +tailscale serve --bg 8000 +# or Cloudflare Tunnel (public URL): +cloudflared tunnel --url http://localhost:8000 +``` + +Whichever you use, add the hostname to `DJANGO_ALLOWED_HOSTS` and +`DJANGO_CSRF_TRUSTED_ORIGINS`. + +Offline behaviour is deliberately conservative: static files are cached, and the +last-seen page is shown if you open the app with no signal, but `/api/` calls and +every write go straight to the network — a cached or replayed dose would be a +lie about whether the cat was medicated. + +If you ever do need a real APK (Play Store, or an installable file), the manifest +here is all [PWABuilder](https://www.pwabuilder.com) needs to wrap it in a +Trusted Web Activity — no Android SDK on your side. + ## Data model - `Medication` — the drug, e.g. Vetmedin. Deactivate rather than delete when a diff --git a/assets/icons/icon-192.png b/assets/icons/icon-192.png new file mode 100644 index 0000000..8d625ce Binary files /dev/null and b/assets/icons/icon-192.png differ diff --git a/assets/icons/icon-512.png b/assets/icons/icon-512.png new file mode 100644 index 0000000..cbd9859 Binary files /dev/null and b/assets/icons/icon-512.png differ diff --git a/assets/icons/icon-maskable-512.png b/assets/icons/icon-maskable-512.png new file mode 100644 index 0000000..41e136c Binary files /dev/null and b/assets/icons/icon-maskable-512.png differ diff --git a/assets/manifest.webmanifest b/assets/manifest.webmanifest new file mode 100644 index 0000000..e87304e --- /dev/null +++ b/assets/manifest.webmanifest @@ -0,0 +1,31 @@ +{ + "name": "Osiris", + "short_name": "Osiris", + "description": "Medication and pulse tracking for Osiris the cat.", + "start_url": "/admin/today/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#202a35", + "theme_color": "#202a35", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/osiris/settings.py b/osiris/settings.py index 98b4bc7..7cdddc4 100644 --- a/osiris/settings.py +++ b/osiris/settings.py @@ -129,6 +129,8 @@ USE_TZ = True STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "static" +# PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output. +STATICFILES_DIRS = [BASE_DIR / "assets"] # Default primary key field type DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/osiris/urls.py b/osiris/urls.py index 1940703..7ab8d99 100644 --- a/osiris/urls.py +++ b/osiris/urls.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.shortcuts import redirect from django.urls import include, path +from django.views.generic import TemplateView from osiris.api import api @@ -10,6 +11,15 @@ admin.site.index_title = "Medication & pulse tracking" urlpatterns = [ path("api/", api.urls), + # Must be served from the root: a worker under /static/ could only control + # /static/, not the /admin/ pages it exists to cache. + path( + "sw.js", + TemplateView.as_view( + template_name="sw.js", content_type="application/javascript" + ), + name="service-worker", + ), # Sits under /admin/ so it inherits the admin's styling and login, but must # come before the admin's own catch-all patterns. path("admin/", include("tracker.urls")), diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html index 0594374..aaec837 100644 --- a/templates/admin/base_site.html +++ b/templates/admin/base_site.html @@ -1,7 +1,22 @@ {% extends "admin/base.html" %} +{% load static %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Osiris') }}{% endblock %} +{% block extrahead %} +{{ block.super }} + + + + + + +{% endblock %} + {% block branding %}
{% if user.is_anonymous %} diff --git a/templates/sw.js b/templates/sw.js new file mode 100644 index 0000000..6e3f6f1 --- /dev/null +++ b/templates/sw.js @@ -0,0 +1,68 @@ +// Served from / (not /static/) so its scope covers /admin/today/. +const CACHE = "osiris-v1"; + +self.addEventListener("install", (event) => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + + // Never touch writes or the API: a stale or replayed dose would be a lie. + if (request.method !== "GET" || new URL(request.url).pathname.startsWith("/api/")) { + return; + } + + // Static assets change rarely: serve from cache, refresh in the background. + if (new URL(request.url).pathname.startsWith("/static/")) { + event.respondWith( + caches.open(CACHE).then(async (cache) => { + const cached = await cache.match(request); + const network = fetch(request) + .then((response) => { + if (response.ok) cache.put(request, response.clone()); + return response; + }) + .catch(() => cached); + return cached || network; + }) + ); + return; + } + + // Pages: always prefer the live version, fall back to the last one seen so the + // app still opens (read-only) with no signal. + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + } + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + return ( + cached || + new Response( + "" + + "" + + "Osiris needs a connection to load. Try again once you're back online.
", + { headers: { "Content-Type": "text/html; charset=utf-8" } } + ) + ); + }) + ); +}); diff --git a/tracker/tests.py b/tracker/tests.py index 4127c71..812101a 100644 --- a/tracker/tests.py +++ b/tracker/tests.py @@ -229,3 +229,25 @@ class TodayPageTests(TrackerTestCase): response = self.client.get(reverse("tracker:today")) self.assertContains(response, "