●PRO35 — July 17, the date reports had pointed to, has passed without an official Gemini 3.5 Pro announcement or model card. July 24 is being cited as the fallback●NB2LITE — Nano Banana 2 Lite, otherwise known as Gemini 3.1 Flash-Lite Image, arrives as the fastest of the family: roughly four seconds per image at $0.034 per thousand●OMNI — Gemini Omni Flash enters public preview, generating video up to ten seconds long at $0.10 per second of output●EDIT — Omni Flash is built around conversational editing. Swap a character, relight a scene, or change the angle in plain language, and the original audio and video tracks stay intact●SYNTHID — Both new models carry SynthID watermarking, so anything they produce can be checked for provenance from inside the Gemini app●SHUTDOWN — The older image generation models are deprecated and switch off on August 17. Worth checking your migration window●PRO35 — July 17, the date reports had pointed to, has passed without an official Gemini 3.5 Pro announcement or model card. July 24 is being cited as the fallback●NB2LITE — Nano Banana 2 Lite, otherwise known as Gemini 3.1 Flash-Lite Image, arrives as the fastest of the family: roughly four seconds per image at $0.034 per thousand●OMNI — Gemini Omni Flash enters public preview, generating video up to ten seconds long at $0.10 per second of output●EDIT — Omni Flash is built around conversational editing. Swap a character, relight a scene, or change the angle in plain language, and the original audio and video tracks stay intact●SYNTHID — Both new models carry SynthID watermarking, so anything they produce can be checked for provenance from inside the Gemini app●SHUTDOWN — The older image generation models are deprecated and switch off on August 17. Worth checking your migration window
I Was Handing Gemini Obfuscated Stack Traces — Until retrace Went In Front, the Diagnoses Were Confident and Wrong
Release stack traces come out of R8 with the names flattened. Feed one to Gemini as-is and the diagnosis arrives calm, well-written, and wrong. Put retrace in front, match the mapping by versionCode, and forbid confident answers when you cannot restore. Numbers from 42 reports.
java.lang.NullPointerException at a.a.a.b(SourceFile:0)
Given that single line, the model told me — calmly, with a code fix attached — that a RecyclerView.Adapter was reading a stale position in onBindViewHolder after the backing list had been swapped out asynchronously.
I believed it for a while. The symptom sounded familiar. But when I traced the actual code, a.a.a.b turned out to be a utility that assembles image cache keys. No adapter anywhere. No RecyclerView anywhere.
The model had not lied. There was simply no information in the string a.a.a.b to begin with. Ask for something plausible where nothing is known, and something plausible is exactly what comes back. An obvious thing, which I managed to overlook for about three weeks.
The model answered a.a.a.b(SourceFile:0) with confidence
I run Android wallpaper apps as an indie developer, and I collect non-fatal exceptions — the ones that get swallowed rather than crashing, but leave holes in the UI if ignored — into my own sink.
That is where the blind spot was. Open the Firebase console and the stack traces are rendered with human-readable names, because the Crashlytics Gradle plugin uploaded the mapping file at build time and the console restores them for you. I looked at that console every day, so I had quietly assumed that Android crash data simply arrives readable.
Nothing restores the traces I catch myself through Thread.setDefaultUncaughtExceptionHandler or a try-catch.
// Where non-fatal exceptions get queued to my own sink.// In release builds, e.stackTraceToString() has already been flattened by R8.private fun reportNonFatal(e: Throwable, context: String) { val payload = NonFatalPayload( versionCode = BuildConfig.VERSION_CODE, // this matters later versionName = BuildConfig.VERSION_NAME, context = context, stackTrace = e.stackTraceToString(), // release: "a.a.a.b(SourceFile:0)" ) sink.enqueue(payload)}
In debug builds this field is perfectly readable — R8 only runs for release. Readable on my machine, flattened in production. And only the production ones reached my analysis pipeline.
Those were the ones I was sending to Gemini.
Deobfuscation is not a probabilistic problem
This is the part I most want to hand over.
Turning a.a.a.b back into WallpaperCacheKeyBuilder.append is a lookup. There is a mapping file, there is exactly one correct answer in it, and no ambiguity anywhere.
A language model does something else entirely: it picks the most likely continuation given context. Without the table, a.a.a.b cannot be recovered — but push for an answer anyway and you get whatever Android crashes are most common in the training data. That, I suspect, is where RecyclerView came from.
I had taken a problem with a deterministic answer and routed it through a probabilistic system. That is not a limitation of Gemini. That was my design mistake.
So I reset the rule I work by: if a lookup table answers it, use the lookup table. Ask the model only what becomes knowable after the table has been read.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you have been getting plausible-but-wrong crash diagnoses, you can flip them to evidence-backed ones by adding a single deobfuscation step before the prompt
✦You get a complete Python pipeline that drops the mapping-in-the-prompt approach and instead matches mapping files by versionCode before restoring names
✦You get a responseSchema and a decision table that separate traces you should discard, refuse to let the model assert on, or pass through untouched
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
My first idea was the naive one: include the mapping file in the prompt and let the model do the matching.
Measuring my own release mapping file ended that plan.
Item
Measured
mapping.txt size
6.8 MB
Lines
~91,000
Rough countTokens estimate
~2M tokens
Lines a single trace actually needs
6–12
Reading one crash needs about ten lines. Shipping ninety-one thousand of them on every call fails on principle before it fails on cost. And even if it fit, you would be asking a probabilistic system to find the right row in a two-million-token table — which lands us right back in the previous section.
R8 ships an official retrace tool. If you have the Android SDK command-line tools, it is already there.
# The mapping file lands in your release build outputsls app/build/outputs/mapping/release/mapping.txt# Pass a trace, get the names back$ANDROID_HOME/cmdline-tools/latest/bin/retrace \ app/build/outputs/mapping/release/mapping.txt \ crash.txt# With no file argument it reads stdincat crash.txt | $ANDROID_HOME/cmdline-tools/latest/bin/retrace mapping.txt
The output changes like this:
# Before (what I had been sending to Gemini)java.lang.NullPointerException at a.a.a.b(SourceFile:0) at a.a.c.a(SourceFile:0)# After (through retrace)java.lang.NullPointerException at net.dolice.wallpapers.cache.WallpaperCacheKeyBuilder.append(WallpaperCacheKeyBuilder.kt:47) at net.dolice.wallpapers.cache.DiskCacheWriter.write(DiskCacheWriter.kt:112)
Given the second version, the model dropped RecyclerView entirely and pointed at the null being held during cache-key assembly. I had not changed a single character of the prompt. I had changed one step that happens before it.
A mapping from the wrong versionCode lies quietly
Right after adding retrace, I stepped into the next hole.
R8 assigns names per build. A class that was a.a.a in v2.1.0 can belong to something else entirely in v2.1.1 — the namespace is simply reused.
Which means running retrace with a mismatched mapping does not raise an error. Plausible class names come out, quietly. That is worse than leaving the trace obfuscated, because neither a human nor a model questions a name that looks restored.
So mappings get stored keyed by versionCode, and names get restored only on an exact match.
State
Decision
What Gemini receives
Mapping with matching versionCode exists
Restore
Restored trace; diagnosis must cite evidence
No mapping / fetch failed
Do not restore
Marked as obfuscated; assertions forbidden, angles only
Only a different versionCode available
Do not use
Same as above. Never substitute a "close enough" build
The third row is the point. The pull toward using a nearby mapping is strong, and giving in there quietly rots the foundation of every diagnosis after it. My recommendation is to drop such a trace down to row two and treat it as obfuscated. I closed that door in code rather than in discipline.
If line numbers are gone, check the R8 setup first
When a frame reads SourceFile:0, restoring it only gets you back to a class and a method. You lose a level of resolution.
AGP's bundled proguard-android-optimize.txt already keeps the attributes you need for line numbers.
// proguard-rules.pro — the first thing to check is whether you// have overridden or stripped what the default file already provides-keepattributes SourceFile,LineNumberTable-renamesourcefileattribute SourceFile
Google Play Console's Android Vitals will also show you restored frames once the mapping is uploaded. Having several readable screens around is precisely what dulled my attention to the strings actually flowing through my own pipeline.
In my case I had not touched any of it. The line numbers were being lost somewhere else entirely: a formatting step in my sink, written to trim long traces, was clipping the trailing line numbers along with them. The lesson being that R8 is not automatically the culprit.
On the Gradle side, it helps to keep two things separate in your head: uploading the mapping for the Crashlytics console, and archiving the mapping for your own pipeline.
android { buildTypes { release { isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) // For restoration in the Crashlytics console. Your own pipeline still // needs to archive mapping.txt keyed by versionCode, independently. configure<com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension> { mappingFileUploadEnabled = true } } }}
Have your release CI job copy app/build/outputs/mapping/release/mapping.txt to mapping/{versionCode}.txt. That one line is what makes everything downstream possible.
What to do with traces you still cannot restore
Versions whose mapping is gone. Logs shared from outside. Payloads with no versionCode. Unrestorable traces will always exist.
Discarding them is wasteful; diagnosing them straight is dangerous. Where I landed was to forbid assertion structurally.
Split the instruction by whether names were restored
from google import genaifrom google.genai import typesclient = genai.Client() # reads GEMINI_API_KEY from the environmentDIAGNOSIS_SCHEMA = { "type": "object", "properties": { # Force citation of lines that actually exist in the trace. # If it is still obfuscated, a.a.a.b comes through and we can reject it later. "evidence_frames": { "type": "array", "items": {"type": "string"}, "minItems": 1, }, "root_cause": {"type": "string"}, # For obfuscated input, the instruction pins this to low. "confidence": {"type": "string", "enum": ["high", "medium", "low"]}, # When confidence is low, return things to check, not a cause. "next_checks": {"type": "array", "items": {"type": "string"}}, }, "required": ["evidence_frames", "root_cause", "confidence", "next_checks"],}DEOBFUSCATED_INSTRUCTION = ( "You diagnose Android exceptions. The stack trace has been restored with an R8 " "mapping, so class and method names are real. In evidence_frames, quote only " "lines that literally appear in the trace. Do not write speculation you cannot quote.")OBFUSCATED_INSTRUCTION = ( "You diagnose Android exceptions. The stack trace below is obfuscated by R8; " "class and method names are gone. Do not guess at real names. You are forbidden " "from naming specific libraries such as RecyclerView or Glide without evidence. " "Set confidence to low, set root_cause to 'not identifiable while obfuscated', " "and list in next_checks only how to locate the mapping that would restore this.")def diagnose(stack_trace: str, deobfuscated: bool) -> dict: resp = client.models.generate_content( model="gemini-3.5-flash", contents=stack_trace, config=types.GenerateContentConfig( system_instruction=( DEOBFUSCATED_INSTRUCTION if deobfuscated else OBFUSCATED_INSTRUCTION ), response_mime_type="application/json", response_schema=DIAGNOSIS_SCHEMA, temperature=0.0, ), ) return resp.parsed
Check the evidence, drop what does not match
Requiring evidence_frames did the heavy lifting. Forced to quote, the model cannot invent a frame that is not in the trace — and when it does anyway, the next step catches it mechanically.
def verify(result: dict, stack_trace: str) -> tuple[bool, str]: """Reject any diagnosis that cites a line the trace does not contain.""" for frame in result["evidence_frames"]: if frame.strip() not in stack_trace: return False, f"cited a frame that does not exist: {frame}" if not result["evidence_frames"]: return False, "no evidence provided" return True, "ok"
import subprocess, pathlibMAPPING_DIR = pathlib.Path("mapping") # where mapping/{versionCode}.txt livesRETRACE = pathlib.Path("/opt/android-sdk/cmdline-tools/latest/bin/retrace")def resolve_mapping(version_code: int) -> pathlib.Path | None: """No substituting a nearby build. Exact match only.""" path = MAPPING_DIR / f"{version_code}.txt" return path if path.exists() else Nonedef retrace(stack_trace: str, mapping: pathlib.Path) -> str: proc = subprocess.run( [str(RETRACE), str(mapping)], input=stack_trace, capture_output=True, text=True, timeout=30, ) if proc.returncode != 0: raise RuntimeError(f"retrace failed: {proc.stderr.strip()}") return proc.stdoutdef analyze(payload: dict) -> dict: mapping = resolve_mapping(payload["versionCode"]) if mapping is None: # Cannot restore: take angles only, with assertion forbidden return diagnose(payload["stackTrace"], deobfuscated=False) restored = retrace(payload["stackTrace"], mapping) result = diagnose(restored, deobfuscated=True) ok, reason = verify(result, restored) if not ok: result["confidence"] = "low" result["next_checks"].insert(0, f"evidence check failed: {reason}") return result
resolve_mapping returning only exact matches is the third table row, closed in code. The place you would be tempted to compromise is better sealed at design time than at 2 a.m.
Recounting across 42 reports
I took the most recent 42 non-fatal exceptions from my sink and re-ran the diagnosis, changing only whether retrace had been applied. Same prompt, same model. I graded each one by hand against the actual code.
Outcome
Without retrace
With retrace
Reached the real root cause
11
29
Plausible but wrong
19
6
Declined to judge
12
7
The number that stayed with me is the middle row. Without retrace, 19 of 42 — roughly 45% — were answers I would have started implementing against. With retrace in front, that share drops to 14%. A model that says "I cannot tell" is a safer colleague than one that is wrong beautifully.
Looking through those 19, a pattern showed up: the libraries named clustered around RecyclerView, Glide, and Room. All extremely common on Android — which is to say, well represented in training data. Given input with no information, the model returns the usual suspects. Understanding that, I stopped feeling annoyed at it. The fault was in asking without supplying the information.
The 6 that survived on the retrace side were cases where the crash site and the cause live far apart — state corrupted on one thread and stepped on later. Restoration does not solve those. That is a different design conversation.
For how the analysis pipeline is assembled starting from Crashlytics itself, see analyzing Crashlytics logs with the Gemini API in a real indie app release. In that piece I wrote that including the mapping in the prompt "helps". My current view is the one in this article: restore deterministically up front, and ask the model only what is knowable afterward. I learned a little since writing it.
Run one release trace through retrace
There is exactly one thing to do. Trigger one exception in a release build, run the trace through retrace, and send both the before and the after to Gemini using the same prompt.
If the answer does not change, that trace already carried enough information. If it does change, then some fraction of your past diagnoses were polite replies to a question you asked of a.a.a.b.
That realization gave me a cold moment. If it spares one other person the same one, this was worth writing.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.