NewThe Android app is out. Share a video from any app and save it.Get it
All articles

September 11, 2026 · VidPickr Team

Five Bugs From Shipping an Android App Google Play Will Never List

Five Bugs From Shipping an Android App Google Play Will Never List

Five Bugs From Shipping an Android App Google Play Will Never List

We shipped an Android app for VidPickr this week. It does what the website does, plus the three things a browser tab cannot: sit in the share sheet, keep downloading after you switch away, and save files where your gallery will find them.

It is not on Google Play and it never will be. Play's Device and Network Abuse policy prohibits apps that download from services whose terms do not allow it, and that covers every YouTube downloader without exception. So the app is an APK on our own site, which turns out to change more about the engineering than you would guess.

What I want to write up is not the architecture. It is the five bugs that ate the week, because four of them produced no error message at all. Nothing crashed. Nothing appeared in a log. In each case the symptom pointed somewhere other than the cause, and in three of them the thing that looked broken was working perfectly.

1. The app produced videos with no sound, and said nothing

The first real download came back as a 63 MB file that played fine and was completely silent.

Above 360p, YouTube serves video and audio as separate streams. Our API returns a format list, and I had the app take the video URL from the format row and the audio URL from a separate audio_tracks array in the same response. That looked obviously right. It was obviously wrong.

audio_tracks is a dub-track menu. It lists alternate language audio for videos that have them, and for plenty of videos it is simply empty. When it was empty, the app downloaded video only, muxed video only, and wrote a perfectly valid MP4 with one track in it.

The fix was to stop reading that list. Every format token already carries both streams, and the server has an endpoint that splits one into two. One call, no guessing, and it cannot come back empty because it is derived from the token the user just chose.

But the fix is not the lesson. The lesson is the second half of the change. My muxer had this in it:

if (audio != null) {
    open(audio, "audio/")?.let { sources += it }
}

If the audio file could not be read, that quietly added nothing and carried on. A silent video is the single worst outcome available here: it looks like success, the person only finds out when they play it back somewhere else, and there is nothing anywhere explaining why. It now throws instead. If the audio was downloaded and cannot be muxed, the whole download fails and says so.

I had criticised exactly this pattern in our server code three days earlier and then wrote it myself in Kotlin.

2. Google sign-in failed, on a server that had already signed them in

The next one is my favourite because every piece of evidence pointed at Google.

Tapping "Continue with Google" showed the account picker, the person chose their account, and the app said Google sign-in failed. Straightforward, surely: the client ID, the SHA-1 fingerprint, the audience claim, something in the OAuth setup.

Three things said otherwise once I looked:

  • Play Services logged FetchGoogleIdTokenCredentialOperation Operation succeeded. Google had issued the token.
  • nginx logged POST /api/auth/google/app 200. The token had reached us and we had accepted it.
  • The database had last_login_at for that account set to the exact second of that request.

The server had verified the token, found the user, minted a session and returned it. Everything worked. Then the app said it failed.

The cause was three lines below where everyone was looking. The account picker has to run on the main thread because it puts UI on screen, so the function was called from a main-thread coroutine, and I had left the blocking HTTP call there too. Android throws NetworkOnMainThreadException for that.

Two properties of that exception made it invisible. It is a RuntimeException, not an IOException, so my carefully written "could not reach the server" catch did not catch it. And its message is null. My error handling was:

_signIn.value = SignIn.Failed(t.message ?: "Google sign-in failed.")

So the sentence on screen was not a diagnosis. It was my fallback string, and it happened to name the one component that was working.

The request still reached the server because the exception is thrown after the socket work is already under way, which is exactly the kind of detail that makes you distrust your own reasoning for twenty minutes.

Now the network call runs on an IO dispatcher, and an exception with no message reports its class name instead of a fallback sentence. If it had done that from the start the log would have read NetworkOnMainThreadException and the fix would have taken a minute.

3. Two hundred progress updates, and Android killed the app

The update flow downloads a 12 MB APK and hands it to the system installer. Tapping Update froze the app long enough for Android to put up VidPickr isn't responding.

The download was on a background thread. The problem was the progress callback: it fired once per 64 KB chunk, about two hundred times, and each one updated a state flow that recomposed the banner. On a slow device that is enough to wedge the main thread.

