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:
- The WebView is the only rendering surface. All navigation via
shouldOverrideUrlLoading; external links open externally, never in-app. JsBridgeis the only JS→native channel and every exposed method is annotated@JavascriptInterface.- Injection only through the Injector in
onPageFinished, guarded bywindow.__spootyInjected. Never add ad-engine/theme/polyfill logic elsewhere. - Flexible playback ownership. The
SpootyPlaybackServicekeeps the process alive; the WebView's own<audio>owns the actual stream. Native ExoPlayer never re-hosts it. - 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¶
javaScriptEnabledonly because the player needs it.allowFileAccessFromFileURLs/allowUniversalAccessFromFileURLs— false.mixedContentMode = MIXED_CONTENT_NEVER_ALLOW,usesCleartextTraffic = false.onReceivedSslErroralways callshandler.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 +
assembleDebugon every push/PR and on-demand, and produces a debug APK artifact for sideloading.