Bukin Mic Recorder: an Android WAV recorder, from first commit to Google Play
Bukin Mic Recorder is a simple Android app that records uncompressed WAV audio, built mainly for USB microphones (it works with the phone’s built-in mic too). This page covers how it’s built, how it’s tested, and every step it took to get onto Google Play. The story behind it is on the blog: Twelve Strangers, Fourteen Days.
- Google Play: https://play.google.com/store/apps/details?id=bukin.wavrecorder
- Source code: https://github.com/EliBukin/WavRecorder
The stack
- Language: Kotlin 2.3.10, classic Android Views with View Binding and Material 3 (Day/Night theme)
- Android: min SDK 24 (Android 7.0), target SDK 36 (Android 16, Google Play’s requirement since August 31, 2026)
- Build: Android Gradle plugin 8.13.2, Gradle 8.14.6, JDK 21
- Tests: JUnit + Robolectric 4.16.1, 818 tests
- No Android Studio. Everything was built and tested from the command line on Linux, with the Android SDK command-line tools, an emulator and a real phone over adb.
- Built together with Claude (Anthropic’s AI) in Claude Code: I directed and tested, Claude wrote most of the code, the tests and the release scripts.
How it works
The app has two tabs, Record and Library. The interesting part is what happens behind the Record button.
RecordFragment (UI) ──binds to──▶ RecordingService (foreground service, "microphone" type)
│ owns
▼
WavRecorder
capture thread ──bounded queue──▶ writer thread
(AudioRecord) (WAV headers, file splits, finalization) A foreground service owns the recording. Since Android 9, a background app can’t use the microphone at all. A foreground service with the microphone type is the only way to keep recording with the screen off, and Android requires it to show a notification the whole time. The app’s notification has a Stop button, so you can end a recording from the lock screen.
Picking the recording format. Every phone and mic combination supports different formats. When a recording starts, the app tries candidate formats and asks Android what it actually captures from the device (AudioRecordingConfiguration.getFormat()), not just what it agrees to deliver (getClientFormat()), because Android can quietly resample or convert. The policy, highest first:
- Full band (at least 44.1 kHz) beats any telephone rate
- 24 bits or more beats 16-bit
- Then the higher sample rate
- Then the sample format (32-bit integer, 32-bit float, 24-bit, 16-bit)
- Then more channels
A hand-written WAV writer. AudioRecord gives raw PCM samples, and Android has no WAV muxer, so the app writes the RIFF/WAVE header itself (44 to 80 bytes depending on the format). It patches the size fields as the recording grows, so a file is still playable even if the app is killed mid-recording.
Splitting long recordings. Long recordings are split into 30, 45 or 60 minute files. A classic WAV file can’t be bigger than 4 GiB, and high-resolution audio gets there fast (192 kHz / 32-bit stereo fills it in about 46 minutes), so the app starts the next file early when it has to, instead of writing a broken header.
Crash recovery. A small on-disk journal records which file is being written. If the process dies, the next start finds the leftover file, fixes its header to cover exactly the audio that was written, forces it to storage and reads it back to verify, and only then clears the journal entry.
Where files go. By default recordings stay in the app’s own storage. You can also pick any folder through Android’s Storage Access Framework. From the Library you can play (with speed control), rename, share, save a copy and delete.
Testing
./gradlew checkruns all unit tests for both debug and release builds, plus lint.- Robolectric runs the Android framework on the JVM, so services, fragments and the recording lifecycle are tested without a phone.
- A
TestIsolationrule makes every test wait until all recording, mic test, recovery and delete threads it started have ended, and fails the test if something is still running. Work left over from one test used to make later tests fail at random. ProcessDeathRestorationTestsimulates Android killing the app mid-recording and checks what survives.- Every release was also tested by hand on the Galaxy A55 with the USB mic, including updating from the previous version without losing recordings.
Full gate before any release:
./gradlew --no-daemon --continue clean check lintRelease assembleDebug assembleRelease bundleRelease Building a signed release
Gradle never signs a release. A script does it in two separate steps:
# 1. Build exactly one commit in a clean temporary checkout, run every gate there,
# and keep the unsigned bundle, the R8 mapping and a build record in dist/
scripts/release.sh build <commit>
# 2. Re-check that directory against its build record, then sign it with the upload key
scripts/release.sh sign dist/<release> <upload-keystore.jks> <key-alias> Why so strict? The build runs from a git archive export, so uncommitted changes can never end up in a release. The sign step only accepts a directory that the build step wrote, with its files still matching the recorded hashes. Google Play keeps the real app signing key (Play App Signing), and I keep only the upload key.
The road to Google Play
New personal developer accounts must pass a closed test before they can publish to production. These are the steps it took:
- Developer account and identity verification. A one-time $25 fee, then Google verifies your ID (a few days).
- App content. A privacy policy (hosted free on GitHub Pages, with a copy inside the app), the Data safety form (what the app collects; for 1.3, nothing), content rating, target audience, and a foreground service declaration for the microphone, which needs a short video of the feature in use.
- Store listing. Icon, feature graphic, screenshots, short and full description.
- Internal testing first, for a quick check of the Play build on my own phone.
- Closed testing: 12 testers for 14 days in a row. I used a paid testing service for the testers. Google counts testers who opted in, not daily installs. Shipping updates during the test (1.1 and 1.2) shows Google the app is actively developed.
- Apply for production. A form with questions about the test: who tested, how engaged they were, what feedback you got, what you changed. Answer honestly, in your own words. Mine was approved in less than a day.
- Production release. Promote the tested release, pick countries (178), and send it for review. It went live three days later.
After that, updates go straight to production, through Google’s review each time, with no more 14-day test.
| Date (2026) | Milestone |
|---|---|
| July 26 | First commit |
| October 10 | 1.0 finished and tested on the phone |
| October 13–14 | Internal testing, then closed testing (alpha) |
| October 19 | 1.1: Share, Save a copy, Rename |
| October 24 | 1.2: Send feedback, What’s new card |
| October 30 | Applied for production access, granted the same day |
| November 2 | Live on Google Play |
| November 3 | 1.3 “Warm & Soft” redesign sent for review |
Lessons learned
- Start the closed test early. The 14 days are the slowest part, so get testers in as soon as the app is usable.
- Don’t paste someone else’s answers into Google’s forms. Testing services offer ready-made production answers; mine claimed things that never happened.
- Test updating, not just installing. Make sure an update from Play keeps the user’s recordings and settings.
- Never run
bmgr restoreon a phone with real recordings. Android wipes all of the app’s data before a full restore. - An AI partner speeds things up a lot, but you still have to test on a real phone and make the decisions yourself.