The same mistake was already sitting in the download service, where a 300 MB video meant nearly five thousand updates. That one had never produced a visible failure, which is the only reason it survived.

Both now report only when the whole percent changes. The UI cannot display more than that anyway.

What makes this one worth writing down is that I had fixed the identical bug two days earlier in a different place. Progress notifications were firing per chunk and Android was shedding them with rate limit (5.0) exceeded in the log, so the progress bar sat still while the download ran perfectly. I throttled the notifications, understood the problem completely, and then wrote the same thing twice more in the layer above.

4. The dashboard said 95 downloads. There had been about twelve

The admin dashboard has a downloads-by-surface panel. The day the app started making real requests it showed 95 downloads from Android before the app existed anywhere outside my emulator.

Every download costs a length probe plus four ranged parts, per leg, and a merged download has two legs. Ten requests, one download. The server was counting each range request as a download.

This was never app-specific. The website's in-browser muxer works the same way, and 25,576 of its 75,252 stream events that day were ranged pieces of downloads that had already been counted. The faster the client, the busier it looked.

A token is minted per format per metadata call, which is the closest thing to a download identifier we have. The first request carrying one is the download. Everything after it is that download continuing, recorded under a different event name so the detail survives without being counted twice.

The part I got wrong on the first pass was the memory. A map keyed by token grows forever, so it needed a ceiling and a sweep. The part I got right by accident: a request with no token still counts. Deduplication needs something to key on, and a download counted twice is a much smaller mistake than a surface reading zero.

5. Sideloading has a dead end in it, and it costs 12 MB to find

This one is not our bug, but it is ours to handle.

An app distributed outside the Play Store has to be trusted as an install source once. Until it is, handing an APK to the installer produces a dialog that says:

For your security, your phone currently isn't allowed to install unknown apps from this source. You can change this in Settings.

It names Settings and does not take you there. So the sequence was: tap Update, wait for a 12 MB download on a phone connection, and arrive at a dead end with no route out of it.

Android exposes both halves of this. canRequestPackageInstalls() answers the question before you spend the data, and ACTION_MANAGE_UNKNOWN_APP_SOURCES with a package URI opens the setting already filtered to your app, one toggle and nothing else on screen. The app checks first and offers the setting instead of the download.

There is one more wall past that. Play Protect stops the install with "Play Protect hasn't seen this app before" and offers to send the app to Google for a scan. Everyone distributing outside Play meets this, and there is nothing to do about it except tell people it is coming.

What actually made the app worth building

Two decisions, both about not doing the obvious thing.

Not React Native. Almost all of the work here is native plumbing. There is no React Native binding for MediaMuxer, and the website's download path depends on browser APIs that Android WebView does not implement, so a JS layer would have sat on top of Kotlin we had to write anyway.

Not ffmpeg. Android ships MediaMuxer, which does the same copy-mode mux our server does with ffmpeg, on the device, for free. The alternative was merging server side, which would put every byte through our infrastructure twice, and bandwidth is already the largest cost we carry. It also kept ffmpeg-kit, which was archived in early 2025, and its 25 MB of native libraries out of the APK. The whole app is 12 MB.

There is a third consequence nobody planned. The app sends no Origin header, so our stream endpoint redirects it straight to the CDN instead of proxying the bytes. App downloads mostly do not touch our servers at all, which makes the app the cheapest surface we run.

The thread running through all of it

Four of these five produced no error. A silent video, a sign-in that succeeded everywhere except in the sentence shown to the user, a progress counter that killed the app by working too well, and a dashboard confidently reporting a number that was eight times the truth.

None of them would have been caught by tests, because in every case the code did exactly what it said. The muxer muxed what it was given. The sign-in reported the exception it received. The progress callback reported progress. The counter counted requests.

What caught them was looking at the output. ffprobe on the downloaded file. nginx access logs next to the app's own logs. A row in the users table with a timestamp on it. The thing I would tell myself at the start of the week is that a green test suite tells you the code does what you wrote, and only the artifact tells you whether what you wrote was the right thing.


The Android app is on this page. It is free, it needs no account, and it is 12 MB.

Got a video to grab?

The tool itself is one click away.

Open vidpickr