LongShot is an Android app that stitches a scrolling screen into one long screenshot. Two of its tools run on-device text recognition: Copy text pulls the text out of a capture, and Hide personal data finds card numbers, IBANs and phone numbers and covers them.
For a month, both were dead in every published build.
Not crashed. Not slow. Dead in the quietest way available: you tapped the button, waited, and got
Couldn't read this image.
No crash dialog. Nothing in Android Vitals — the app never crashed, so there was nothing to report. The crash rate for the entire period reads –. Every user who tried the feature concluded the app couldn't handle their screenshot.
They were wrong. It could not handle any screenshot.
Why it took five rounds to find
Because it worked. Every single time I tested it.
I test on a device over ADB with installDebug. The debug variant is not minified — that is the Android default, and I had never questioned it. So five rounds of "fixed it, please try again" were five rounds of verifying a code path R8 had never touched.
The false trails I burned first, in order:
Memory. The captures are tall — 1080 × 18660 is ordinary. I assumed the recogniser was being handed a bitmap it could not allocate, and rewrote the scanner to read the image one tile at a time through BitmapRegionDecoder.
A budget in the wrong unit. While doing that I found a genuine bug: the scan budget was expressed in pixels while the cost is bytes. The code's own comment described the danger, then set the ceiling at exactly the dangerous value.
A fix that was a no-op. I set inPreferredConfig = RGB_565 to halve the memory. Logging showed decoded 1080x3404 ARGB_8888. BitmapFactory ignores the preference for images with an alpha channel. My fix changed nothing — and I only knew because I had added the log line.
A bug I introduced myself. BitmapRegionDecoder reads from its InputStream on every decodeRegion call, not only at construction. I wrapped the stream in .use { }; every tile then failed. I verified the fix on a 2340-pixel capture — the one input short enough never to be tiled, and therefore the exact input that cannot exhibit the bug.
All of that was real work. None of it was the cause.
What cracked it was a question from the person reporting the bug:
Is this about the library not being included in the app when you export it?
What R8 actually did
ML Kit does not find its components through any call R8 can see. The merged manifest names them in a <meta-data> tag, and a component runtime instantiates each one by reflection:
<meta-data
android:name="com.google.mlkit.vision.text.internal.TextRegistrar"
android:value="com.google.firebase.components.ComponentRegistrar" />
The library ships a consumer rule for exactly this case. Here it is, copied out of configuration.txt in my own release build:
-keep class * implements com.google.firebase.components.ComponentRegistrar
That rule keeps the class. It says nothing about the class's members — and in ProGuard/R8 that is the entire difference. -keep class X guarantees X survives shrinking. It guarantees nothing about what is inside X.
So R8 kept TextRegistrar, observed that nothing in the program ever calls its no-argument constructor — nothing does, in bytecode — and removed it. usage.txt, R8's record of what it deleted, named it in one line:
com.google.mlkit.vision.text.internal.TextRegistrar:
public void <init>()
At runtime the component runtime asks for an instance, finds no constructor to invoke, initialisation throws, and TextRecognition.getClient() fails. My code catches it:
val recognizer = try {
TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
} catch (t: Throwable) {
Log.e(TAG, "scan: text recogniser unavailable", t)
return Result(emptyList(), 0, 0)
}
— and the UI, having examined zero pixels, reports that it couldn't read the image. Which is true, and completely useless as a diagnosis.
The fix
One line. It is the stock rule with a member specification added:
-keep class * implements com.google.firebase.components.ComponentRegistrar { <init>(); }
You do not have to take that on faith, and neither did I. Comment the line out,
leaving the library's own rule in force, and rebuild:
$ grep '<init>' app/build/outputs/mapping/debug/usage.txt # what R8 DELETED
com.google.mlkit.vision.text.internal.TextRegistrar:
public void <init>()
$ grep TextRegistrar app/build/outputs/mapping/debug/seeds.txt # what R8 KEPT
com.google.mlkit.vision.text.internal.TextRegistrar
One line in seeds.txt: the class, and nothing else. Put the rule back and
build again:
$ grep TextRegistrar app/build/outputs/mapping/release/seeds.txt
com.google.mlkit.vision.text.internal.TextRegistrar
com.google.mlkit.vision.text.internal.TextRegistrar: TextRegistrar()
Two lines: the class and its constructor. Same source, same library, same
stock rule present in both — the only variable is the member specification.
In my build that single rule covers seven registrars.
About the advice you will find instead
Search for this problem and you will be told to write:
-keep class com.google.mlkit.** { *; }
That works. It also switches off shrinking and obfuscation across the entire ML Kit surface in order to save one constructor. The narrow rule is the same fix at a fraction of the cost — and it is not ML Kit-specific. It covers any ComponentRegistrar, which is how the whole Firebase component system is discovered.
The change that actually matters
Fixing the rule fixes today's bug. It does nothing about the reason the bug lived for a month.
That defect was in the build configuration:
debug {
// Minified like release, on purpose. The text features were broken in every
// published build and worked in every debug build, because R8 runs only in
// release and strips a constructor ML Kit needs by reflection. A debug variant
// that does not run R8 cannot reproduce the one class of bug that reaches users,
// so this one does. It costs build time; it buys the ability to test what ships.
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
A debug variant that skips R8 cannot reproduce any R8 bug. That is not a small blind spot. Reflection-driven initialisation is exactly where release-only failures live, and it fails silently rather than loudly — no crash, no stack trace, no Vitals entry, just a feature that quietly does nothing.
Builds got slower. In exchange, the thing I test is the thing I ship.
Afterwards
On a minified build, against a 1080 × 18660 capture:
scan: 299 raw lines over 14 tiles (0 failed)
redact: 286 lines over 14 tiles (0 failed), 3 region(s), full-res
copyText: 286 lines over 14 tiles (0 failed), full-res
Zero text recogniser unavailable. 125 unit tests, 0 failures.
If you ship ML Kit, Firebase, or anything with a ComponentRegistrar
Three checks, all cheap, all on a build you have already made:
-
grep '<init>' app/build/outputs/mapping/release/usage.txt— anything reflection reaches whose constructor appears here is already broken in what you shipped. -
grep Registrar app/build/outputs/mapping/release/seeds.txt— every registrar should appear twice: once as a class, once as a constructor. Once means the constructor is gone. - Turn on
isMinifyEnabledfor debug and launch the app.
The third one is the one that finds the next bug instead of this one.
LongShot is free on Google Play. It captures with MediaProjection rather than an AccessibilityService, so it cannot read or act inside other apps — the trade is that you scroll manually. It declares no INTERNET permission: INTERNET and ACCESS_NETWORK_STATE are removed from the merged manifest with tools:node="remove", including the copies ML Kit would otherwise merge in, so the operating system refuses any socket rather than the developer promising not to open one. You do not have to take my word for that one — run aapt dump permissions on the APK.










