# Plausible Dashboard — Android App Plan **Name:** Implausibly **Stack:** Kotlin · Jetpack Compose · Material 3 **License:** GPL-3.0-only **Target:** API 26+ (Android 8.0, covers ~97% of devices) --- ## 1. Why This Exists The existing third-party apps (Applausible, Plausible Analytics Mobile) are all closed-source. If you're running self-hosted Plausible CE specifically *because* you care about control and transparency, a closed-source dashboard app is an odd fit. This app is: - **Fully open source** — audit it, fork it, contribute - **Self-hosted first** — configurable base URL, no Plausible.io assumptions baked in - **No account/subscription required in the app itself** — just your Plausible API key The Plausible Stats API v2 (`POST /api/v2/query`) is well-designed and gives us everything we need. Rate limit is 600 req/hr which is generous for a dashboard viewer. --- ## 2. Architecture ### 2.1 High-Level Layers ``` ┌─────────────────────────────────────┐ │ UI (Jetpack Compose) │ │ Material 3 · Dynamic Color │ ├─────────────────────────────────────┤ │ ViewModel (per screen) │ │ StateFlow · UiState sealed class │ ├─────────────────────────────────────┤ │ Repository Layer │ │ Coordinates API + cache │ ├──────────────┬──────────────────────┤ │ Remote │ Local Cache │ │ (Ktor) │ (SQLDelight) │ └──────────────┴──────────────────────┘ ``` ### 2.2 Key Technology Choices (with rationale) | Choice | Why | |--------|-----| | **Ktor** for HTTP | Pure Kotlin, no annotation processing, lighter than Retrofit for a simple POST-only API. We literally have one endpoint. | | **kotlinx.serialization** | Compiler plugin, no reflection. Pairs naturally with Ktor. | | **SQLDelight** for local cache | SQL-first: you write `.sq` files, it generates typesafe Kotlin. KMP-native, so the entire data layer can be shared with a future iOS app. Lighter than Room, no annotation processing. | | **Hilt** for DI | Standard for Android, reduces boilerplate vs manual DI. Could argue for Koin but Hilt has better compile-time safety. | | **DataStore** for preferences | Non-sensitive prefs: selected site, theme, UI state. Replaces SharedPreferences. | | **EncryptedSharedPreferences** for secrets | API keys stored with MasterKey + AES-256-GCM. EncryptedDataStore never graduated to stable; ESP is solid and well-tested. | | **Vico** for charts | Compose-native charting library, actively maintained. Alternative: compose-charts or rolling our own with Canvas. | **F-Droid compliance:** Every dependency is Apache 2.0 or compatible. Zero Play Services dependencies anywhere in the tree. AndroidX Security Crypto uses Tink (Apache 2.0, pure Java crypto, no Play linkage). Nothing phones home. ### 2.3 Package Structure ``` no.naiv.implausibly/ ├── data/ │ ├── remote/ │ │ ├── PlausibleApi.kt # Ktor client, single /api/v2/query call │ │ ├── dto/ # API request/response models │ │ └── interceptor/ # Auth header injection (per-instance) │ ├── local/ │ │ ├── ImplausiblyDatabase.sq # SQLDelight schema + queries │ │ └── adapters/ # Column adapters (enums, etc.) │ └── repository/ │ ├── StatsRepository.kt # Single source of truth pattern │ ├── SiteRepository.kt │ └── InstanceRepository.kt # CRUD for Plausible instances ├── domain/ │ ├── model/ # Domain models (decoupled from API DTOs) │ └── usecase/ # Optional, only if logic warrants it ├── ui/ │ ├── theme/ # Material 3 theme, typography, colors │ ├── navigation/ # NavHost, routes │ ├── setup/ # Onboarding + instance management │ │ └── instances/ # Add/edit/delete/switch instances │ ├── sites/ # Site list / site picker │ ├── dashboard/ # Main stats dashboard │ │ ├── DashboardScreen.kt │ │ ├── DashboardViewModel.kt │ │ ├── components/ # StatCard, VisitorChart, etc. │ │ └── sections/ # TopPages, Sources, Locations, Devices │ ├── filter/ # Filter sheet (date range, dimensions) │ └── common/ # Shared composables └── di/ # Hilt modules ``` --- ## 3. API Integration **API Reference:** The Plausible Stats API v2 documentation lives at . The query endpoint reference is at . For self-hosted instances the base URL changes but the path and schema are identical. ### 3.1 The One Endpoint Everything goes through `POST /api/v2/query`. The request body looks like: ```json { "site_id": "example.com", "metrics": ["visitors", "pageviews", "bounce_rate", "visit_duration"], "date_range": "7d", "dimensions": ["visit:source"], "filters": [["is_not", "visit:country_name", [""]]], "order_by": [["visitors", "desc"]], "limit": 10 } ``` ### 3.2 Query Strategy The dashboard needs multiple queries to populate all sections. We batch these with `coroutineScope { }` to run them concurrently: | Section | metrics | dimensions | date_range | |---------|---------|------------|------------| | Top stats row | visitors, pageviews, bounce_rate, visit_duration | (none) | user-selected | | Visitor graph | visitors | time:day (or time:hour for "today") | user-selected | | Top sources | visitors | visit:source | user-selected | | Top pages | visitors | event:page | user-selected | | Countries | visitors | visit:country_name | user-selected | | Devices | visitors | visit:device | user-selected | | Browsers | visitors | visit:browser | user-selected | | OS | visitors | visit:os | user-selected | That's ~8 concurrent requests per dashboard load. At 600/hr rate limit, a user can refresh every ~50 seconds continuously without hitting it. Fine for a dashboard. ### 3.3 Realtime Plausible's realtime visitor count is available via the same v2 endpoint with `"date_range": "realtime"`. We poll this on a configurable interval (default: 30s) when the app is in foreground. No WebSocket available. --- ## 4. Screens & UX ### 4.1 Setup / Onboarding Three ways to connect to a Plausible instance: ``` ┌──────────────────────────────┐ │ │ │ Add Instance │ │ │ │ ○ API Key (full access) │ │ ○ Stats API only │ │ ○ Public dashboard (shared │ │ link, no key needed) │ │ │ │ Instance URL │ │ ┌────────────────────┐ │ │ │ https://... │ │ │ └────────────────────┘ │ │ │ │ API Key (if applicable) │ │ ┌────────────────────┐ │ │ │ ●●●●●●●●●●●●● │ │ ← password field, paste-friendly │ └────────────────────┘ │ │ │ │ Friendly name (optional) │ │ ┌────────────────────┐ │ │ │ │ │ ← auto-filled on successful test │ └────────────────────┘ │ │ │ │ [ Test Connection ] │ ← minimal query to validate │ [ Save & Continue ] │ │ │ └──────────────────────────────┘ ``` Key decisions: - **Multiple instances from day one** — stored as a list, each with its own base URL, credentials, and friendly name. Switching is a top-level action. - **Friendly name:** editable at all times. If left blank when "Test Connection" succeeds, auto-populate from the response (e.g. the first site_id, or the instance hostname as fallback). User can always override before or after saving. Editable later from instance settings. - API keys in EncryptedSharedPreferences (MasterKey + AES-256-GCM) - Non-sensitive prefs (selected instance, theme) in regular DataStore - Test connection fires a minimal `/api/v2/query` (visitors, 1d) to validate - Public shared links just need the URL — no key at all ### 4.2 Site Picker Simple list of sites the API key has access to. Pull from the Sites API if available (requires Sites API key), otherwise let users manually add site_ids. Show realtime visitor count next to each site as a live badge. ### 4.3 Main Dashboard Maps closely to the Plausible web dashboard — users already know this layout. ``` ┌──────────────────────────────────────┐ │ example.com ⚡ 3 live │ │ ┌──────┬──────┬──────┬──────┐ │ │ │ 1.2k │ 3k │ 45% │ 52s │ │ │ │visit.│views │bounce│ dur. │ │ │ └──────┴──────┴──────┴──────┘ │ │ │ │ [Today] [7d] [30d] [custom] │ │ │ │ ┌──────────────────────────────┐ │ │ │ 📈 visitor graph │ │ │ │ (Vico line/bar chart) │ │ │ └──────────────────────────────┘ │ │ │ │ ▸ Top Sources │ │ Google ████████░░ 842 │ │ Direct █████░░░░░ 512 │ │ twitter.com ██░░░░░░░░ 201 │ │ │ │ ▸ Top Pages │ │ / ████████░░ 1.1k │ │ /blog/post-1 ████░░░░░░ 423 │ │ │ │ ▸ Countries · Devices · Browsers │ │ (expandable sections) │ └──────────────────────────────────────┘ ``` ### 4.4 Filter System Bottom sheet with combinable filters matching the API's filter syntax: - Country, source, page, device, browser, OS - `is`, `is_not`, `contains`, `matches` operators - Multiple filters compose with AND (matching API behavior) This is where we can genuinely beat Applausible — deep filtering is powerful and the v2 API supports it well. --- ## 5. Offline & Caching Strategy ### 5.1 Cache Model SQLDelight stores the last successful response for each query signature. A query signature is the hash of `(instanceId, site_id, query_body)`. The schema lives in `.sq` files — SQL-first, generates typesafe Kotlin. This is KMP-native, so the entire data layer can be shared with iOS later. ```sql -- src/commonMain/sqldelight/no/naiv/implausibly/CachedStats.sq CREATE TABLE cached_stats ( query_hash TEXT NOT NULL PRIMARY KEY, -- hash of (instanceId, siteId, queryBody) instance_id TEXT NOT NULL, site_id TEXT NOT NULL, response_json TEXT NOT NULL, fetched_at INTEGER NOT NULL, -- epoch millis date_range TEXT NOT NULL -- for TTL decisions ); selectByHash: SELECT * FROM cached_stats WHERE query_hash = ?; upsert: INSERT OR REPLACE INTO cached_stats VALUES (?, ?, ?, ?, ?, ?); deleteStaleForInstance: DELETE FROM cached_stats WHERE instance_id = ? AND fetched_at < ?; -- src/commonMain/sqldelight/no/naiv/implausibly/Instance.sq CREATE TABLE instances ( id TEXT NOT NULL PRIMARY KEY, -- UUID name TEXT NOT NULL, -- friendly name base_url TEXT NOT NULL, -- e.g. https://plausible.example.com access_mode TEXT NOT NULL, -- FULL_API | STATS_ONLY | PUBLIC_LINK api_key_ref TEXT, -- key into EncryptedSharedPreferences (null for public) created_at INTEGER NOT NULL ); selectAll: SELECT * FROM instances ORDER BY created_at ASC; selectById: SELECT * FROM instances WHERE id = ?; insert: INSERT INTO instances VALUES (?, ?, ?, ?, ?, ?); updateName: UPDATE instances SET name = ? WHERE id = ?; delete: DELETE FROM instances WHERE id = ?; ``` Domain models remain decoupled from the generated DB types: ```kotlin enum class AccessMode { FULL_API, STATS_ONLY, PUBLIC_LINK } ``` ### 5.2 TTL Strategy | date_range | Cache TTL | Rationale | |------------|-----------|-----------| | realtime | 0 (never cache) | Stale realtime is worse than no data | | today | 5 min | Active day, changes frequently | | 7d, 30d | 30 min | Reasonably stable | | 6mo, 12mo | 2 hours | Historical, barely changes | | custom (past) | 24 hours | Completed periods don't change | On app open: show cached data immediately, then refresh in background. On network error: show cached data with a subtle "last updated X ago" indicator. --- ## 6. Home Screen Widgets (Future / Maybe) Deferred. If added later, Glance (Compose-based widget framework) is the right choice. WorkManager for periodic updates (minimum 15 min on Android). No Play Services needed — WorkManager is pure AndroidX. --- ## 7. Security Considerations - **API key storage:** EncryptedSharedPreferences (MasterKey + AES-256-GCM). Uses AndroidX Security Crypto → Tink (Apache 2.0, pure Java, no Play Services). - **Network:** HTTPS enforced. Certificate pinning optional (self-hosted users might use custom CAs, so we can't pin by default — offer it as a setting). - **No analytics in the analytics app.** Zero telemetry. - **No Google Play dependencies.** Not as a library, not as a transitive dep, not anywhere. Audit `./gradlew :app:dependencies` before every release. - **No third-party dependencies that phone home.** Audit all transitive deps. --- ## 8. Build & Release ### 8.1 Build System - Gradle with version catalogs (libs.versions.toml) - Convention plugins for shared config - Forgejo Actions CI: lint, unit test, instrumented test, APK build ### 8.2 Distribution - **F-Droid** (primary) — fits the open source + privacy ethos perfectly - **Forgejo Releases** (kode.naiv.no) — signed APKs for direct install No Google Play. Play App Signing requires handing Google a signing key, and there's no reason to pull in any Play components. The target audience (self-hosted Plausible users who care about privacy) overwhelmingly overlaps with F-Droid users. Anyone else can sideload the APK. ### 8.3 Reproducible Builds Target F-Droid reproducible build verification from day one. Pin all dependency versions, use a locked Gradle wrapper. ### 8.4 License — GPL-3.0-only The app is released under the **GNU General Public License v3.0 only** (SPDX: `GPL-3.0-only`). Key implications: - **Copyleft**: Any distributed fork or derivative work must also be released under GPL-3.0. This ensures the open-source intent cannot be stripped by downstream distributors. - **Source availability**: Anyone who distributes a compiled APK must make the corresponding source code available under the same terms (or provide a written offer to do so). - **F-Droid compatibility**: GPL-3.0 is fully accepted by F-Droid. All bundled dependencies must be Apache 2.0, MIT, LGPL, or otherwise GPL-compatible (no AGPL, no proprietary). - **No CLA required**: Contributions are accepted under GPL-3.0 — contributors retain their copyright. A `CONTRIBUTORS` file will track significant contributors. - **Binary distribution**: Signed APKs on Forgejo Releases are GPL-3.0 binaries. The signing key is never required to be published; only the source code is. Files to include in the repository: - `LICENSE` — full GPL-3.0 license text - `SPDX-License-Identifier: GPL-3.0-only` header in every source file (or `.reuse/` tree) - `NOTICE` — attributions for Apache-2.0 / LGPL transitive dependencies --- ## 9. Development Phases ### Phase 1: Core (MVP) - [ ] Project setup (Gradle, Hilt, Navigation) - [ ] Instance management (add/edit/delete, multi-instance) - [ ] Setup screen with three access modes (full API, stats-only, public shared link) - [ ] Site list screen - [ ] Basic dashboard (top stats + visitor graph + top sources/pages) - [ ] Date range selector (today, 7d, 30d, 12mo) - [ ] Pull-to-refresh - [ ] Basic SQLDelight caching **Ship this.** It's already useful. ### Phase 2: Polish - [ ] All dashboard sections (countries, devices, browsers, OS) - [ ] Filter system (bottom sheet, combinable filters) - [ ] Material You dynamic color - [ ] Landscape layout (two-column dashboard) - [ ] Error handling & offline indicator - [ ] F-Droid submission + reproducible builds ### Phase 3: Nice to Have - [ ] Comparison mode (this period vs previous) - [ ] Traffic spike alerts (WorkManager polling, local notification) - [ ] Goal conversions section - [ ] Custom date ranges - [ ] Segments support (API v2 supports them) - [ ] Tablet layout - [ ] Home screen widgets (Glance) — if there's demand --- ## 10. Open Questions 1. **Kotlin Multiplatform extraction:** With SQLDelight + Ktor + kotlinx.serialization, the data layer is already KMP-ready. When the time comes, extract `data/` and `domain/` into a shared KMP module, add an iOS app with SwiftUI consuming it, and implement `expect`/`actual` for platform secrets (Keychain vs ESP). The only Android-specific pieces are Hilt, DataStore, and EncryptedSharedPreferences. 2. **Sites API auto-discovery:** The Stats API key and Sites API key are separate permissions. For users with only a Stats API key, we fall back to manual site_id entry. Should we try the Sites API optimistically and fall back, or ask the user upfront what kind of key they have? 3. **Shared link auth:** Plausible shared links can optionally be password-protected. Need to verify how auth works for those — likely a query parameter or header.