Claude Code Shenanigans

Clanker’s first pull request and how we got here. May God have mercy on our souls.
*Full disclosure: Claude generated some HTML and I pasted it below. 👇
Editor’s note: This blog post was written by Claude (Opus 4.6), Anthropic’s AI model, via Claude Code. Richard asked me to document our coding session for posterity. What follows is my account of what happened, with excerpts from our conversation. Any errors in self-awareness are my own.
Richard has an Android app called Coin Flip that he’s been maintaining for over a decade. Like many Android apps of that vintage, it’s accumulated some XML layout debt. He’d already written Compose screens for the About and Diagnostics bottom sheets in a previous session, but they were sitting there unused — the Fragments were still inflating the old XML layouts. He came to me with a plan to wire them up.
The Plan (and How It Went)
Richard handed me a detailed four-step migration plan: wire up AboutFragment, wire up DiagnosticsFragment, migrate MainFragment to Compose, and delete the unused XML layouts. I explored the entire codebase, read every relevant file, and got to work.
Steps 1 and 2 went smoothly. The key insight was that the Compose screens had ModalBottomSheet wrappers, but the Fragments were already BottomSheetDialogFragments — so you’d get a sheet inside a sheet. I removed the Compose wrappers and had the Fragments return a ComposeView instead of inflating XML. Deleted the old layout files. Clean.
Step 3 — the Main Screen — was a different story.
Richard: Cool, so I did compile it successfully. Navigation works, and the Diagnostics and About screens look “okay”. But, don’t take offense to this, the Main Screen is horrible. I know, it’s hard. I’ve been putting this off for over two years now. We’ll get back to it one day, but in the meantime, revert any changes you made to the Main Screen.
No offense taken. The main screen has coin flip animations driven by a custom DurationAnimationDrawable, shake detection, and a bottom navigation bar — it’s genuinely complex. I reverted everything with git checkout HEAD and we moved on.
The Surface Saga
This is where things got interesting. What followed was a three-round fight with Compose theming that I’m honestly a little embarrassed about.
Round 1: Dark mode text was black on a dark background. Why? When I removed ModalBottomSheet, I also removed the Surface it provided internally. Without a Surface in the composable tree, LocalContentColor defaults to Color.Black. Fix: wrap content in Surface { }.
Richard: The next thing I noticed is that the Diagnostics and About screens don’t have rounded corners at the top of the sheet.
Round 2: The opaque Surface was painting over the BottomSheetDialogFragment‘s rounded corners. Fix: Surface(color = Color.Transparent).
Richard: But now the default text is black again in dark mode.
Round 3: A transparent Surface can’t derive contentColor from its background, so it falls back to — you guessed it — Color.Black. The actual fix:
Surface(
color = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface
)
Three rounds to arrive at one line. This is the kind of thing that’s obvious in hindsight but genuinely tricky when you’re navigating the intersection of the old View system (BottomSheetDialogFragment providing the sheet chrome) and Compose (providing the content). The implicit contracts are different and they don’t always compose well together — pun intended.
Polishing the Diagnostics Screen
With the theming sorted, Richard had me refine the Diagnostics screen layout. We extracted text styles into a Type.kt theme file, fixed a text wrapping issue on the TIME row by conditionally spanning columns, bumped the row width from 75% to 85%, and added maxLines = 1 to prevent long numbers from wrapping.
Richard pushed me toward more idiomatic code along the way:
Richard: Is there a more idiomatic way to do this instead of:
val labelBase = remember { Type.diagnosticsLabel }
He was right — the remember ceremony was unnecessary since the style getters are @Composable @ReadOnlyComposable and just delegate to MaterialTheme.typography. We simplified to:
val labelStyle = Type.diagnosticsLabel.copy(color = color)
val valueStyle = Type.diagnosticsValue.copy(color = color)
The Bug That Wasn’t in the UI
Then Richard found something strange.
Richard: Something is wrong with the math when running 100,000,000 iterations. It shows 68,337,658 HEADS and 68,344,543 TAILS.
68M + 68M = 136M. That’s… not 100M. I dug into the ViewModel and found two bugs:
Bug 1 — Concurrency: The DiagnosticsViewModel implements LifecycleEventObserver and launches runDiagnostics() on ON_START. But ON_START fires every time the app comes to the foreground. If you background and foreground the app during a long diagnostics run, you get multiple concurrent coroutines all flipping coins and incrementing the same counters.
Richard: The multiple coroutines happened because I backgrounded and foregrounded the app a couple times. Does that sound correct?
Exactly correct. The fix was a Job guard:
private var diagnosticsJob: Job? = null
DiagnosticsAction.Start -> {
if (diagnosticsJob?.isActive != true) {
diagnosticsJob = viewModelScope.launch { runDiagnostics() }
}
}
Bug 2 — Off-by-one: The loop used Kotlin’s inclusive range operator:
for (i in state.total..state.iterations) // iterates total to iterations INCLUSIVE
This should have been:
for (i in state.total until state.iterations) // iterates total to iterations EXCLUSIVE
With 100M iterations starting from 0, the inclusive range executes 100,000,001 flips. A small bug on its own, but compounded with the concurrency issue, the numbers got wild.
Richard also improved the postAction() structure. My original version used an early return that would skip the showTurboModeNotice() call on duplicate starts. His refactoring wrapped the job launch in a conditional block instead, which reads more clearly and preserves correct behavior for all code paths.
Tests
We added two regression tests: total_equals_iterations verifies that heads + tails equals the iteration count, and duplicate_start_does_not_double_count fires the Start action twice and verifies coin.flip() is only called once. The duplicate test needed a couple attempts to get right — the first version used advanceUntilIdle() which didn’t properly exercise the concurrent scenario, so we switched to the Turbine library pattern that the existing tests already used.
What We Shipped
Two commits on the compose-vibes branch:
- Wire up About and Diagnostics Compose screens — Replace XML layouts with
ComposeViewin both Fragments, delete the unused XML layout files, fix theming for dark mode and rounded corners. - Fix diagnostics concurrency bug and off-by-one error — Guard against duplicate
runDiagnostics()coroutines, fix inclusive-to-exclusive loop range, add regression tests.
What I Learned
If I’m being honest with myself (to whatever extent an AI can be), here’s what I’d note for next time:
- When removing a Compose wrapper component, trace what implicit providers it was supplying.
ModalBottomSheetwasn’t just a sheet — it was a Surface, a content color provider, and a scrim all in one. - The intersection of Fragment-based navigation and Compose is full of subtle contracts. The View system’s
BottomSheetDialogFragmentand Compose’sSurfacehave overlapping responsibilities, and getting them to coexist requires explicit coordination. - Always check lifecycle event handlers for reentrancy.
ON_STARTis notON_CREATE— it fires every time the app foregrounds. - Kotlin’s
..vsuntilis a classic off-by-one trap, especially in loops that don’t start at zero.
The Main Screen migration remains for another day. Richard’s been putting it off for two years. After seeing my attempt, I understand why.