An offline-only music player for Android. React Native, Expo SDK 57, TypeScript.
It plays the files a streaming service will not: FLAC, ALAC, and anything else
already on the phone. It surfaces the technical truth of a file rather than
hiding it. And it does not have a network layer — not a disabled one, not one
behind a setting. Release builds ship without the INTERNET permission at all.
Latest release — an APK
you can sideload. It ships without the INTERNET permission; the release notes
say how to check that yourself, and what the build is signed with.
More, with what each one is showing, in docs/screenshots.md.
- Playback of local files, lossless first-class, with background playback, lock-screen controls and a persistent queue.
- Five shuffle algorithms, chosen in Settings, each explained where you choose it. Not one shuffle behind a toggle — see docs/shuffle.md.
- Local playlists with drag-reorder, a cover mosaic, and shuffle.
- Listening statistics computed on the device from your own history: top tracks, artists, albums and playlists by week, month and year, with a Wrapped summary. Nothing is uploaded because there is nowhere to upload it to.
- Technical metadata surfaced: bitrate, sample rate, bit depth, codec, file size, on a monospaced spec strip.
- Dark and light themes, Turkish and English, both switchable.
No network. No accounts. No telemetry. No analytics SDK.
This is enforced rather than intended: plugins/withOfflineOnly.js strips the
INTERNET permission from the release manifest and restores it only for debug
builds, where Metro needs it. A change that introduces a network call does not
fail review — it fails to work.
- Node 22+
- JDK 17 or 21 — Android Studio's bundled JBR is fine, no separate install
- Android Studio with SDK Platform 36+
- macOS or Linux
minSdkVersion is 26. Raising it to 31 was considered and rejected: it costs
roughly a fifth of Android devices while deleting almost no code. See
ADR 002.
None of this is set by default, and every "it works on my machine" failure in
this project so far has been one of these three lines missing. Put them in
~/.zshrc:
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export ANDROID_HOME="$HOME/Library/Android/sdk"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"Verify before blaming the code:
java -version # 17 or 21
adb devices # your device, "device" not "unauthorized" or "offline"
npx expo-doctorThen:
npm install
./app.sh # checks the environment, finds a device, starts the dev buildapp.sh (and app.bat on Windows) only checks the environment — it will tell
you exactly what is missing and stop. It will not set JAVA_HOME or
ANDROID_HOME for you, because silently changing a developer's toolchain
environment is a worse failure than an error message.
npx expo start --dev-client # the normal one. JS/TS changes hot reload.
npm run lint
npm run typecheck
npm test
npm run db:generate # after a schema changenpx expo run:android is only for native changes: adding or removing a
native dependency, editing app.json, or changing a config plugin. It is a
ten-minute build, and reaching for it after a TypeScript edit is the most common
way to waste an afternoon here.
Kotlin has its own tests:
cd android && ./gradlew :audio-tags:testDebugUnitTestadb install -r works on the emulator and, in practice, on MIUI too — including
updating in place over an existing build without losing its data. If it does
fail with INSTALL_FAILED_USER_RESTRICTED, which MIUI does depending on how the
device is configured, push the file and install it from the phone instead:
adb push android/app/build/outputs/apk/debug/app-debug.apk /sdcard/Download/MIUI does reliably block adb shell input and pm grant with a
SecurityException, so automated UI testing on those devices is not
possible — taps need a human. Use the emulator for behaviour and keep the
phone for what only it can answer: old-API paths, real frame timing, the media
notification, and anything involving headphones or a phone call.
It blocks input, not the build, and that is enough for a rendering bug the
emulator will not reproduce. Put the app into the state you need from code — a
setTimeout in PlayerLayer that opens the sheet on launch — and add
android:showWhenLocked="true" android:turnScreenOn="true" to .MainActivity
in the generated manifest, so am start wakes a sleeping screen. Then
exec-out screencap for what it drew and dumpsys activity top for the native
view tree with bounds. Both patches are throwaway, and an incremental
assembleRelease is about 35 seconds, so it is fast enough to bisect with.
cd android
./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk
./gradlew bundleRelease # app/build/outputs/bundle/release/app-release.aabThe APK is universal — every ABI in one file, which is why it is around 130 MB. That is the one to sideload or hand to somebody. The AAB is the one to upload: Play splits it per device and what people download is a fraction of that.
Both are currently signed with the debug key, which is fine for sideloading and
not fine for the Play Store. A release key belongs in ~/.gradle/gradle.properties,
never in the repository.
Publishing one:
gh release create v1.0.3 \
android/app/build/outputs/apk/release/app-release.apk#mufify-1.0.3.apk \
--title "Mufify 1.0.3" --notes-file <notes>Raise version and android.versionCode in app.json first. Android refuses
an install whose versionCode does not climb, so shipping two builds on the
same number makes the second one undownloadable in practice.
The artifacts are gitignored along with the rest of android/ — a 128 MB binary
does not belong in the history. The release is where it goes.
What the release build is checked for, and what assembleRelease produced on
2026-08-05:
| Check | Result |
|---|---|
INTERNET permission |
absent — this is the whole promise, and plugins/withOfflineOnly.js is what keeps it out |
RECORD_AUDIO, SYSTEM_ALERT_WINDOW, WRITE_EXTERNAL_STORAGE |
absent, blocked in app.json |
| Debuggable | no |
versionCode / versionName |
5 / 1.0.3 |
One permission does survive that is worth knowing about: ACCESS_NETWORK_STATE,
pulled in by a dependency rather than asked for here. It cannot open a
connection — that needs INTERNET, which is absent — but a Play Store listing
renders it as "view network connections", which reads oddly next to the claim on
this page. It is deliberately not blocked yet: removing a permission a
library expects is the kind of change that fails at runtime on a device rather
than at build time, and it has not been tested on hardware.
app/ routes only — read params, render a screen, nothing else
src/
components/ui/ shared presentational components
features/ one directory per feature: screens, components, hooks
services/ pure logic — shuffle, stats, scanner, formatters
db/ schema, migrations, and the only place Drizzle is imported
theme/ design tokens, in exactly two files
i18n/ en.json and tr.json, kept in step by a test
modules/ local native modules (Kotlin)
Four rules that are bugs rather than preferences when violated:
- Only
src/services/audio/*imports the audio library. - Only
src/db/queries/*imports Drizzle or expo-sqlite. - No business logic in component bodies.
- Layers point downward:
components → hooks → services → db.
docs/architecture.md goes further: the startup ordering, the two flows worth tracing, and why there is no global state library.
| AGENTS.md | the house style, binding on humans and agents alike |
| docs/screenshots.md | what it looks like, with what each screen is doing |
| docs/architecture.md | how the pieces fit, and where state lives |
| docs/components.md | what each shared component is for |
| docs/theming.md | the token system, and how to add a colour |
| docs/i18n.md | how to add a string and a language |
| docs/database.md | schema, indexes, the play-counting rule |
| docs/scanner.md | the two-stage scan and artwork cache |
| docs/player.md | the audio engine and its Android gotchas |
| docs/shuffle.md | each algorithm in plain language |
| docs/stats.md | events, rollups, period keys, repeat detection |
| docs/performance.md | measurements, before and after |
| docs/adr/ | every non-obvious decision, with its reasoning |
See CONTRIBUTING.md. The short version: read AGENTS.md
first, and lint, typecheck and test must all pass before a commit counts
as done.
MIT. See LICENSE.



