Frostify
An Android music player with no backend of its own
Search and play music from SoundCloud, Audius, Bandcamp, the Internet Archive and Jamendo, with no account and no server of mine in between - every request originates from the user’s own device, and the app asks for almost no permissions to do it.
01No backend, and what that costs
There is no Frostify server. Requests go from the phone to the provider directly, which means no relay holds a listening history, there is nothing to subpoena, nothing to breach, and no hosting bill that grows with adoption.
It also means rate limits and regional availability are the user’s and not mine, which the project documents instead of hiding. Free tracks play as progressive audio in the WebView; SoundCloud’s protected catalogue plays through the device’s own Widevine CDM using the standard EME flow via Shaka Player. Nothing here circumvents DRM - it uses the licensed path, which is more work and the only version that can carry a company name.
02The Rust layer is a boundary, not a proxy
A WebView cannot make some provider requests because of CORS, so a Rust HTTP layer makes them instead. That is a request-forwarding component inside a consumer app, which is the classic shape of a server-side request forgery hole, so it is written as a boundary with rules rather than as a convenience.
Initial URLs are restricted to approved provider domains. HTTPS on the standard port only. Embedded URL credentials rejected, only GET, HEAD and POST accepted, fixed request and response size limits, sensitive headers stripped, and errors that never hand a full URL back to the UI.
The address filter is not a loopback check. Twenty-eight ranges are refused across both families: the private and loopback blocks, and also carrier-grade NAT, link-local, the three documentation networks, the benchmarking range, the 6to4 relay anycast address, IETF protocol assignments, unique-local and site-local IPv6, the discard prefix and segment-routing space. An IPv4-mapped IPv6 address is unwrapped and checked as IPv4, and so is the IPv4 embedded in a NAT64 address - the two places this kind of filter is usually walked around.
The filter sits inside the resolver rather than in front of it. A custom DNS resolver drops every non-public address a name resolves to and fails the request outright if nothing public is left, so there is no gap between the check and the connection for the answer to change underneath it.
A redirect has to pass the transport rules and land back on a provider domain. Audius is the exception, because it resolves a stream by delegating to an independently operated content node - and the URL the chain started on decides whether that is allowed, so a provider open redirect cannot hand the layer to an unrelated host.
The rule written into the project is that this boundary is not weakened to fix a provider integration. If a provider needs a domain, the domain and its tests get added.
03The native half
A Kotlin MediaSession and foreground service give background playback, notification and lockscreen controls, Bluetooth and Android Auto - the parts that decide whether a music player is usable at all, and none of which a WebView can provide.
The media browser validates the calling package and UID through AndroidX before it returns anything, because Android Auto browsing is an interface any app on the device can ask to speak to. Internal service actions require the app’s own token, and artwork downloads stay HTTPS-only, provider-scoped, redirect-checked and bounded in time and size.
On the device: SQLite for likes, playlists and listening history, WebView storage for settings and playback position, LRCLIB for time-synced lyrics, and responsive layouts for phones and tablets.
04Shipping it
The manifest is treated as a permission budget: backups off, cleartext traffic disabled, no camera, microphone, location or storage permission, no FileProvider, no deep link, and no notification permission beyond the media-session exemption. An Android music player has no business asking for any of those.
A release is not finished when the build passes. The exact signed artifact goes onto physical devices, its signature is verified, its checksum published, and an upgrade from the previous public version is tested for data loss - and background playback, the media notification, lockscreen and Bluetooth controls, Android Auto and Widevine playback are all exercised before it goes out.
05Decisions worth explaining
The address check belongs inside the resolver
Checking a hostname and then connecting to it are two operations, and between them the answer can change - which is the whole of DNS rebinding. So the filter is not a step before the request; it is a DNS resolver that returns only public addresses and errors when a name has none. The request cannot reach an address the resolver never handed out, which removes the race instead of narrowing it.
The permissions an app does not ask for
Every permission in the manifest is one a reviewer, a store listing and a privacy-minded user has to be convinced about. Keeping the list at almost nothing means most of those conversations never happen, and it disciplines the design: a feature that would need location access has to justify itself against the whole product, not against a checkbox.
Naming what the providers might not like
The project documents that SoundCloud’s unofficial API and Bandcamp page parsing may conflict with those services’ terms, while Audius, the Internet Archive and Jamendo expose public APIs. Writing that down is uncomfortable and correct; a music app that implies every source is blessed is the one that surprises its users later.
Questions about this project?
I am happy to walk through the architecture, the parts that did not work, or the code itself.