implausible/plan.md
Ole-Morten Duesund aa66172d58 feat: implement Phase 1 MVP of Implausibly
Working Android dashboard app for self-hosted Plausible Analytics CE.
Connects to Plausible API v2 (POST /api/v2/query), displays top stats,
visitor chart, top sources, and top pages with date range selection.

Architecture: Kotlin + Jetpack Compose + Material 3 + Hilt + Ktor +
SQLDelight + EncryptedSharedPreferences. Single :app module, four-layer
unidirectional data flow (UI → ViewModel → Repository → Data).

Includes: instance management, site list, caching with TTL per date
range, encrypted API key storage, custom Canvas visitor chart,
pull-to-refresh, and unit tests for API, cache, repository, and
domain model layers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:46:08 +01:00

19 KiB

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 https://plausible.io/docs/stats-api. The query endpoint reference is at https://plausible.io/docs/stats-api/query. 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:

{
  "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.

-- 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:

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.