Skip to content

Architecture

┌──────────────────────────────────────────────────────────────┐
│ MainActivity (single activity, View-based)                    │
│  ┌─ WebView ────────────────┐   ┌─ SettingsStore (DataStore)  │
│  │ open.spotify.com SPA     │   │ adMode · theme · ua ·        │
│  │  + injected pipeline     │   │ background · preconnect ·    │
│  │   core-spooty.js/.css    │   │ prewarm                      │
│  │   ad-engine.js           │   └──────────┬───────────────────┘
│  │   themes/<active>        │              │ combine()
│  └──────────┬───────────────┘              ▼
│             │ addJavascriptInterface   Injector (onPageFinished,
│             │                             guarded by __spootyInjected)
│  ┌─ JsBridge (WeakRef→activity) ──┐
│  │ openSettings · onTrackChanged  │
│  │ onPlaybackPaused · log         │
│  └──────┬─────────────────────────┘
│         └────────────────────────────────┐
└──────────────────────────────────────────┼──────┐
      SpootyApplication                    │      │
      DNS preconnect + renderer warm-up    │      │
                                           ▼      ▼
                                   SpotifyPlaybackService   SettingsActivity
                                   foreground MediaSession  programmatic UI
                                   (placeholder ExoPlayer,  ad/theme/ua toggles
                                   metadata mirroring)        via SettingsStore

Component map

Component File Responsibility
MainActivity MainActivity.kt WebView host, lifecycle, bridge wiring, injector callsite
SpootyApplication SpootyApplication.kt feature-guarded startup prewarm
SettingsStore settings/SettingsStore.kt single settings source of truth
SettingsActivity settings/SettingsActivity.kt programmatic settings UI
SpotifyPlaybackService SpotifyPlaybackService.kt lockscreen controls + process keep-alive
NetworkMonitor NetworkMonitor.kt connectivity-aware cacheMode
Injector inject/Injector.kt the only CSS/JS injection gateway
ThemeManager / ThemeParser inject/ theme → palette → CSS pipeline
JsBridge js/JsBridge.kt @JavascriptInterface JS↔native channel
Assets assets/ core-spooty.js/.css, ad-engine.js, polyfill, themes

Hard invariants (do not break)

These mirror AGENTS.md and are enforced by lint + review:

  1. The WebView is the only rendering surface. All navigation via shouldOverrideUrlLoading; external links open externally, never in-app.
  2. JsBridge is the only JS→native channel and every exposed method is annotated @JavascriptInterface.
  3. Injection only through the Injector in onPageFinished, guarded by window.__spootyInjected. Never add ad-engine/theme/polyfill logic elsewhere.
  4. Flexible playback ownership. The SpootyPlaybackService keeps the process alive; the WebView's own <audio> owns the actual stream. Native ExoPlayer never re-hosts it.
  5. Ad-skip is opt-in. Mute is the default. Changing this default requires an explicit user decision — it's a project invariant.

WebView security posture

  • javaScriptEnabled only because the player needs it.
  • allowFileAccessFromFileURLs / allowUniversalAccessFromFileURLsfalse.
  • mixedContentMode = MIXED_CONTENT_NEVER_ALLOW, usesCleartextTraffic = false.
  • onReceivedSslError always calls handler.cancel().
  • All bridge methods gated with @JavascriptInterface.

Testing strategy

  • JVM unit tests (app/src/test) cover the pure logic: ad-mode mapping, theme parsing, ad-mode behavior. Framework code stays thin so almost everything interesting is testable on the JVM.
  • CI runs lint + ktlint + detekt + unit tests + assembleDebug on every push/PR and on-demand, and produces a debug APK artifact for sideloading.