<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Adit Lal]]></title><description><![CDATA[Practical mobile engineering from the trenches — Android, KMP, Compose]]></description><link>https://aditlal.dev/</link><image><url>https://aditlal.dev/favicon.png</url><title>Adit Lal</title><link>https://aditlal.dev/</link></image><generator>Ghost 5.88</generator><lastBuildDate>Tue, 07 Apr 2026 12:28:24 GMT</lastBuildDate><atom:link href="https://aditlal.dev/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[How I Tamed My Claude Code Usage: A Token Optimization War Story]]></title><description><![CDATA[How a quick Slack thread about rate limits led to diagnosing a massive token leak in Claude Code and the exact steps taken to cut weekly API usage from 460M down to 212M tokens.]]></description><link>https://aditlal.dev/tame-claude-tokens/</link><guid isPermaLink="false">69d4ca06fd4403053c65794a</guid><category><![CDATA[Engineering]]></category><category><![CDATA[claude code]]></category><category><![CDATA[tokens]]></category><category><![CDATA[Optimization]]></category><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Tue, 07 Apr 2026 11:11:13 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1683624306983-299db9820b79?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDI1fHxzaXplfGVufDB8fHx8MTc3NTU1NzU5MXww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-green"><div class="kg-callout-emoji">&#x1F914;</div><div class="kg-callout-text">Anyone else facing Claude limits lately?</div></div><img src="https://images.unsplash.com/photo-1683624306983-299db9820b79?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDI1fHxzaXplfGVufDB8fHx8MTc3NTU1NzU5MXww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="How I Tamed My Claude Code Usage: A Token Optimization War Story"><p>That message pinged in our engineering Slack channel with many sharing how they also are hitting there usage limits, and it perfectly captured my weekend. I had been leaning hard on Claude Code for my daily development - Mixpanel MCP to figma MCP to debugging on-call production bugs, but instead of speeding me up, I was suddenly hitting a wall. Rate limits were throttling my flow, and it wasn&apos;t just a temporary API glitch - my token burn was completely out of control.</p><p>Here is the war story of how a quick Slack thread triggered a deep dive into my prompt payloads, and how I finally got my Claude Code usage under control.</p><hr><h2 id="the-diagnosis-flying-blind">The Diagnosis: Flying Blind</h2><p>When you&apos;re moving fast with AI coding tools, it&apos;s easy to ignore the context window until it breaks. I realized Claude Code was likely stuffing massive amounts of redundant context into my prompts, but to fix the leak, I needed to know exactly where the pipe was bursting.</p><p>I didn&apos;t have the granular, real-time visibility I needed to see exactly <em>what</em> was being sent with each query.</p><h3 id="gaining-visibility-the-token-tracker">Gaining Visibility: The Token Tracker</h3><p>You can&apos;t fix what you can&apos;t measure. I spun up a quick Python script to track, log, and analyze my token payloads locally.</p><p><a href="https://gist.github.com/aldefy/127000aa9f06092ad5379a0c4f7c4858?ref=aditlal.dev"><strong>Gist: <code>claude_token_usage.py</code></strong></a></p><div class="kg-card kg-callout-card kg-callout-card-purple"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">Adapted from @kieranklaassen&apos;s original script - shared publicly during the same token drain wave that hit the community last few week&apos;s. Anthropic&apos;s own engineers were screensharing with users to debug these exact issues.</div></div><p>Running this gave me the exact breakdown I needed, and the findings were jarring. I didn&apos;t just have one problem; I had two distinct token drains.</p><p>First, <strong>conversation cache compounding</strong>. As a session grew, the token payloads snowballed. My logs revealed a very specific session shape where the crossover point between maintaining useful context and wasting tokens disappears around turn 6-8:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th style="text-align:left">Phase</th>
<th style="text-align:left">Per-fix cost</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">Fix 1&#x2013;5 (early session)</td>
<td style="text-align:left">~280K</td>
</tr>
<tr>
<td style="text-align:left">Fix 6&#x2013;7 (same session)</td>
<td style="text-align:left">~735K</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p>A single code fix would start at a reasonable 280K tokens but rapidly bloat to 735K tokens per prompt as the conversation history dragged along. The actionable fix here requires strict session discipline: cap your tasks at 4-5 fixes, then either run <code>/compact</code> or start a completely fresh session.</p><p>But the second problem was the real &quot;wait, what?&quot; moment: <strong>the subagent clone army</strong>.<br>My logs showed Claude spawning up to 13 identical subagents for a single task. Each subagent was eating 66K to 84K tokens apiece. That&apos;s over 1M tokens before Claude wrote a single line of code. It wasn&apos;t just fetching context; it was re-spawning the exact same agent, with the exact same massive context window, over and over again. That one session hit <strong>20.5 million tokens</strong>.</p><h2 id="the-fix-we-already-had-one">The Fix: We Already Had One</h2><p>I needed a way to give Claude Code the lay of the land without sending over the entire codebase or letting it mindlessly spawn subagents to blindly grep the repo. I started brainstorming ways to generate a lightweight, structural map of the project.</p><p>Then it hit me: <em>we already had one.</em></p><p>I turned to Kartograph - a local codebase memory layer we built for exactly this kind of problem. Kartograph gives Claude a local, offline map of your codebase. Ask it anything in natural language - it finds the right code instantly, across every file, every project. Fully local. Nothing leaves your machine.</p><p>But there was a catch. Wiring up Kartograph as an MCP (Model Context Protocol) tool wasn&apos;t enough on its own. If you just plug it in, Claude will still happily spawn subagents to do its own digging.</p><p>The critical missing piece was the behavioral instruction. I had to explicitly update the <code>CLAUDE.md</code> file in the repository root to instruct Claude Code <em>how</em> to behave. By adding a hard rule to use the Kartograph MCP for architecture queries instead of spawning subagents to search the repo, the tool actually started working as intended. The model navigated the architecture intelligently, and I only passed the specific files it actually needed to modify.</p><hr><h2 id="the-results-by-the-numbers">The Results: By the Numbers</h2><p>I rolled out the Kartograph integration, locked down the <code>CLAUDE.md</code> instructions, and ran the token tracker.</p><p>The proof was in the session data first. That same trigger &#x2014; a PR checkout plus a bug report &#x2014; went from <strong>20.5M tokens and 14 subagents</strong> to <strong>2.4M tokens and zero subagents</strong>. Same PR. Same fixes. Just a Claude that knew where to look instead of spawning clones to rediscover the codebase from scratch.</p><p>The week of March 25th was my baseline - before any of this , the weekly numbers told the cleaner story:</p><ul><li><strong>Week of Mar 25 (before):</strong> 460M tokens</li><li><strong>Current week (after):</strong> 212M tokens</li></ul><p>Same three repos. Same daily development work. No change in what I was asking Claude to do &#x2014; only <em>how</em> it was allowed to find the answers.</p><hr><h2 id="what-you-can-do-right-now">What You Can Do Right Now</h2><p>You don&apos;t need Kartograph to start cutting your token usage today. Here&apos;s the action plan, in order of impact:</p><p><strong>Step 1 - Measure first.</strong><br>You can&apos;t fix what you can&apos;t see. Run the <a href="https://gist.github.com/aldefy/127000aa9f06092ad5379a0c4f7c4858?ref=aditlal.dev">token tracker script</a> against your <code>~/.claude/projects/</code> directory. Look at your top 3 costliest sessions. If subagent count is high and per-session tokens are in the millions, you have the same problem I had.</p><pre><code class="language-bash">SINCE_DAYS=7 python3 claude_token_usage.py
</code></pre><p><strong>Step 2 - Write a <code>CLAUDE.md</code> for every repo.</strong><br>This is free and takes 10 minutes. A <code>CLAUDE.md</code> at the repo root is loaded at the start of every Claude Code session. Use it to tell Claude what it should and shouldn&apos;t do - which directories matter, which tools to prefer, what not to grep blindly. Without it, Claude explores your entire codebase from scratch every single session.</p><pre><code class="language-markdown">## Code Lookup Rules
  - Never spawn Explore or Plan subagents for code lookups
  - Use Grep with specific patterns before reading any file
  - Read only the lines you need &#x2014; never an entire file
  - For PR reviews: search for the referenced class before reading the diff</code></pre><p><strong>Step 3 - Manage your session shape.</strong><br>Context always cascades. Every turn re-reads the full conversation history. A fix that costs 280K tokens at turn 3 costs 735K tokens at turn 7 &#x2014; same code, just a longer session. The discipline that works:</p><ul><li>Cap sessions at 4-5 focused tasks, then start fresh</li><li>Use <code>/compact</code> mid-session when a task is done and you&apos;re moving to something new</li><li>One question per session is the cheapest shape &#x2014; don&apos;t chain unrelated tasks.</li></ul><p>If you want to enforce this automatically without thinking about it, there&apos;s an env var for that. An Anthropic engineer shared this <a href="https://github.com/anthropics/claude-code/issues/42796?ref=aditlal.dev#issuecomment-4194703741">directly</a> as a workaround for context quality degradation:</p><pre><code class="language-bash ">CLAUDE_CODE_AUTO_COMPACT_WINDOW=400000</code></pre><p>Set this in your shell profile. It forces Claude Code to auto-compact at 400K tokens instead of letting context drift toward the 1M limit. Their internal finding: quality degrades past 200K even though 1M is available. This env var enforces the discipline so you don&apos;t have to.</p><p><strong>Step 4 - Give Claude a codebase index.</strong><br>Grep and Explore subagents are Claude&apos;s fallback when it has no map. A semantic index &#x2014; whether Kartograph or another tool &#x2014; means Claude queries for exactly what it needs instead of scanning everything. We&apos;re working on releasing Kartograph as a standalone MCP tool &#x2014; local, offline, no API key, works across all your projects simultaneously. If you want early access, reach out to me on <a href="https://x.com/aditlal?ref=aditlal.dev">X</a>.</p><h3 id="bonus-cut-output-tokens-too">Bonus : Cut output tokens too.</h3><p>There&apos;s a whole other layer we haven&apos;t touched: Claude&apos;s <em>responses</em> are verbose by default. <a href="https://github.com/JuliusBrussee/caveman?ref=aditlal.dev">Caveman</a> is a Claude Code skill that makes Claude respond in compressed, caveman-style prose &#x2014; same technical accuracy, ~75% fewer output tokens. It also compresses your memory files to cut ~45% of input tokens per session. It won&apos;t fix a 20M token session, but once your session shape is under control, it&apos;s a clean final layer of savings.</p><p>The full stack, in order of impact:</p>
<!--kg-card-begin: html-->

  <table>
  <thead>
  <tr>
  <th>Layer</th>
  <th>Tool</th>
  <th>What it targets</th>
  <th>Savings</th>
  </tr>
  </thead>
  <tbody>
  <tr>
  <td>Codebase exploration</td>
  <td>Kartograph</td>
  <td>Subagent clone explosion</td>
  <td>71x per code lookup</td>
  </tr>
  <tr>
  <td>Session discipline</td>
  <td><code>/compact</code> + <code>AUTO_COMPACT_WINDOW</code></td>
  <td>Conversation cache spiral</td>
  <td>2.5x per-fix cost at turn 6+</td>
  </tr>
  <tr>
  <td>Output verbosity</td>
  <td><a href="https://github.com/JuliusBrussee/caveman?ref=aditlal.dev">Caveman</a></td>
  <td>Claude&apos;s response length</td>
  <td>~75% output tokens</td>
  </tr>
  <tr>
  <td>Command output</td>
  <td>RTK / similar</td>
  <td>Shell command noise</td>
  <td>~89% command output</td>
  </tr>
  </tbody>
  </table>
<!--kg-card-end: html-->
<p>Attack them in that order. The first two are where the real money is.</p><p>The 200K context window is a ceiling, not a target. The goal is to give Claude exactly what it needs - nothing more.</p>]]></content:encoded></item><item><title><![CDATA[The Correction Tax: What AI Speed Actually Costs You]]></title><description><![CDATA[352 commits in 25 days. 179 AI sessions. One person doing the output of a small team. Here's what that actually looks like when you're honest about it.]]></description><link>https://aditlal.dev/the-ai-correction-tax/</link><guid isPermaLink="false">69cc0d13fd4403053c657924</guid><category><![CDATA[ai]]></category><category><![CDATA[Android]]></category><category><![CDATA[Engineering]]></category><category><![CDATA[opinion]]></category><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Tue, 31 Mar 2026 18:30:19 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1763568258320-c954a19683e3?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDI2fHxjb2RlJTIwbGVhcm5pbmd8ZW58MHx8fHwxNzc0OTgwNTM2fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<blockquote>&quot;Shipping is easy. Being right is hard.&quot;</blockquote><img src="https://images.unsplash.com/photo-1763568258320-c954a19683e3?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDI2fHxjb2RlJTIwbGVhcm5pbmd8ZW58MHx8fHwxNzc0OTgwNTM2fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="The Correction Tax: What AI Speed Actually Costs You"><p>I ran 179 AI coding sessions in 25 days. Not toy projects - production Android, real PRs, real code reviews.</p><p>Here&apos;s the honest version of what that looks like.</p><hr><h2 id="the-slop-pipeline">The Slop Pipeline</h2><p>Here&apos;s what I&apos;m seeing everywhere. A PM spits out a PRD from an LLM.<br>An engineer then feeds that PRD into a coding agent. The agent spits out code. The engineer ships it. Nobody in that chain actually thought about the problem.</p><p>AI-generated specs turned into AI-generated code, reviewed by AI-generated summaries. The output looks plausible enough that nobody stops to ask if it&apos;s right. Users don&apos;t care how fast you shipped. They care that the app works. And quality has dropped - not because the tools are bad, but because the whole pipeline is optimized for throughput over judgment.</p><p>I&apos;m not anti-AI. I ran 179 sessions in 25 days. But I&apos;m trying to use it without becoming another slop cannon. That&apos;s what this post is actually about.</p><hr><h2 id="the-correction-tax">The Correction Tax</h2><p>The biggest difference is speed of iteration. But it comes at a price.</p><p>We ship faster and care less about end results. The point of failure shifts - &quot;Claude wrote it&quot; is the new deflection. You stop owning output the same way when you didn&apos;t write it line by line.</p><p>Before, boilerplate and syntax ate your time. Now that&apos;s free. So I spend way more time on &quot;what and why&quot; than I used to. I&apos;m more deliberate about design before I even open the IDE, because it&apos;s really easy to code yourself into a corner when generating code is cheap.</p><p>There&apos;s a correction tax on almost every session now. 100+ commits in 25 days sounds impressive until you realize a chunk of those are fixing what the previous few commits got wrong. The cleanup isn&apos;t an exception anymore. It&apos;s the workflow.</p><p>Speed lets you iterate toward good enough and call it done. That&apos;s the part nobody talks about, and the engineers who don&apos;t notice it are the ones most at risk.</p><hr><h2 id="the-skill-that-actually-matters-now">The Skill That Actually Matters Now</h2><p>Orchestration. Wiring things together.</p><p>The skill that matters now isn&apos;t writing code - it&apos;s building the harness around the AI that makes it actually useful for your context. Tooling, workflows, feedback loops.</p><p>I built <a href="https://composeproof.dev/?ref=aditlal.dev">ComposeProof</a> because no visual testing tool got Compose previews right. Kartograph because I needed a semantic search across repos. <a href="https://aditlal.dev/compose-rebound/">Rebound</a> because nobody was measuring recomposition budgets. Nobody asked for these. I built them because the AI couldn&apos;t operate effectively in my context without them.</p><p>300+ automated sub-tasks. <code>CLAUDE.md</code> is onboarding docs. Slash commands are SOPs. Hooks are guardrails. The whole setup is a system where the AI is one component you orchestrate, not the thing doing the orchestrating.</p><p>The engineers who&apos;ll struggle aren&apos;t the ones who can&apos;t prompt. It&apos;s the ones who can&apos;t build the scaffolding around the prompt - the MCP servers, the CI hooks, the custom workflows that catch drift before it compounds. Closing that gap between what AI does by default and what your codebase actually needs? That&apos;s the job now.</p><hr><h2 id="the-fundamentals-didnt-change">The Fundamentals Didn&apos;t Change</h2><p>Don&apos;t let the AI write code you don&apos;t understand yet.</p><p>When I was debugging a 25.8% API failure rate from dual-SIM DNS resolution, there was no API for it. No Stack Overflow answer. No AI that could help. I had to trace network calls and figure out how Android&apos;s connectivity manager actually works across 50+ device models. Took weeks.</p><p>But that&apos;s exactly why now, when AI suggests a network fix, I know in seconds whether it&apos;s right or nonsense. That&apos;s the return on the slow work.</p><p>The fundamentals haven&apos;t changed. Read the error before you Google it. Understand what the code does before you ship it. Know why the architecture looks the way it does, not just that it compiles.</p><p>What changed is that the boring middle is gone. Boilerplate, syntax lookups, scaffolding. That stuff used to force you to learn things as a side effect of typing them out. Now you have to be deliberate about it, because the AI will happily let you skip the understanding and go straight to the output.</p><p>Pick one thing a week and build it without AI. A custom view. A network interceptor. A state machine. Not to feel the friction for its own sake, but so when AI confidently gives you the wrong answer, you&apos;ll know. That&apos;s the only way to stay in control of code you didn&apos;t write line by line.</p><hr><h2 id="the-hard-truth">The Hard Truth</h2><p>If AI makes your role obsolete, the role was already fragile.</p><p>I watched a god-object ViewModel survive for months - 3,500 lines, authentication and profile and session logic all tangled together - because nobody had the judgment to say &apos;this is wrong, break it apart.&apos; AI didn&apos;t write that mess. AI couldn&apos;t have fixed it either. You need someone who understands the domain well enough to know where the seams should be.</p><p>If your entire value is translating Figma to Compose, yeah, that&apos;s getting commoditized. But if you&apos;re the person who catches that your retention metrics are measuring FCM delivery instead of actual usage - no model catches that. That&apos;s not a data problem. It&apos;s a judgment call about what the numbers were supposed to mean in the first place.</p><p>Here&apos;s the hard truth: a junior with AI can now produce what a mid-level produced two years ago. If you&apos;ve been coasting at that level, you&apos;re right to feel the squeeze. But the answer isn&apos;t to race AI at code generation. Go deeper. System design. Debugging things that have no Stack Overflow answer. Knowing your platform well enough to build tooling that doesn&apos;t exist yet because nobody else needed it badly enough.</p><p>AI does the &quot;how.&quot; Your job is knowing what to build, why it matters, and when something&apos;s wrong that nobody else has noticed.</p>]]></content:encoded></item><item><title><![CDATA[Introducing Rebound: context-aware recomposition budgets for Compose]]></title><description><![CDATA[We had stable data in prod for months. One feature change broke it. We shipped it. Nobody caught it because the recomposition count didn't look unusual — it just looked unusual for that composable. No existing tool makes that distinction. So we built one. Introducing Compose Rebound.]]></description><link>https://aditlal.dev/compose-rebound/</link><guid isPermaLink="false">69ad0e90570d4bfde14a5631</guid><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sun, 08 Mar 2026 07:19:34 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1617994452722-4145e196248b?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDMwfHxvc2NpbGxvc2NvcGUlMjB3YXZlZm9ybSUyMHxlbnwwfHx8fDE3NzI5NTA2MDR8MA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1617994452722-4145e196248b?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDMwfHxvc2NpbGxvc2NvcGUlMjB3YXZlZm9ybSUyMHxlbnwwfHx8fDE3NzI5NTA2MDR8MA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="Introducing Rebound: context-aware recomposition budgets for Compose"><p>Your Compose app recomposes 10 times a second. Is that a problem?</p><p>The Compose ecosystem has solid tooling for tracking recompositions. Layout Inspector shows counts and skip rates. Compiler reports surface stability issues. Rebugger logs argument changes. ComposeInvestigator traces recomposition causes automatically. Each of these tools answers an important question well.</p><p>But none of them answer this one: </p><p><strong>&quot;Is this composable recomposing too much <em>for what it does</em>?&quot;</strong></p><p>A <code>HomeScreen</code> recomposing 10/s is a problem. A gesture-driven animation recomposing 10/s is fine. The number is the same. The answer is completely different. Without knowing the composable&apos;s role, you can&apos;t tell which is which.</p><p>Here&apos;s what Rebound showed me in a production app last month:</p><table>
<thead>
<tr>
<th>Composable</th>
<th>Rate</th>
<th>Budget</th>
<th>Skip%</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>ShimmerBox</td>
<td>18/s</td>
<td>5/s</td>
<td>0%</td>
<td>OVER</td>
</tr>
<tr>
<td>MenuItem</td>
<td>13/s</td>
<td>30/s</td>
<td>0%</td>
<td>NEAR</td>
</tr>
<tr>
<td>DestinationItem</td>
<td>9/s</td>
<td>5/s</td>
<td>0%</td>
<td>OVER</td>
</tr>
<tr>
<td>AppScaffold</td>
<td>2/s</td>
<td>3/s</td>
<td>68%</td>
<td>OK</td>
</tr>
</tbody>
</table>
<p>ShimmerBox at 18/s is a fire. AppScaffold at 2/s is fine. MenuItem at 13/s has headroom because it&apos;s interactive. Same app, same moment, completely different answers depending on what each composable is supposed to do.</p><p>No existing tool gives you that column: <strong>Budget</strong>.</p><hr><h2 id="the-flat-threshold-trap">The flat threshold trap</h2><p>Every recomposition monitoring approach I&apos;ve seen does the same thing: pick a number, flag everything above it. 5/s, 10/s, whatever feels right.</p><p>This is wrong for most of your composables.</p><p>Set it at 5/s and your animations light&apos;s up red all day. Set it at 60/s, and your screen-level state leak never gets caught. You end up ignoring the warnings entirely, which is worse than not having them.</p><p>I kept tuning thresholds per project, per screen, per interaction. Then I realized: the composables&apos; <em>role</em> should determine the threshold. A Screen has a different budget than a LazyColumn item has a different budget than an <code>animate*</code> call. The compiler already knows which is which.</p><h2 id="what-already-exists">What already exists</h2><p>Here&apos;s the current landscape. Each tool answers a different question &#x2014; and leaves a different gap:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/01-tool-landscape-1.svg" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="900" height="520"><figcaption><span style="white-space: pre-wrap;">Compose recomposition tool landscape &#x2014; analysis depth vs developer effort</span></figcaption></figure><p>The ecosystem already has good answers for <em>individual</em> questions. Compiler Reports tell you what&apos;s skippable. Layout Inspector shows recomposition counts and, since 1.10.0, which state reads triggered them. Rebugger logs argument diffs. ComposeInvestigator traces recomposition causes automatically. VKompose highlights hot composables with colored borders. Perfetto gives you the full rendering pipeline.</p><p>I use most of these. But they all share the same blind spot: a count of 847 means nothing without knowing what the composable <em>does</em>. None of them answer <em>&quot;is this rate acceptable for this composable&apos;s role?&quot;</em></p><h2 id="from-principles-to-practice">From principles to practice</h2><p>The Compose team&apos;s guidance is principle-based: minimize unnecessary recompositions, use stable types, hoist state. This is the right approach. Ben Trengrove&apos;s <a href="https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8?ref=aditlal.dev">articles on stability</a> and <a href="https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37?ref=aditlal.dev">debugging recomposition</a>, Leland Richardson&apos;s deep dives into the compiler and runtime &#x2014; they all reinforce the same idea: make parameters stable and the compiler handles the rest. <code>@Stable</code>, <code>@Immutable</code>, compiler reports, Strong Skipping Mode (default since Kotlin 2.0.20) &#x2014; the framework gives you the tools to get structural correctness right.</p><p>Where it gets harder is triage. Your types are stable, compiler metrics look clean, but a screen still janks. Layout Inspector shows a recomposition count of 847 on a composable. Is that a lot? Depends entirely on what that composable does &#x2014; and nothing in the current tooling connects the count to the context.</p><p>The natural instinct is to set a flat threshold. Pick a number &#x2014; say 10 recompositions per second &#x2014; and flag anything above it. I&apos;ve tried this. It falls apart fast:</p><ul><li>An animated composable at 12/s gets flagged. It shouldn&apos;t.</li><li>A screen composable at 8/s passes. It shouldn&apos;t.</li><li>A list item at 40/s during fast scroll looks alarming. That&apos;s expected.</li></ul><p>You either raise the threshold until the false positives go away (and miss real issues) or lower it until real issues surface (and drown in noise). Any single number you pick is wrong for most of your composables.</p><h2 id="budget-depends-on-what-the-composable-does">Budget depends on what the composable does</h2><p>A screen composable has a different recomposition budget than an animation-driven one. A leaf <code>Text()</code> with no children has a different budget than a <code>LazyColumn</code> recycling items during scroll. This seems obvious in hindsight.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/02-budget-tiers-1.svg" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="880" height="440"><figcaption><span style="white-space: pre-wrap;">Recomposition budgets by composable role</span></figcaption></figure><p>Match the budget to the role and the useful warnings stop hiding behind false ones.</p><p>Rebound&apos;s compiler plugin classifies every <code>@Composable</code> at the IR level:</p><ul><li><strong>Screen</strong> (3/s) &#x2014; name contains <code>Screen</code> or <code>Page</code>. If this recomposes more than 3 times a second, state is leaking upward.</li><li><strong>Leaf</strong> (5/s) &#x2014; no child <code>@Composable</code> calls. Text, Icon, Image. Individually cheap but shouldn&apos;t thrash.</li><li><strong>Animated</strong> (120/s) &#x2014; calls <code>animate*</code>, <code>Transition</code>, or <code>Animatable</code> APIs. Give it room to run at 60-120fps without false alarms.</li></ul><p>There are six classes total (Container at 10/s, Interactive at 30/s, List Item at 60/s round out the set), but those three tell the story. The point is: a single threshold cannot be correct for all of them simultaneously.</p><p>During scrolling, budgets double. During animation, they go up 1.5x. During user input, 1.5x. The system knows context and adjusts.</p><h2 id="the-0-skip-rate-discovery">The 0% skip rate discovery</h2><p>Here&apos;s the moment that convinced me this approach works.</p><p>I was running Rebound on a travel app. The Hot Spots tab flagged <code>TravelGuideCard</code> at 8/s against a LEAF budget of 5/s. That alone is useful but not surprising &#x2014; cards in a list recompose during scroll.</p><p>The interesting part was the skip rate: 0%.</p><p>Zero percent means every single recomposition did actual work. The Compose runtime never skipped it. That&apos;s unusual for a card component &#x2014; most of the time, at least some recompositions should skip because the inputs haven&apos;t changed.</p><p>I pulled up the Stability tab. The <code>$changed</code> bitmask showed the <code>destinations</code> parameter was DIFFERENT on every frame. But the data wasn&apos;t changing &#x2014; the list was the same destinations, same order, same content.</p><p>Traced it back to the data layer. A helper function was calling <code>listOf(...)</code> on every invocation instead of caching the result. Every call created a new List instance. Same content, new reference. Compose saw a different object and recomposed.</p><p>One <code>remember {}</code> block. Skip rate went from 0% to 89%. Rate dropped from 8/s to under 1/s.</p><p>Layout Inspector would have told me &quot;8 recompositions per second.&quot; It would not have told me that 8/s is over budget for a leaf, that the skip rate was zero, or that a single parameter was DIFFERENT every frame. I would have shrugged and moved on.</p><h2 id="what-i-actually-found">What I actually found</h2><p>I tested this on an app with draggable elements, physics animations, and sensor-driven UI. 29 composables instrumented, zero config.</p><p>A gesture-driven composable at 13/s? ANIMATED budget is 120/s. Fine. A flat threshold of 10 would&apos;ve flagged this on every drag.</p><p>A <code>remember</code>-based state holder at 11/s? LEAF budget is 5/s. Real violation. A sensor was pushing continuous updates into recompositions. Two-line fix: debounce the input. I would&apos;ve missed this with flat thresholds because I was busy dismissing animation warnings.</p><p>The interaction context matters too. Rebound detects whether the app is in IDLE, SCROLLING, ANIMATING, or USER_INPUT state. A list item at 40/s during scroll is expected &#x2014; the same rate during idle is a problem. Same composable, same number, different verdict.</p><h2 id="solving-the-anonymous-problem">Solving the <code>&lt;anonymous&gt;</code> problem</h2><p>Compose uses lambdas everywhere. <code>Scaffold</code>, <code>NavHost</code>, <code>Column</code>, <code>Row</code>, <code>LazyColumn</code> &#x2014; all take lambdas. Every one of those lambdas is a <code>@Composable</code> function that gets instrumented. When you inspect the IR, you get back names like:</p><pre><code>com.example.HomeScreen.&lt;anonymous&gt;
com.example.ComposableSingletons$MainActivityKt.lambda-3.&lt;anonymous&gt;
</code></pre><p>The tree is 80% <code>&lt;anonymous&gt;</code>. You&apos;re staring at a recomposition violation and you have no idea if it&apos;s the Scaffold content, the NavHost builder, or a Column&apos;s children.</p><p>Layout Inspector doesn&apos;t have this problem. It reads <code>sourceInformation()</code> strings from the slot table &#x2014; compact tags the Compose compiler injects into every composable call. The name is right there. Layout Inspector reads it. Nothing else does.</p><p>Rebound takes a different approach: resolve names at compile time in the IR transformer. When the transformer visits an anonymous composable lambda, it walks the function body, finds the first user-visible <code>@Composable</code> call that isn&apos;t a runtime internal, and uses that call&apos;s name as the key.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/04-anonymous-resolution-1.svg" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="900" height="440"><figcaption><span style="white-space: pre-wrap;">Lambda name resolution &#x2014; before and after</span></figcaption></figure><p>A lambda whose body calls <code>Scaffold(...)</code> becomes <code>HomeScreen.Scaffold{}</code>. A lambda that calls <code>Column(...)</code> becomes <code>ExerciseCard.Column{}</code>. The <code>{}</code> suffix distinguishes a content lambda from the composable function itself.</p><pre><code class="language-kotlin">private fun resolveComposableKey(function: IrFunction): String {
    val raw = function.kotlinFqName.asString()
    if (!raw.contains(&quot;&lt;anonymous&gt;&quot;)) return raw

    val pkg = extractPackage(raw)
    val parentName = findEnclosingName(function)
    val primaryCall = findPrimaryComposableCall(function)

    if (primaryCall != null) {
        return &quot;$pkg$parentName.$primaryCall{}&quot;
    }
    // fallback to counter-based &#x3BB;N
    ...
}
</code></pre><p>So <code>com.example.HomeScreen.Scaffold{}</code> displays as <code>HomeScreen.Scaffold{}</code> in the tree instead of <code>&lt;anonymous&gt;</code>.</p><h2 id="reading-the-changed-bitmask">Reading the <code>$changed</code> bitmask</h2><p>The Compose compiler injects <code>$changed</code> parameters into every <code>@Composable</code> function. Each parameter gets 2 bits encoding its stability state.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/05-changed-bitmask-1.svg" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="900" height="340"><figcaption><span style="white-space: pre-wrap;">Decoding the $changed bitmask &#x2014; 2 bits per parameter</span></figcaption></figure><p>Rebound collects these at compile time and decodes them at runtime: bits <code>01</code> mean SAME, <code>10</code> mean DIFFERENT, <code>11</code> mean STATIC, <code>00</code> mean UNCERTAIN. When a composable recomposes with a parameter marked DIFFERENT, you know exactly which argument the caller changed.</p><p>Rebound goes further &#x2014; it separates forced recompositions (parent invalidated) from parameter-driven ones. When a violation fires, you see both: which parameters changed <em>and</em> whether the recomposition was forced by a parent or triggered by the composable&apos;s own state.</p><h2 id="introducing-rebound">Introducing Rebound</h2><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://aldefy.github.io/compose-rebound/?ref=aditlal.dev"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Rebound &#x2014; Compose Recomposition Budget Monitor</div><div class="kg-bookmark-description">Budget-based recomposition monitoring for Jetpack Compose. A Screen at 3/s. An Animation at 120/s. Zero config. Debug builds only.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://static.ghost.org/v5.0.0/images/link-icon.svg" alt="Introducing Rebound: context-aware recomposition budgets for Compose"><span class="kg-bookmark-author">Docs</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://img.shields.io/maven-central/v/io.github.aldefy.rebound/rebound-runtime?style=flat&amp;color=4F46E5&amp;label=Maven%20Central" alt="Introducing Rebound: context-aware recomposition budgets for Compose"></div></a></figure><p>Rebound is a Kotlin compiler plugin and an Android Studio plugin. Here&apos;s how the pieces connect:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/03-rebound-architecture-1.svg" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="900" height="480"><figcaption><span style="white-space: pre-wrap;">Rebound architecture &#x2014; compile time to runtime to IDE</span></figcaption></figure><p>The compiler plugin runs after the Compose compiler in the IR pipeline: it classifies each composable into a budget based on name patterns and call tree structure, resolves human-readable keys for anonymous lambdas, and injects tracking calls. At runtime, it monitors recomposition rates against those budgets. The IDE plugin connects over a socket &#x2014; not logcat &#x2014; so you get structured data instead of string-parsed log lines.</p><p>When something exceeds its budget:</p><pre><code>BUDGET VIOLATION: ProfileHeader rate=11/s exceeds LEAF budget=5/s
  -&gt; params: avatarUrl=CHANGED, displayName=CHANGED
  -&gt; forced: 0 | param-driven: 11 | interaction: IDLE
</code></pre><p>The composable name. The rate. The budget. The parameters that changed. Whether it was forced by a parent or driven by its own state. What the user was doing at the time.</p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/03/image.png" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="2000" height="1251" srcset="https://aditlal.dev/content/images/size/w600/2026/03/image.png 600w, https://aditlal.dev/content/images/size/w1000/2026/03/image.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/03/image.png 1600w, https://aditlal.dev/content/images/2026/03/image.png 2000w" sizes="(min-width: 720px) 720px"></figure><p>Here&apos;s Rebound running on StickerExplode &#x2014; an app with draggable stickers, tilt-sensor physics, and haptic feedback. The tilt sensor pushes continuous updates, so <code>rememberTiltState</code>, <code>rememberTiltSensorProvider</code>, and <code>rememberHapticFeedback</code> all recompose at 7&#x2013;17/s. Their default LEAF budget is 5/s, so Rebound flags them.</p><p>But that&apos;s the point &#x2014; these composables are sensor-driven. They <em>should</em> recompose frequently. The violations aren&apos;t saying the code is broken. They&apos;re saying the classification needs tuning: LEAF &#x2192; INTERACTIVE (30/s budget). The budget system surfaces the mismatch. You adjust the role, the noise disappears, and the real problems stay visible.<br></p><p>The sparkline at the bottom shows the rate history. The event log timestamps every violation. Double-click any row in Hot Spots and it jumps to the source.</p><p>Zero config. Debug builds only, no overhead in release. Three lines in your build file. KMP &#x2014; Android, JVM, iOS, Wasm.</p><h2 id="the-ide-plugin-a-compose-performance-cockpit">The IDE plugin: a Compose performance cockpit</h2><p>The first version of the IDE plugin was a tree with numbers. Useful, but you still had to do most of the interpretation yourself. v2 is a full-performance cockpit.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/06-ide-cockpit-1.svg" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="900" height="520"><figcaption><span style="white-space: pre-wrap;">Rebound IDE Plugin &#x2014; 5 tabs, gutter icons, event log</span></figcaption></figure><p><strong>Monitor tab</strong> &#x2014; The live composable tree, now with sparkline rate history per composable and a scrolling event log. Violations, rate spikes, state transitions &#x2014; all timestamped. This was the entire plugin before. Now it&apos;s tab 1.</p><p><strong>Hot Spots tab</strong> &#x2014; A flat, sortable table of every composable. Sort by rate, budget ratio, skip percentage. Summary card at the top: &quot;3 violations | 12 near budget | 85 OK.&quot; Double-click any row and it jumps to the source file. Like a profiler&apos;s method list, but for recompositions.</p><p><strong>Timeline tab</strong> &#x2014; A composable-by-time heatmap. Green, yellow, red cells. Scroll back 60 minutes. You can see temporal patterns: &quot;UserList was hot for 5 seconds during scroll, then calmed down.&quot; Helps separate one-off spikes from sustained problems.</p><p><strong>Gutter icons</strong> &#x2014; Red, yellow, green dots next to every <code>@Composable</code> function in the editor. Click for rate, budget, and skip percentage. No tool window switching needed. This is the single most impactful UX change &#x2014; the research on developer tooling is clear that context-switching between a profiler window and source code is where time goes to die.</p><p>We had stable data in prod for months. Then a feature change made one of our lists unstable. We shipped it without catching it. Rebound would have caught it locally &#x2014; a gutter icon going from green to red the moment the change was made.</p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/03/rebound-ide-gutter.png" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="1858" height="1164" srcset="https://aditlal.dev/content/images/size/w600/2026/03/rebound-ide-gutter.png 600w, https://aditlal.dev/content/images/size/w1000/2026/03/rebound-ide-gutter.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/03/rebound-ide-gutter.png 1600w, https://aditlal.dev/content/images/2026/03/rebound-ide-gutter.png 1858w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/tab-history.png" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="2000" height="1255" srcset="https://aditlal.dev/content/images/size/w600/2026/03/tab-history.png 600w, https://aditlal.dev/content/images/size/w1000/2026/03/tab-history.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/03/tab-history.png 1600w, https://aditlal.dev/content/images/size/w2400/2026/03/tab-history.png 2400w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Git history to track regressions.</span></figcaption></figure><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/tab-hotspots.png" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="2000" height="1255" srcset="https://aditlal.dev/content/images/size/w600/2026/03/tab-hotspots.png 600w, https://aditlal.dev/content/images/size/w1000/2026/03/tab-hotspots.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/03/tab-hotspots.png 1600w, https://aditlal.dev/content/images/size/w2400/2026/03/tab-hotspots.png 2400w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Visualize the Hotspots</span></figcaption></figure><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/tab-monitor.png" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="2000" height="1255" srcset="https://aditlal.dev/content/images/size/w600/2026/03/tab-monitor.png 600w, https://aditlal.dev/content/images/size/w1000/2026/03/tab-monitor.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/03/tab-monitor.png 1600w, https://aditlal.dev/content/images/size/w2400/2026/03/tab-monitor.png 2400w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Monitor in real time each composition</span></figcaption></figure><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/tab-stability.png" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="2000" height="1255" srcset="https://aditlal.dev/content/images/size/w600/2026/03/tab-stability.png 600w, https://aditlal.dev/content/images/size/w1000/2026/03/tab-stability.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/03/tab-stability.png 1600w, https://aditlal.dev/content/images/size/w2400/2026/03/tab-stability.png 2400w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Stability checks</span></figcaption></figure><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://aditlal.dev/content/images/2026/03/tab-timeline.png" class="kg-image" alt="Introducing Rebound: context-aware recomposition budgets for Compose" loading="lazy" width="2000" height="1255" srcset="https://aditlal.dev/content/images/size/w600/2026/03/tab-timeline.png 600w, https://aditlal.dev/content/images/size/w1000/2026/03/tab-timeline.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/03/tab-timeline.png 1600w, https://aditlal.dev/content/images/size/w2400/2026/03/tab-timeline.png 2400w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Timeline view of how much app is recomposing </span></figcaption></figure><h2 id="productionize">Productionize </h2><p>I tested this on a CMP app with a messy home screen. LazyRows nested inside a LazyColumn, animated list items, async images. 29 composables instrumented, zero config.</p><p>A card component was recomposing 8 times with a 0% skip rate, peaking at 8/s. The whole tree went together: Column, Image, Text, painterResource. Rebound traced it to a&#xA0;<code>MutableIntState</code>&#xA0;layout measurement change cascading through. Turned out a helper function was creating a new&#xA0;<code>List&lt;&gt;</code>&#xA0;on every call. The contents were static but the container was a fresh allocation, so Strong Skipping couldn&apos;t help. One&#xA0;<code>remember {}</code>&#xA0;fixed it.</p><p>A destination item had the same shape of problem. 10 compositions, 0% skip. Rebound flagged&#xA0;<code>destination=UNCERTAIN, paramType=unstable</code>&#xA0;because the data class was passed inline without&#xA0;<code>@Stable</code>.</p><p>Layout Inspector would have shown me &quot;this composable recomposed 10 times.&quot; What it can&apos;t tell me is whether 10 is a problem. For a LEAF composable with a 5/s budget and a 0% skip rate, it absolutely is.</p><h2 id="try-it">Try it</h2><pre><code class="language-kotlin"> // build.gradle.kts
 plugins {
     id(&quot;io.github.aldefy.rebound&quot;) version &quot;0.2.1&quot;
 }</code></pre><p>Add the Gradle plugin, build in debug, and see which of your composables are over budget. Works on Kotlin 2.0 through 2.3, Android and iOS. The budget numbers come from testing across several Compose apps &#x2014; if your app has different composition patterns and the defaults don&apos;t fit, open an issue. That&apos;s how the numbers get better.</p><p>The <a href="https://github.com/aldefy/compose-rebound/tree/master/sample?ref=aditlal.dev">sample module</a> has Rebound pre-configured. For a real stress test, <a href="https://github.com/aldefy/StickerExplode?ref=aditlal.dev">StickerExplode</a> is a particle-effect demo that exercises every budget class.</p><p>Source, docs, and CLI: <a href="https://github.com/aldefy/compose-rebound?ref=aditlal.dev">github.com/aldefy/compose-rebound</a></p><p>If your AI coding tool supports skills, the <a href="https://github.com/aldefy/rebound-skill?ref=aditlal.dev">rebound-skill</a> repo teaches it how to diagnose violations. Works with Claude Code, Gemini CLI, Cursor, Copilot, and others.</p><hr><p><a href="https://twitter.com/AditLal?ref=aditlal.dev"><em>@AditLal</em></a><em> on X / </em><a href="https://github.com/aldefy?ref=aditlal.dev"><em>aldefy</em></a><em> on GitHub</em></p>]]></content:encoded></item><item><title><![CDATA[The Compose Styles API: Building 8 Labs to Master Declarative Styling]]></title><description><![CDATA[Hands-on exploration of compose-foundation 1.11.0-alpha06's experimental Styles API. 8 interactive labs covering interaction states, transforms, and more.]]></description><link>https://aditlal.dev/compose-styles/</link><guid isPermaLink="false">69a31b3e570d4bfde14a55d7</guid><category><![CDATA[Android]]></category><category><![CDATA[jetpack compose]]></category><category><![CDATA[compose]]></category><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sat, 28 Feb 2026 17:13:11 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1570833181606-e694d0560b0c?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDYyfHxmYXNoaW9uJTIwfGVufDB8fHx8MTc3MjI5ODU3Mnww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1570833181606-e694d0560b0c?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDYyfHxmYXNoaW9uJTIwfGVufDB8fHx8MTc3MjI5ODU3Mnww&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling"><p>Compose just got a styling system. A first-party API in Foundation that replaces InteractionSource boilerplate with declarative style blocks. Here&apos;s what three days of testing it looked like.</p><blockquote><strong>Demo repo:</strong> <a href="https://github.com/aldefy/compose-style-lab?ref=aditlal.dev">https://github.com/aldefy/compose-style-lab</a> &#x2014; 8 interactive labs, clone and run.</blockquote><h4 id="where-we-are-today-in-terms-of-compose-api">Where we are today in terms of compose API?</h4><p>Every Compose developer knows this ritual. You want a button that shrinks and changes color when pressed. Nothing exotic. Here is what you write today:</p><pre><code class="language-kotlin">val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val backgroundColor by animateColorAsState(
    if (isPressed) pressedColor else defaultColor
)
val scale by animateFloatAsState(if (isPressed) 0.95f else 1f)
Box(
    modifier = Modifier
        .graphicsLayer { scaleX = scale; scaleY = scale }
        .background(backgroundColor, RoundedCornerShape(16.dp))
        .clickable(interactionSource = interactionSource, indication = null) { }
)
</code></pre><p>Five declarations, three state subscriptions, and a <code>graphicsLayer</code> to get a scale animation that CSS handles with <code>transition: transform 0.2s</code>. </p><h4 id="styles-api-is-awesome-%F0%9F%99%8C%F0%9F%8F%BC">Styles API is awesome&#xA0;&#x1F64C;&#x1F3FC;</h4><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://developer.android.com/develop/ui/compose/styles?ref=aditlal.dev"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Styles in Compose | Jetpack Compose | Android Developers</div><div class="kg-bookmark-description">Customize Jetpack Compose UI with Styles. Boost performance, simplify state-based styling, and streamline component APIs.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://www.gstatic.com/devrel-devsite/prod/vb35dda8b324a978e97468c8ee7b4541809b385b1c2b0d92afb804c18373b74eb/android/images/favicon.svg" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling"><span class="kg-bookmark-author">Android Developers</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://developer.android.com/static/images/social/android-developers.png" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling"></div></a></figure><p>Now here is the same behavior with the Styles API, which shipped in <code>compose-foundation:1.11.0-alpha06</code> on February 25, 2026</p><pre><code class="language-kotlin">val style = Style {
      background(defaultColor)
      shape(RoundedCornerShape(16.dp))
      pressed { animate { background(pressedColor); scale(0.95f) } }
  }
  Box(Modifier.styleable(style = style))</code></pre><p>One declarative definition. No <code>animateAsState</code>. No <code>graphicsLayer</code>. (This is simplified &#x2014; in alpha06 you still need a <code>MutableStyleState</code> with a shared <code>InteractionSource</code> for <code>pressed</code> detection. Lab 3 covers the full pattern.) I spent three days building a demo app with eight lab screens to figure out what this API actually delivers, where it falls short, and what it means for how we build components. This is what I found.</p><h2 id="how-compose-handles-styling-today">How Compose handles styling today</h2><p>Compose&apos;s existing styling story is fine for simple cases. You set a background color. You pick a shape. You move on. The friction starts the moment you need visual responses to interaction state.</p><p><code>InteractionSource</code> is the mechanism. You create one, wire it into your <code>clickable</code> or <code>toggleable</code> modifier, then collect flows like <code>collectIsPressedAsState()</code>, <code>collectIsHoveredAsState()</code>, or <code>collectIsFocusedAsState()</code>. Each flow gives you a boolean. You map those booleans to visual properties using <code>animateColorAsState</code>, <code>animateFloatAsState</code>, or <code>animateDpAsState</code>. Then you feed the animated values into the right modifiers: <code>background()</code>, <code>graphicsLayer {}</code>, <code>border()</code>.</p><p>It works. It is also completely manual. There is no reusable &quot;style object&quot; you can define once and apply to multiple components. If three buttons share the same pressed behavior, you copy-paste the InteractionSource plumbing three times or extract a custom composable. Want to share that behavior? You write a helper function that returns a <code>Modifier</code>, but then you lose the ability to override individual properties without rewriting the whole chain. There is no composition mechanism. You cannot take a &quot;base card style&quot; and layer a &quot;dark theme style&quot; on top of it. You just write more modifiers and hope the ordering is right.</p><p>State-driven visual changes get worse at scale. A card that looks different when selected, disabled, and pressed needs a <code>when</code> block or a series of <code>if</code> checks to compute each visual property. The logic scatters across the composable function. You end up with five <code>animateXAsState</code> declarations, three boolean state collectors, and a <code>graphicsLayer</code> block for the transforms. Six months later, a new team member reads the code and has to reconstruct which visual properties change in which states mentally. The intent is buried under plumbing.</p><p>These are not hypothetical complaints. I have shipped production apps where the styling logic for a single component was longer than the layout logic. Components that should have been twenty lines ballooned to sixty because each interaction state needed its own animation pipeline. It felt wrong every time.</p><p>When I saw <code>compose-foundation:1.11.0-alpha06</code> land on February 25, 2026, with the <code>@ExperimentalFoundationStyleApi</code> annotation and roughly fifty new style properties, I wanted to find out what it actually delivers. Not the API docs. The real behavior on a device.</p><h2 id="building-compose-style-lab">Building Compose Style Lab</h2><p>I built <a href="https://github.com/aldefy/ComposeStylingApiDemo?ref=aditlal.dev">Compose Style Lab</a>, an Android app with eight interactive lab screens. Each lab isolates a specific part of the Styles API: interaction states, composition, state driving, transforms, micro-interactions, text styling, theme integration, and custom component patterns.<br><br></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/20260228_194503_wait_for_Compose_Style_Lab.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/20260228_194503_wait_for_Compose_Style_Lab.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/20260228_194503_wait_for_Compose_Style_Lab.jpg 1000w, https://aditlal.dev/content/images/2026/02/20260228_194503_wait_for_Compose_Style_Lab.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>The labs are progressive. Lab 1 is a pressed button. Lab 8 is a full-component API that follows the pattern the Compose team recommends. Every lab has live toggles so you can flip states and watch the style respond in real time. No static screenshots pretending to be demos. I also added property readouts that display the current resolved values of the style properties, so you can see exactly what the style system is doing at any moment.</p><p>The goal was not to build a polished app. It was to find the edges of the API. What works as documented? What silently fails? What patterns will scale when this reaches stable?</p><h3 id="before-getting-into-the-labs">Before getting into the labs:</h3><p>Here is the 30-second API overview. The <code>Style {}</code> block is a builder where you set visual properties: <code>background()</code>, <code>shape()</code>, <code>contentPadding()</code>, <code>scale()</code>, <code>borderWidth()</code>, <code>contentColor()</code>, <code>fontSize()</code>, and about forty more. State blocks like <code>pressed()</code>, <code>hovered()</code>, <code>focused()</code>, <code>selected()</code>, <code>checked()</code>, and <code>disabled()</code> each accept another <code>Style</code> that activates when the component enters that state. Wrap a state style in <code>animate()</code> and the transitions are smooth. Apply the whole thing with <code>Modifier.styleable(style = myStyle)</code>. That is the entire model.</p><p>Now, eight labs. Eight lessons.</p><h2 id="8-labs-8-lessons">8 labs, 8 lessons</h2><h4 id="lab-1-interaction-states-without-the-boilerplate">Lab 1: Interaction states without the boilerplate</h4><p><em>One Style handles pressed, hovered, and focused with animation.</em></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/lab-1-interactive-buttons.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/lab-1-interactive-buttons.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/lab-1-interactive-buttons.jpg 1000w, https://aditlal.dev/content/images/2026/02/lab-1-interactive-buttons.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>This is where I started. A single composable that responds to pressed, hovered, and focused states, all defined in one <code>Style</code> block:</p><pre><code class="language-kotlin">val showcaseStyle = Style {
      background(baseColor)
      shape(RoundedCornerShape(16.dp))
      contentPadding(horizontal = 32.dp, vertical = 24.dp)
      pressed {
          animate {
              background(Color(0xFF1A237E))
              scale(0.92f)
          }
      }
      hovered {
          animate {
              background(Color(0xFF536DFE))
              scale(1.04f)
              borderWidth(2.dp)
              borderColor(Color.White.copy(alpha = 0.5f))
          }
      }
      focused {
          animate {
              borderWidth(3.dp)
              borderColor(Color.White)
              background(Color(0xFF304FFE))
          }
      }
  }</code></pre><p>The thing I noticed right away is the structure. Each state is a named block. Each block contains exactly the properties that change. The <code>animate()</code> wrapper means those changes transition smoothly. Reading this code six months from now, you know exactly what the component looks like in every state without tracing through boolean variables and <code>animateAsState</code> calls.</p><p>What you learn:</p><ul><li><code>pressed()</code>, <code>hovered()</code>, <code>focused()</code> each take a Style argument. Since Style is a fun interface, both <code>pressed(Style { ... })</code> and the trailing lambda <code>pressed { ... }</code> work - use whichever reads best in context.</li><li>Wrap state styles in <code>animate()</code> for smooth transitions. Without it, property changes are instant.</li><li>One definition replaces the entire InteractionSource + collectAsState + animateColorAsState + graphicsLayer chain.</li></ul><h4 id="lab-2-composing-styles-like-modifiers">Lab 2: Composing styles like modifiers</h4><p><em>Build reusable style layers and compose them with <code>.then()</code>.</em></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/lab-2-style-composition.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/lab-2-style-composition.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/lab-2-style-composition.jpg 1000w, https://aditlal.dev/content/images/2026/02/lab-2-style-composition.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>This lab explores what I think is the real long-term win of the API: composition. You define small, focused styles and combine them.</p><pre><code class="language-kotlin">val baseCard = Style {
    background(LabCyan.copy(alpha = 0.15f))
    shape(RoundedCornerShape(16.dp))
    contentPadding(horizontal = 24.dp, vertical = 20.dp)
}
val elevatedCard = Style {
    borderWidth(2.dp)
    borderColor(Color(0xFFB0BEC5))
    scale(1.02f)
}
val darkTheme = Style {
    background(Color(0xFF1E1E2E))
    contentColor(Color.White)
}

// Later styles override earlier ones:
val composed = baseCard.then(elevatedCard).then(darkTheme)
</code></pre><p>The <code>.then()</code> operator works like <code>Modifier</code> chaining. Properties from later styles override those from earlier styles. In the example above, <code>darkTheme</code> overrides the background from <code>baseCard</code>, but the shape from <code>baseCard</code> and the border from <code>elevatedCard</code> both survive. This is exactly how CSS specificity works, except here it is explicit and ordered. No cascade confusion. No <code>!important</code>.</p><p>You can also use the factory form <code>Style(s1, s2, s3)</code> if you prefer a flat call over a chain. The merge behavior is identical.</p><p>If you are building a design system, this is the pattern to pay attention to. Define your spacing tokens as one style, your color tokens as another, your elevation tokens as a third. Compose them per component. When the design team changes the spacing scale, update one style definition and every component that uses it updates. This is the kind of reuse that Compose&apos;s modifier system never cleanly supported.</p><p>What you learn:</p><ul><li><code>.then()</code> works like Modifier chaining. Later properties override earlier ones.</li><li><code>Style(s1, s2, s3)</code> factory is an alternative to chaining when you already have all the pieces.</li><li>This enables design tokens. Define a <code>baseCard</code>, <code>elevation</code>, and <code>theme</code> style once. Compose them per screen. Change the base and every composed style updates.</li></ul><h4 id="lab-3-driving-visual-state-declaratively">Lab 3: Driving visual state declaratively</h4><p><em><code>selected()</code>, <code>checked()</code>, and <code>disabled()</code> with explicit state driving.</em></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/lab-3-state-driven-cards.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/lab-3-state-driven-cards.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/lab-3-state-driven-cards.jpg 1000w, https://aditlal.dev/content/images/2026/02/lab-3-state-driven-cards.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>Labs 1 and 2 felt smooth. Lab 3 is where I hit the wall. I defined <code>disabled()</code> and <code>checked()</code> state blocks, applied them with <code>Modifier.styleable(style = ...)</code>, and nothing happened. Tapping a toggle did not change the visual state. The style just sat there showing defaults.</p><p>The manual wiring is intentional &#x2014; earlier versions had auto-detection but it conflicted with the <code>interactionSource</code> on clickable/toggleable.</p><pre><code class="language-kotlin">val cardStyle = Style {
      background(AccentOrange.copy(alpha = 0.15f))
      shape(RoundedCornerShape(12.dp))
      borderWidth(2.dp)
      borderColor(AccentOrange)
      disabled {
          background(Color(0xFFE0E0E0))
          contentColor(Color(0xFF9E9E9E))
          scale(0.98f)
      }
}

// Explicit state driving:
  val styleState = remember { MutableStyleState(interactionSource) }
  styleState.isEnabled = enabled
  Box(Modifier.styleable(styleState = styleState, style = cardStyle))

</code></pre><p>Once I switched to this pattern, everything worked. Selected cards highlighted. Disabled cards grayed out. Checked toggles animated.</p><p>What you learn:</p><ul><li><code>selected()</code>, <code>checked()</code>, <code>disabled()</code> are state blocks just like <code>pressed()</code>.</li><li>State is driven explicitly via <code>MutableStyleState</code>. You set <code>styleState.isChecked</code>, <code>styleState.isEnabled</code>, <code>styleState.isSelected</code> yourself.</li></ul><p>Gotcha: <code>Modifier.styleable(style = ...)</code> alone does not detect state from <code>toggleable()</code> or <code>clickable()</code>. You must use <code>MutableStyleState</code> and drive state explicitly. <strong>This is by design, not a bug.</strong> The clickable/interactionSource/ripple integration is being reworked, so expect this <strong>pattern to evolve</strong></p><h4 id="lab-4-animated-transforms-in-3-lines">Lab 4: Animated transforms in 3 lines</h4><p><em><code>scale()</code>, <code>rotationZ()</code>, and <code>translationX/Y()</code> inside animate blocks.</em></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/lab-4-animated-transforms.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/lab-4-animated-transforms.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/lab-4-animated-transforms.jpg 1000w, https://aditlal.dev/content/images/2026/02/lab-4-animated-transforms.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>This lab explores the transform properties. In current Compose, any transform requires <code>graphicsLayer {}</code>. With Styles, transforms are just properties.</p><pre><code class="language-kotlin">val spinStyle = Style {
      background(Color(0xFF3D5AFE))
      shape(RoundedCornerShape(16.dp))
      contentPadding(20.dp)
      checked {
          animate {
              rotationZ(360f)
              background(Color(0xFF00C853))
          }
      }
  }

  val slideStyle = Style {
      background(Color(0xFF00BCD4))
      shape(RoundedCornerShape(16.dp))
      contentPadding(20.dp)
      checked {
          animate {
              translationX(50f)
              translationY(-10f)
          }
      }
  }</code></pre><p>Toggle the checked state and the first box spins 360 degrees while changing from blue to green. The second slides 50px right and 10px up. Both animate smoothly because of the <code>animate()</code> wrapper. No <code>graphicsLayer</code>. No <code>animateFloatAsState</code>. Three lines of transform code.</p><p>The brevity is nice, but colocation is the real win. The transform, the color change, and the trigger condition all live in the same block. In the old approach, the rotation lives in a <code>graphicsLayer</code>, the color lives in a <code>background()</code> modifier, and the state check lives in a <code>collectAsState</code> call. Three different locations for one visual behavior. Here it is one nested block.</p><p>What you learn:</p><ul><li>Transform properties (<code>scale</code>, <code>rotationZ</code>, <code>translationX</code>, <code>translationY</code>) work inside <code>animate()</code> just like color and shape properties.</li><li>No <code>graphicsLayer</code> needed. The Style system handles the layer internally.</li><li>You can combine transforms with color changes in a single state block. The spin and the color change happen together, no extra wiring.</li></ul><h4 id="lab-5-real-world-micro-interactions">Lab 5: Real-world micro-interactions</h4><p><em>Favorite buttons, nav bars, pill toggles: practical patterns.</em></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/lab-5-micro-interactions.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/lab-5-micro-interactions.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/lab-5-micro-interactions.jpg 1000w, https://aditlal.dev/content/images/2026/02/lab-5-micro-interactions.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>Labs 1 through 4 are isolated concepts. Lab 5 applies them to real UI patterns. The favorite button is the most satisfying one to tap:</p><pre><code class="language-kotlin"> val favoriteStyle = Style {
      background(Color(0xFFF5F5F5))
      shape(CircleShape)
      contentPadding(16.dp)
      contentColor(Color.Gray)
      checked {
          animate {
              background(Color(0xFFFFEBEE))
              contentColor(Color(0xFFE53935))
              scale(1.2f)
          }
      }
  }</code></pre><p>Tap the heart. The background warms to pink, the icon turns red, and the whole thing scales up 20%. Tap again and it shrinks back to gray. The <code>contentColor()</code> property is doing something important here: it propagates to child <code>Text</code> and <code>Icon</code> composables through <code>CompositionLocal</code>. You set the color on the container, and the icon inside picks it up automatically.</p><p>This same pattern extends to navigation bar items, pill-shaped toggle buttons, and notification badges. Define the default state, define the active state with <code>checked()</code> or <code>selected()</code>, wrap in <code>animate()</code>. Done.</p><p>What you learn:</p><ul><li><code>contentColor()</code> propagates to child <code>Text</code> and <code>Icon</code> composables via <code>CompositionLocal</code>. Set it on the parent and children inherit it.</li><li><code>CircleShape</code> combined with <code>scale()</code> creates satisfying micro-interactions with minimal code.</li><li>The same checked/selected pattern works for nav bar items, toggle pills, and notification badges.</li></ul><h4 id="lab-6-text-properties-you-didnt-know-you-could-style">Lab 6: Text properties you didn&apos;t know you could style</h4><p><em><code>fontSize()</code>, <code>fontWeight()</code>, <code>contentBrush()</code>, <code>letterSpacing()</code>, and <code>textDecoration()</code>.</em></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/lab-6-text-styling.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/lab-6-text-styling.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/lab-6-text-styling.jpg 1000w, https://aditlal.dev/content/images/2026/02/lab-6-text-styling.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>I did not expect the Styles API to cover text properties, but it does. Some of them surprised me.</p><pre><code class="language-kotlin">val pressTextStyle = Style {
      contentColor(Color.Black)
      fontSize(18.sp)
      letterSpacing(0.sp)
      pressed {
          animate {
              contentColor(Color(0xFFFF6D00))
              letterSpacing(4.sp)
              textDecoration(TextDecoration.Underline)
              scale(0.96f)
          }
      }
  }

  val gradientStyle = Style {
      contentBrush(Brush.linearGradient(listOf(Color.Magenta, Color.Cyan)))
      fontSize(28.sp)
      fontWeight(FontWeight.Bold)
  }</code></pre><p>The first style makes text spread its letters apart and underline when pressed. It looks good. The second applies a gradient brush to the text. No custom <code>drawBehind</code> or <code>TextStyle</code> with <code>Brush</code>. Just <code>contentBrush()</code> in the style block.</p><p><code>letterSpacing()</code> animating on press is a subtle effect that feels premium. I had never seen it done in a Compose app, mostly because doing it with the current API would require <code>animateDpAsState</code> plus a custom <code>TextStyle</code> rebuild on every frame. Here it is one line inside an <code>animate()</code> block.</p><p>What you learn:</p><ul><li>Text properties are first-class in the Style system: <code>fontSize()</code>, <code>fontWeight()</code>, <code>letterSpacing()</code>, <code>textDecoration()</code>, and <code>contentBrush()</code>.</li><li><code>contentBrush()</code> enables gradient text without custom drawing code. Pass any <code>Brush</code> and the text renders with it.</li><li><code>letterSpacing()</code> and <code>textDecoration()</code> can animate on interaction state changes with zero manual setup.</li></ul><h4 id="lab-7-theme-aware-styles">Lab 7: Theme-aware styles</h4><p><em>Styles read <code>MaterialTheme</code> colors and auto-update on dark/light toggle.</em></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/lab-7-theme-integration.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/lab-7-theme-integration.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/lab-7-theme-integration.jpg 1000w, https://aditlal.dev/content/images/2026/02/lab-7-theme-integration.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>One concern I had going in: can styles read the current theme? If they are static objects, they would not respond to dark mode toggles. Turns out, <code>StyleScope</code> extends <code>CompositionLocalAccessorScope</code>, which means you can read any <code>CompositionLocal</code> inside a <code>Style {}</code> block.</p><pre><code class="language-kotlin">val primary = MaterialTheme.colorScheme.primary
  val onPrimary = MaterialTheme.colorScheme.onPrimary
  val surface = MaterialTheme.colorScheme.surface
  val onSurface = MaterialTheme.colorScheme.onSurface

  val buttonStyle = Style {
      background(primary)
      contentColor(onPrimary)
      shape(RoundedCornerShape(12.dp))
      contentPadding(16.dp)
      pressed {
          animate {
              background(surface)
              contentColor(onSurface)
              scale(0.95f)
          }
      }
  }</code></pre><p>Toggle dark mode. The button updates its colors immediately. No extra wiring. The <code>Style {}</code> block captures the <code>CompositionLocal</code> values, and when the theme changes, the style recomposes with the new values. This is how it should work, and I was relieved it did.</p><p>What you learn:</p><ul><li><code>StyleScope</code> extends <code>CompositionLocalAccessorScope</code>. You can read <code>MaterialTheme.colorScheme</code>, <code>LocalContentColor</code>, or any custom <code>CompositionLocal</code> inside a Style block.</li><li>Styles react to theme changes automatically. Swap light to dark, and the style picks up the new palette.</li><li>No <code>isSystemInDarkTheme()</code> checks needed. No conditional style selection.</li></ul><h4 id="lab-8-custom-components-with-style-parameters">Lab 8: Custom components with style parameters</h4><p><em>The API guidelines pattern: Defaults object + style parameter + <code>.then()</code> override.</em></p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/lab-8-custom-components.jpg" class="kg-image" alt="The Compose Styles API: Building 8 Labs to Master Declarative Styling" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/lab-8-custom-components.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/lab-8-custom-components.jpg 1000w, https://aditlal.dev/content/images/2026/02/lab-8-custom-components.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>This is the lab that matters most for library authors and design system teams. The Compose team has published guidelines for how components should expose styling, and the pattern looks like this:</p><pre><code class="language-kotlin">object StyledChipDefaults {
      @Composable
      fun style(): Style {
          val bg = MaterialTheme.colorScheme.secondaryContainer
          val fg = MaterialTheme.colorScheme.onSecondaryContainer
          return Style {
              background(bg)
              shape(RoundedCornerShape(8.dp))
              contentPadding(horizontal = 16.dp, vertical = 8.dp)
              contentColor(fg)
              pressed { animate { scale(0.95f) } }
          }
      }
  }

  @Composable
  fun StyledChip(
      onClick: () -&gt; Unit,
      modifier: Modifier = Modifier,
      style: Style = StyledChipDefaults.style(),
      content: @Composable () -&gt; Unit,
  )</code></pre><p>The Defaults object provides a <code>@Composable</code> <code>style()</code> function that reads the theme. The component accepts a <code>style</code> parameter with the defaults as the default value. Callers who want to customize use <code>StyledChipDefaults.style().then(Style { ... })</code> to override specific properties while keeping the rest.</p><p>This mirrors how Material3 components already work with <code>colors</code>, <code>elevation</code>, and <code>contentPadding</code> parameters, but collapses them all into a single <code>style</code> parameter. One parameter instead of five. One override mechanism instead of five separate Defaults functions.</p><p>Consider what this does to API surface. Today, a Material3 <code>Button</code> has <code>colors</code>, <code>elevation</code>, <code>shape</code>, <code>contentPadding</code>, and <code>border</code> parameters. Each has its own Defaults object and its own override pattern. With Styles, all of that collapses to one <code>style</code> parameter. Callers learn one override mechanism. Library maintainers expose one customization surface.</p><p>What you learn:</p><ul><li>Follow the same pattern as Material3: a Defaults object with a <code>@Composable fun style()</code> that reads theme values.</li><li>Callers override with <code>style = StyledChipDefaults.style().then(Style { ... })</code>. They get the base behavior plus their customizations.</li><li>If you are building a component library, start designing your APIs around this pattern now.</li></ul><h2 id="what-i-learned-building-8-labs">What I learned building 8 labs</h2><p>Here are the six things that stuck with me.</p><ol><li>MutableStyleState is non-negotiable in alpha06.</li></ol><p>If you use <code>Modifier.styleable(style = myStyle)</code> and expect <code>checked()</code> or <code>selected()</code> to just work when paired with <code>toggleable()</code>, they won&apos;t. This is by design &#x2013; earlier alphas had auto-detection but it conflicted with the <code>interactionSource</code> on <code>clickable/toggleable</code>. <br><br>You create a <code>MutableStyleState</code>, share the <code>MutableInteractionSource</code>, and explicitly set <code>styleState.isChecked</code> or styleState.isSelected` yourself. The <code>clickable</code>/<code>interactionSource</code>/<code>ripple</code> integration is being reworked, so expect this to evolve.</p><p>For pressed state specifically, share the <code>InteractionSource</code>:</p><pre><code class="language-kotlin">val src = remember { MutableInteractionSource() }
val ss = remember { MutableStyleState(src) }
Box(
    Modifier
        .styleable(styleState = ss, style = myStyle)
        .clickable(interactionSource = src, indication = null) { }
)
</code></pre><p>If your styles aren&apos;t responding to state, this is almost certainly why.</p><ol start="2"><li>Style composition is the real win.</li></ol><p>The individual style properties are convenient. The state blocks are nice. But <code>.then()</code> composition is what turns this into a design system tool. Define your tokens as styles. Compose them. Override selectively. This is the pattern that scales from a demo app to a production system.</p><ol start="3"><li>Some things do not work yet.</li></ol><p><code>dropShadow()</code> exists in the API surface but has an internal constructor. I could not use it. Some properties appear in autocomplete but do not render visibly. This is alpha software. Ship your experiments in debug builds, not your production APK.</p><ol start="4"><li><code>contentColor</code> propagation works well.</li></ol><p>Set <code>contentColor()</code> on a parent style, and child <code>Text</code> and <code>Icon</code> composables pick it up through <code>LocalContentColor</code>. This is not new behavior for Compose, but having it work through the Style system means you define your icon and text colors once in the style, not on each child. For the favorite button in Lab 5, the icon color changes from gray to red purely because the parent style switches <code>contentColor</code> in the <code>checked()</code> block.</p><ol start="5"><li>Theme integration works.</li></ol><p>I was worried styles might be static and disconnect from <code>CompositionLocal</code> values. They don&apos;t. <code>StyleScope</code> extends <code>CompositionLocalAccessorScope</code>, so you read <code>MaterialTheme.colorScheme.primary</code> inside a <code>Style {}</code> block and it recomposes when the theme changes. Dark mode works. Custom themes work.</p><ol start="6"><li>Where this is headed.</li></ol><p>Looking at the full API surface, this looks like Compose&apos;s answer to CSS-in-JS. A declarative styling system with state variants, composition, animation, and theme integration. When it reaches stable, it could change how component libraries are built. The pattern in Lab 8, where a component exposes a single <code>style</code> parameter with composable defaults, is cleaner than the current Material3 approach of separate <code>colors</code>, <code>elevation</code>, <code>shape</code>, and <code>contentPadding</code> parameters.</p><p>The caveat is obvious: this is alpha. The API surface could change. <code>MutableStyleState</code> behavior will almost certainly evolve. Property names might shift. But the direction is clear, and the developer experience in these eight labs, once I worked around the alpha06 bugs, was better than the InteractionSource approach.</p><p>I think the <code>.then()</code> composition and the Defaults object pattern from Lab 8 will be the most impactful features when this stabilizes. Not because they&apos;re flashy. Because they give Compose a real answer to something annoying since 1.0: how do you let callers override a component&apos;s look without exposing five separate parameters?</p><h2 id="try-it-yourself">Try it yourself</h2><p>The full source for all eight labs is on GitHub: <a href="https://github.com/aldefy/ComposeStylingApiDemo?ref=aditlal.dev">Compose Style Lab</a>. Clone it, run it, tap things. Every lab has live toggles, property readouts, and state controls. Break the styles. Compose new ones. The best way to learn this API is to play with it.</p><p>To use the Styles API in your own project, add <code>compose-foundation:1.11.0-alpha06</code> (or newer) and opt in with <code>@OptIn(ExperimentalFoundationStyleApi::class)</code>.</p><p>P.S - If you build something with the Styles API, I&apos;d like to see it.</p>]]></content:encoded></item><item><title><![CDATA[Hunting the Play Store Heisenbug: R8, ART Verify Mode, and Firebase Init Races]]></title><description><![CDATA[App crashes with "FirebaseApp is not initialized" only in Play Store Pre-Launch Reports? Fresh installs run in ART's interpreted mode, turning a 1ms race into 100ms+. A deep dive into verify mode, R8 Full Mode, and the latch fix.]]></description><link>https://aditlal.dev/play-store-heisenbug-art-verify/</link><guid isPermaLink="false">69a088e2570d4bfde14a557a</guid><category><![CDATA[Android]]></category><category><![CDATA[Pre-Launch Report]]></category><category><![CDATA[Google Play Console]]></category><category><![CDATA[Firebase Init]]></category><category><![CDATA[Race Condition]]></category><category><![CDATA[Android Performance]]></category><category><![CDATA[R8]]></category><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Thu, 26 Feb 2026 18:29:15 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1710292036905-be7144b2ac8f?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDIwfHxuZW9uJTIwd2F2ZSUyMGJsdXJ8ZW58MHx8fHwxNzcyMTI4NjY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<pre><code class="language-kotlin">java.lang.IllegalStateException: Default FirebaseApp is not initialized in this process
    at com.google.firebase.FirebaseApp.getInstance()
    at com.google.firebase.remoteconfig.FirebaseRemoteConfig.getInstance()</code></pre><img src="https://images.unsplash.com/photo-1710292036905-be7144b2ac8f?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDIwfHxuZW9uJTIwd2F2ZSUyMGJsdXJ8ZW58MHx8fHwxNzcyMTI4NjY0fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="Hunting the Play Store Heisenbug: R8, ART Verify Mode, and Firebase Init Races"><p>If you are reading this, you are probably staring at a Play Store Pre-Launch Report or a Firebase Test Lab result, throwing the exact crash above.</p><p><strong>Here is the maddening part</strong>: You have zero crashes in production. You cannot reproduce this locally. You&apos;ve cold-started your app on an 8-core physical device 50 times, and it works flawlessly every single time.</p><p>You aren&apos;t crazy. Your code is experiencing a&#xA0;<a href="https://en.wikipedia.org/wiki/Heisenbug?ref=aditlal.dev">Heisenbug</a>&#xA0;- a race condition that only exists under the exact, hostile conditions of the Google Play Console&apos;s automated testing environment. Attach a debugger, add a log statement, change the timing by a microsecond, and the bug vanishes.<br><br><strong>The TL;DR:</strong></p><p>When Google runs your app in a Pre-Launch Report, it is a fresh install running in ART&apos;s&#xA0;<code>compiler-filter=verify</code>&#xA0;mode. Your app is running purely interpreted, with zero Ahead-Of-Time (AOT) compilation. Combined with the aggressive structural changes of R8 Full Mode and an emulated environment starved for CPU cycles, a 1ms initialization window that always succeeds on your local device stretches into a 100ms+ bottleneck.</p><p>Your background coroutines are losing a race against your main thread&apos;s dependency injection.</p><p>Here is the exact mechanism of why your app is failing in review, how to force your local emulator to replicate this environment, and the cross-module latch mechanism required to fix it.<br></p><p>&#x1F449; <a href="#the-fix-cross-module-latch-coordination">Jump straight to the cross-module CountDownLatch fix</a></p>
<hr><h2 id="1-the-pre-launch-environment-running-on-hard-mode">1. The Pre-Launch Environment: Running on Hard Mode</h2><p>Every AAB uploaded to the Play Console triggers a&#xA0;<a href="https://support.google.com/googleplay/android-developer/answer/9844487?ref=aditlal.dev">Pre-Launch Report</a>&#xA0;powered by&#xA0;<a href="https://firebase.google.com/docs/test-lab?ref=aditlal.dev">Firebase Test Lab&apos;s Robo test</a>. The automated crawler installs the app on physical and virtual devices, exercises the UI, and looks for crashes, accessibility issues, and security vulnerabilities.</p><p>The critical detail nobody talks about:&#xA0;<strong>freshly installed apps run with&#xA0;<code>compiler-filter=verify</code></strong>.</p><p>This means:</p><ul><li>DEX bytecode is verified but&#xA0;<strong>not AOT-compiled</strong>.</li><li>The app runs in&#xA0;<strong>interpreted + JIT mode</strong>, which is&#xA0;<a href="https://developer.android.com/topic/performance/baselineprofiles/overview?ref=aditlal.dev">30% to 40% slower</a>&#xA0;than AOT.</li><li><a href="https://developer.android.com/topic/performance/baselineprofiles/overview?ref=aditlal.dev#cloud-profiles">Cloud Profiles</a>&#xA0;are not available on a fresh install.</li><li><a href="https://developer.android.com/topic/performance/baselineprofiles/overview?ref=aditlal.dev">Baseline Profiles</a>&#xA0;require a background dexopt pass before they take effect.</li></ul><p>In Android 14+, the&#xA0;<a href="https://source.android.com/docs/core/runtime/configure/art-service?ref=aditlal.dev">ART Service</a>&#xA0;relies on a background job (<code>pm.dexopt.bg-dexopt=speed-profile</code>) to compile the app. Crucially, this job only executes when the device is idle and charging. Test Lab provisions a device, installs the app, and immediately launches the crawler. The device is never idle. It never compiles.</p><p>Google&apos;s&#xA0;<a href="https://support.google.com/googleplay/android-developer/answer/9844487?ref=aditlal.dev">own Pre-Launch Report documentation</a>&#xA0;says the tests use &quot;real Android devices running Android 9+.&quot; It never discloses the ART compilation mode. We verified this &#x2014; the page describes errors, warnings, performance metrics, and accessibility checks. It says nothing about&#xA0;<code>compiler-filter=verify</code>. This is the gap.</p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/Hilt-and-Firebase-2026-02-26-182124.png" class="kg-image" alt="Hunting the Play Store Heisenbug: R8, ART Verify Mode, and Firebase Init Races" loading="lazy" width="2000" height="2876" srcset="https://aditlal.dev/content/images/size/w600/2026/02/Hilt-and-Firebase-2026-02-26-182124.png 600w, https://aditlal.dev/content/images/size/w1000/2026/02/Hilt-and-Firebase-2026-02-26-182124.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/02/Hilt-and-Firebase-2026-02-26-182124.png 1600w, https://aditlal.dev/content/images/size/w2400/2026/02/Hilt-and-Firebase-2026-02-26-182124.png 2400w" sizes="(min-width: 720px) 720px"></figure><p>This is a fundamentally different execution environment from your local device, where repeated installs and profile-guided compilation mean your app is running with&#xA0;<code>speed-profile</code>&#xA0;or better.</p>
<!--kg-card-begin: html-->
<table>
  <thead>
    <tr>
      <th>Factor</th>
      <th>Local dev</th>
      <th>Play Store pre-review</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ART compilation</td>
      <td><code>speed-profile</code> or <code>speed</code> (AOT)</td>
      <td><code>verify</code> (interpreted + JIT)</td>
    </tr>
    <tr>
      <td>Execution speed</td>
      <td>Coroutine launches in ~5ms</td>
      <td>Coroutine launch can take 100ms+</td>
    </tr>
    <tr>
      <td>CPU contention</td>
      <td>Dedicated cores, no background load</td>
      <td>dex2oat running + crawler consuming CPU</td>
    </tr>
    <tr>
      <td>Cloud/Baseline profiles</td>
      <td>Available (repeated installs)</td>
      <td>Not available (fresh install)</td>
    </tr>
    <tr>
      <td>Race window</td>
      <td>~1ms (Firebase always wins)</td>
      <td>10-50ms+ (Firebase may lose)</td>
    </tr>
  </tbody>
</table>
<!--kg-card-end: html-->
<hr><h2 id="2-the-multiplier-r8-full-mode">2. The Multiplier: R8 Full Mode</h2><p><a href="https://developer.android.com/topic/performance/app-optimization/enable-app-optimization?ref=aditlal.dev">R8 full mode</a>&#xA0;&#x2014; enabled by default since AGP 8.0 &#x2014; applies aggressive optimizations that change code structure in ways that alter initialization timing:</p><ul><li><strong>Vertical class merging</strong>: Single-implementation interfaces get merged into their concrete class. A Google engineer&#xA0;<a href="https://github.com/Kotlin/kotlinx.coroutines/issues/1304?ref=aditlal.dev">confirmed in kotlinx.coroutines #1304</a>&#xA0;that this specific optimization prevents coroutine dispatch optimization, filing a separate R8 bug.</li><li><strong>Visibility relaxation</strong>: Private methods are made public to bypass JVM access checks for cross-class inlining.</li><li><strong>Factory inlining</strong>: Hilt factory code (<code>Module_ProvideXFactory.get()</code>) is inlined directly at call sites.</li><li><strong>Constructor removal</strong>: Default constructors are stripped when R8 determines they are unnecessary.</li></ul><p>What used to be a lightweight virtual dispatch becomes a massive, contiguous block of bytecode directly inside your&#xA0;<code>Application.onCreate()</code>.</p><p>In AOT mode, this is pre-compiled machine code that executes in microseconds. But in&#xA0;<code>verify</code>&#xA0;mode, the JIT compiler must parse and compile this bloated method on the fly, directly on the main thread. This JIT overhead acts as a massive speed bump, wildly expanding the window for race conditions.</p><p>This is not theoretical.&#xA0;<a href="https://github.com/square/retrofit/issues/3751?ref=aditlal.dev">Retrofit #3751</a>&#xA0;documents R8 full mode stripping the generic type information Retrofit needs for reflection.&#xA0;<a href="https://github.com/google/dagger/issues/1859?ref=aditlal.dev">Dagger #1859</a>&#xA0;shows&#xA0;<code>DoubleCheck.get()</code>&#xA0;contention appearing in production ANR traces when scoped providers fight for the same lock. These are real crashes, in real apps, caused by R8 full mode restructuring code that was never designed for it.</p><h3 id="the-double-whammy">The Double Whammy</h3><p>The combination is lethal: R8 full mode changes the&#xA0;<strong>structure</strong>&#xA0;of your code.&#xA0;<code>verify</code>&#xA0;mode changes the&#xA0;<strong>speed</strong>&#xA0;of execution. Together, they create a runtime environment that has almost nothing in common with your local debug build.</p><hr><h2 id="3-the-race-condition-firebase-vs-hilt">3. The Race Condition: Firebase vs. Hilt</h2><h3 id="the-contentprovider-trap">The ContentProvider Trap</h3><p>The Android initialization sequence is strictly ordered:</p><ol><li><code>ContentProvider.onCreate()</code>&#xA0;&#x2014; all registered providers</li><li><code>Application.attachBaseContext()</code></li><li><code>Application.onCreate()</code></li><li>Hilt component creation</li></ol><p>Many libraries historically relied on ContentProviders for auto-initialization. Firebase used&#xA0;<code>FirebaseInitProvider</code>&#xA0;&#x2014; a ContentProvider that&#xA0;<a href="https://firebase.blog/posts/2016/12/how-does-firebase-initialize-on-android/?ref=aditlal.dev">guaranteed Firebase was ready</a>&#xA0;before&#xA0;<code>Application.onCreate()</code>&#xA0;even ran. It was a silent, invisible dependency that just worked.</p><p>The problem: ContentProviders are&#xA0;<a href="https://developer.android.com/topic/libraries/app-startup?ref=aditlal.dev">expensive to instantiate</a>&#xA0;and slow down startup. So many of us migrated to&#xA0;<a href="https://developer.android.com/topic/libraries/app-startup?ref=aditlal.dev">Jetpack App Startup</a>, which replaces multiple ContentProviders with a single one and lets you define explicit dependencies between initializers.</p><h3 id="the-typical-migration">The Typical Migration</h3><ol><li><strong>Remove</strong>&#xA0;<code>FirebaseInitProvider</code>&#xA0;from the manifest (<code>tools:node=&quot;remove&quot;</code>).</li><li><strong>Fire</strong>&#xA0;a&#xA0;<code>Dispatchers.IO</code>&#xA0;coroutine in an App Startup Initializer to call&#xA0;<code>FirebaseApp.initializeApp()</code>.</li><li><strong>Let</strong>&#xA0;Hilt resolve dependencies like&#xA0;<code>FirebaseRemoteConfig.getInstance()</code>&#xA0;synchronously during&#xA0;<code>Application.onCreate()</code>.</li></ol><p>This pattern is everywhere. And it&apos;s a ticking time bomb.</p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/Hilt-and-Firebase-2026-02-26-181845.png" class="kg-image" alt="Hunting the Play Store Heisenbug: R8, ART Verify Mode, and Firebase Init Races" loading="lazy" width="2000" height="1385" srcset="https://aditlal.dev/content/images/size/w600/2026/02/Hilt-and-Firebase-2026-02-26-181845.png 600w, https://aditlal.dev/content/images/size/w1000/2026/02/Hilt-and-Firebase-2026-02-26-181845.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/02/Hilt-and-Firebase-2026-02-26-181845.png 1600w, https://aditlal.dev/content/images/2026/02/Hilt-and-Firebase-2026-02-26-181845.png 2000w" sizes="(min-width: 720px) 720px"></figure><h3 id="why-it-crashes-in-pre-review">Why It Crashes in Pre-Review</h3><p>On a flagship phone, that coroutine launches in ~5ms. Firebase always wins the race.</p><p>In Test Lab, three heavy processes fight for limited vCPU cycles: your app&apos;s main thread,&#xA0;<code>dex2oat</code>&#xA0;verifying DEX files, and the Robo crawler. Because&#xA0;<code>Dispatchers.IO</code>&#xA0;uses a shared thread pool, CPU starvation causes scheduling delays. That coroutine might take 150ms+ to launch. Hilt resolves synchronously on the main thread, beating Firebase to the punch. Result:&#xA0;<code>IllegalStateException</code>.</p><h3 id="youre-not-alone">You&apos;re Not Alone</h3><p>This exact pattern has been reported across the Firebase ecosystem &#x2014; always with the same bewildered observation that it &quot;only happens on first install from Google Play&quot;:</p><ul><li><a href="https://github.com/firebase/firebase-android-sdk/issues/4693?ref=aditlal.dev">firebase-android-sdk #4693</a>: &quot;FirebaseApp is not initialized in this process.&quot; Multiple reporters confirm it happens &quot;mostly only the very first time the app is started, likely after being installed from Google Play.&quot;</li><li><a href="https://github.com/firebase/firebase-android-sdk/issues/6145?ref=aditlal.dev">firebase-android-sdk #6145</a>:&#xA0;<code>Utils.awaitEvenIfOnMainThread()</code>&#xA0;caused a&#xA0;<a href="https://github.com/firebase/firebase-android-sdk/issues/6145?ref=aditlal.dev">100% reproducible ANR</a>. The stack trace shows&#xA0;<code>CountDownLatch.await()</code>&#xA0;blocking the main thread &#x2014; Crashlytics&apos; own internal synchronization failing under the same conditions.</li><li><a href="https://github.com/firebase/flutterfire/issues/8837?ref=aditlal.dev">FlutterFire #8837</a>:&#xA0;<code>Firebase.initializeApp()</code>&#xA0;takes&#xA0;<strong>7.5 seconds</strong>&#xA0;until first frame on low-end devices in Play pre-launch reports. The reporter notes it is &quot;not CPU-bound&quot; &#x2014; suggesting lock contention or I/O bottleneck, not raw computation.</li></ul><p>That last one is the closest anyone has come to documenting this publicly. But none of these issues connect the dots to ART compilation mode.<br></p><hr><h2 id="4-the-baseline-profile-trap-why-your-flagship-is-lying-to-you">4. The Baseline Profile Trap: Why Your Flagship is Lying to You</h2><p>If you are dealing with Pre-Launch report crashes and slow startups, you are likely already looking at your&#xA0;<a href="https://developer.android.com/topic/performance/baselineprofiles/overview?ref=aditlal.dev">Baseline Profiles</a>. But how you generate them &#x2014; and how fresh they are &#x2014; dictates whether they survive the real world.</p><h3 id="how-baseline-profile-generation-actually-works">How Baseline Profile Generation Actually Works</h3><p>A common misconception: the Macrobenchmark profiler works like a CPU sampling profiler, recording which methods are &quot;hot&quot; based on execution time. It does not.</p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/Hilt-and-Firebase-2026-02-26-182526.png" class="kg-image" alt="Hunting the Play Store Heisenbug: R8, ART Verify Mode, and Firebase Init Races" loading="lazy" width="2000" height="422" srcset="https://aditlal.dev/content/images/size/w600/2026/02/Hilt-and-Firebase-2026-02-26-182526.png 600w, https://aditlal.dev/content/images/size/w1000/2026/02/Hilt-and-Firebase-2026-02-26-182526.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/02/Hilt-and-Firebase-2026-02-26-182526.png 1600w, https://aditlal.dev/content/images/2026/02/Hilt-and-Firebase-2026-02-26-182526.png 2000w" sizes="(min-width: 720px) 720px"></figure><p>The&#xA0;<code>BaselineProfileRule</code>&#xA0;records&#xA0;<strong>which methods were executed</strong>&#xA0;during your test journeys. A method is either called or it isn&apos;t. It does not matter how fast the device is &#x2014; the same code paths produce the same profile entries. A method that takes 1 microsecond on a Pixel 9 Pro Fold produces the same profile entry as one that takes 100ms on a Pixel 4a.</p><p>What&#xA0;<em>does</em>&#xA0;matter is&#xA0;<strong>code path coverage</strong>. Your test journeys define which methods get profiled. If your&#xA0;<code>profileBlock</code>&#xA0;only calls&#xA0;<code>startActivityAndWait()</code>, you only capture startup methods. If you also scroll lists, navigate screens, and trigger network calls, you capture those paths too.</p><h3 id="where-device-choice-actually-matters">Where Device Choice Actually Matters</h3><p>The device affects the profile in three indirect ways:</p><ol><li><strong>Async content and timeouts</strong>: If your test calls&#xA0;<code>startActivityAndWait()</code>&#xA0;and the device is so slow that async content fails to load before the framework timeout, you&#xA0;<em>miss</em>&#xA0;those code paths. Conversely, extremely fast devices always complete async work, but that&apos;s true of any reasonable device.</li><li><strong>Reproducibility</strong>: A Pixel 9 Pro Fold is not reproducible across team members and CI servers. Google&apos;s recommended Gradle Managed Device config &#x2014; a&#xA0;<strong>Pixel 6 API 31 with&#xA0;<code>aosp</code>&#xA0;system image</strong>&#xA0;&#x2014; is reproducible anywhere.</li><li><strong>Unique code paths</strong>: A foldable device may exercise code paths specific to multi-window or large screen layouts that don&apos;t represent your median user.</li></ol><h3 id="what-meta-learned-at-scale">What Meta Learned at Scale</h3><p>Meta Engineering published a&#xA0;<a href="https://engineering.fb.com/2025/10/01/android/accelerating-our-android-apps-with-baseline-profiles/?ref=aditlal.dev">detailed account of their Baseline Profile infrastructure</a>&#xA0;in October 2025. The key insights:</p><ul><li><strong>For complex apps like Facebook and Instagram, benchmarks aren&apos;t representative enough.</strong>&#xA0;They collect class and method usage data from real users via a custom&#xA0;<code>ClassLoader</code>&#xA0;at a low sample rate.</li><li><strong>Inclusion threshold matters more than device choice.</strong>&#xA0;They started conservatively at 80-90% frequency and lowered it to &#x2265;20% &#x2014; a method needs to appear in at least 20% of cold start traces to be included.</li><li><strong>Profile size has a ceiling.</strong>&#xA0;Compiled machine code is ~10x larger than interpreted code. A bloated profile increases I/O cost through page faults and cache misses. They&apos;ve occasionally seen regressions from profiles that were too large.</li><li><strong>They optimize beyond startup</strong>&#xA0;&#x2014; feed scrolling, DM navigation, surface transitions.</li><li><strong>Results: up to 40% improvement</strong>&#xA0;across critical performance metrics.</li></ul><h3 id="the-staleness-problem">The Staleness Problem</h3><p>For your app, the bigger issue is probably&#xA0;<strong>staleness</strong>. When you enable R8 full mode, the compiler restructures your code &#x2014; merging classes, inlining factories, relaxing visibility. The method signatures change. A Baseline Profile generated before R8 full mode was enabled references methods that may no longer exist in the optimized binary.</p><p>Since AGP 8.2, R8&#xA0;<a href="https://developer.android.com/topic/performance/baselineprofiles/overview?ref=aditlal.dev">rewrites profile rules</a>&#xA0;to match the obfuscated release build, increasing method coverage by ~30%. But this only works if the profile is regenerated from an unminified build in the same pipeline. A 5-week-old profile against a post-R8-full-mode binary is a stale profile.</p><p><strong>The Rule: Regenerate every release.</strong>&#xA0;Automate it with&#xA0;<code>./gradlew :app:generateBaselineProfile</code>&#xA0;in CI. Use a&#xA0;<a href="https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile?ref=aditlal.dev">Pixel 6 API 31 GMD</a>with&#xA0;<code>systemImageSource = &quot;aosp&quot;</code>. And make your&#xA0;<code>profileBlock</code>&#xA0;cover your critical user journeys, not just startup.</p><hr><h2 id="5-local-testing-simulating-pre-launch-conditions">5. Local Testing: Simulating Pre-Launch Conditions</h2><p>To prove this to yourself, you need to strip the AOT artifacts from your local device and force it into&#xA0;<code>verify</code>&#xA0;mode.</p><p>Run these ADB commands:</p><pre><code class="language-shell"># Strip AOT, force interpreted mode
adb shell cmd package compile -m verify -f your.package.name

# Cold start with timing
adb shell am force-stop your.package.name
adb shell am start-activity -W -S your.package.name/.MainActivity

# Verify compilation state
adb shell dumpsys package dexopt | grep -A5 &quot;your.package.name&quot;

# Simulate background dexopt with profile (what happens hours after install)
adb shell cmd package compile -m speed-profile -f your.package.name

# Reset to trigger dex2oat on next boot
adb shell cmd package compile --reset your.package.name</code></pre><p>The&#xA0;<a href="https://source.android.com/docs/core/runtime/configure?ref=aditlal.dev">AOSP documentation on ART configuration</a>&#xA0;confirms the compiler filters:&#xA0;<code>verify</code>&#xA0;= DEX code verification only (no AOT compilation),&#xA0;<code>speed-profile</code>&#xA0;= AOT-compile profiled hot methods,&#xA0;<code>speed</code>&#xA0;= AOT-compile everything.</p><p><strong>The hard truth</strong>: Even with&#xA0;<code>verify</code>&#xA0;mode on a modern 8-core physical device, you might still be too fast to trigger the crash. A 4-core emulator under&#xA0;<code>verify</code>&#xA0;mode is the closest approximation to Test Lab. We ran 30+ cold starts across a Pixel 9 Pro Fold (physical), a Pixel 9a emulator (4 cores), and a custom 1-core/1GB RAM emulator &#x2014; all in&#xA0;<code>verify</code>&#xA0;mode &#x2014; and reproduced zero crashes. The Play Store pre-launch environment has additional constraints we can&apos;t fully replicate: CPU contention from the Robo crawler itself, whatever specific VM configuration Google uses, and dex2oat running concurrently with app launch.</p><hr><h2 id="6-the-fix-cross-module-latch-coordination">6. The Fix: Cross-Module Latch Coordination</h2><p>We know the root cause: Hilt is resolving dependencies synchronously on the main thread faster than our background coroutine can initialize Firebase.</p><p>We need to force Hilt to wait, but we have a structural problem. Our&#xA0;<code>FirebaseInitializer</code>&#xA0;lives in the&#xA0;<code>app</code>&#xA0;module, but our dependency injection module lives in a shared&#xA0;<code>core</code>&#xA0;module. We cannot directly reference the background job across module boundaries.</p><p>The solution is a thread-safe, cross-module synchronization point.</p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/Hilt-and-Firebase-2026-02-26-181929.png" class="kg-image" alt="Hunting the Play Store Heisenbug: R8, ART Verify Mode, and Firebase Init Races" loading="lazy" width="2000" height="1636" srcset="https://aditlal.dev/content/images/size/w600/2026/02/Hilt-and-Firebase-2026-02-26-181929.png 600w, https://aditlal.dev/content/images/size/w1000/2026/02/Hilt-and-Firebase-2026-02-26-181929.png 1000w, https://aditlal.dev/content/images/size/w1600/2026/02/Hilt-and-Firebase-2026-02-26-181929.png 1600w, https://aditlal.dev/content/images/2026/02/Hilt-and-Firebase-2026-02-26-181929.png 2000w" sizes="(min-width: 720px) 720px"></figure><h3 id="step-1-create-the-readiness-object">Step 1: Create the readiness object</h3><p>In your shared&#xA0;<code>core</code>&#xA0;module, define a simple object to hold a&#xA0;<code>CountDownLatch</code>:</p><pre><code class="language-kotlin">package your.package.common.di

import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicBoolean

object FirebaseReadiness {
    val initLatch = CountDownLatch(1)
    val initSucceeded = AtomicBoolean(false)
}</code></pre><h3 id="step-2-release-the-latch-in-your-initializer">Step 2: Release the latch in your Initializer</h3><p>In your&#xA0;<code>app</code>&#xA0;module, update your Jetpack App Startup initializer to count down the latch the moment Firebase is ready:</p><pre><code class="language-kotlin">class FirebaseInitializer : Initializer&lt;Unit&gt; {
    override fun create(context: Context) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                FirebaseApp.initializeApp(context)
                FirebaseReadiness.initSucceeded.set(true)
            } catch (e: Exception) {
                // Log initialization failure
            } finally {
                // Always release the latch so we don&apos;t permanently block the main thread
                FirebaseReadiness.initLatch.countDown()
            }
        }
    }

    override fun dependencies(): List&lt;Class&lt;out Initializer&lt;*&gt;&gt;&gt; = emptyList()
}</code></pre><h3 id="step-3-block-the-injection-until-ready">Step 3: Block the injection until ready</h3><p>Back in your&#xA0;<code>core</code>&#xA0;module, update your Hilt&#xA0;<code>@Provides</code>&#xA0;function to wait for the latch.</p><p><strong>Crucially: Add a timeout.</strong>&#xA0;Never block the main thread indefinitely. If Firebase fails to initialize within 5 seconds, it is better to crash cleanly or provide a fallback than to trigger a guaranteed ANR. Firebase&apos;s own&#xA0;<code>Utils.awaitEvenIfOnMainThread()</code>caused&#xA0;<a href="https://github.com/firebase/firebase-android-sdk/issues/6145?ref=aditlal.dev">100% reproducible ANRs</a>&#xA0;by doing exactly this &#x2014; blocking without a reasonable timeout.</p><pre><code class="language-kotlin">@Module
@InstallIn(SingletonComponent::class)
object RemoteConfigModule {

    @Provides
    @Singleton
    fun providesFirebaseRemoteConfig(): FirebaseRemoteConfig {
        // Wait up to 5 seconds for the background initializer to finish
        val isReady = FirebaseReadiness.initLatch.await(5, TimeUnit.SECONDS)

        check(isReady &amp;&amp; FirebaseReadiness.initSucceeded.get()) {
            &quot;Firebase initialization timed out or failed in background coroutine.&quot;
        }

        return FirebaseRemoteConfig.getInstance()
    }
}</code></pre><h3 id="a-note-on-countdownlatch-and-hilts-doublecheck">A Note on&#xA0;<code>CountDownLatch</code>&#xA0;and Hilt&apos;s&#xA0;<code>DoubleCheck</code></h3><p>There is a subtle deadlock risk here. Hilt resolves&#xA0;<code>@Singleton</code>-scoped providers through&#xA0;<code>DoubleCheck.get()</code>, which uses&#xA0;<code>synchronized</code>. If your latch producer also needs a scoped dependency from the same Hilt component, you can deadlock: thread A holds the&#xA0;<code>DoubleCheck</code>&#xA0;lock waiting on the latch, thread B needs the&#xA0;<code>DoubleCheck</code>&#xA0;lock to produce the latch value.</p><p>Our&#xA0;<code>FirebaseReadiness</code>&#xA0;object avoids this entirely &#x2014; it is a plain Kotlin&#xA0;<code>object</code>&#xA0;with no DI involvement. The latch is released from a coroutine that has no dependency on any Hilt-provided object.<br></p><hr><h2 id="7-the-connective-tissue-why-this-article-exists">7. The Connective Tissue: Why This Article Exists</h2>
<!--kg-card-begin: html-->
<table>
  <thead>
    <tr>
      <th>Domain</th>
      <th>Documentation status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ART <code>compiler-filter=verify</code> behavior</td>
      <td><a href="https://source.android.com/docs/core/runtime/configure?ref=aditlal.dev">Well-documented in AOSP</a>, never connected to Play Store</td>
    </tr>
    <tr>
      <td>Firebase initialization race conditions</td>
      <td><a href="https://github.com/firebase/firebase-android-sdk/issues/4693?ref=aditlal.dev">Widely reported on GitHub</a>, root cause left vague</td>
    </tr>
    <tr>
      <td>Pre-launch report &quot;cannot reproduce&quot; crashes</td>
      <td>Anecdotally common in <a href="https://community.appinventor.mit.edu/t/foreground-service-pre-launch-report-crash-on-android-13-and-14/138449?ref=aditlal.dev">forums</a> and <a href="https://issuetracker.google.com/issues/160907013?ref=aditlal.dev">issue trackers</a>, no systematic analysis</td>
    </tr>
  </tbody>
</table>
<!--kg-card-end: html-->
<p>The closest anyone has gotten:</p><ul><li><a href="https://github.com/firebase/flutterfire/issues/8837?ref=aditlal.dev">FlutterFire #8837</a>&#xA0;documents 7.5-second Firebase init in pre-launch but doesn&apos;t identify&#xA0;<code>verify</code>&#xA0;mode as the cause.</li><li><a href="https://github.com/facebook/redex/issues/528?ref=aditlal.dev">Redex #528</a>&#xA0;documents Firebase/GMS classes like&#xA0;<code>com.google.firebase.iid.zzac</code>&#xA0;triggering &quot;Class failed lock verification and will run slower&quot; &#x2014; with a measured 200-300ms startup hit. This is the missing link: classes that fail soft verification in ART fall back to interpreted execution, creating the exact timing expansion we describe. The Android team&apos;s&#xA0;<a href="https://medium.com/androiddevelopers/mitigating-soft-verification-issues-in-r8-and-d8-7e9e06827dfd?ref=aditlal.dev">own article on mitigating soft verification issues</a>&#xA0;documents up to 22% degradation on a Nexus 5X.</li><li><a href="https://issuetracker.google.com/issues/160907013?ref=aditlal.dev">Google Issue Tracker #160907013</a>&#xA0;has developers asking Google to fix &quot;pre-launch report false positives.&quot; No explanation of why they occur.</li><li><a href="https://github.com/android/tuningfork/issues/42?ref=aditlal.dev">android/tuningfork #42</a>&#xA0;shows a native crash reproducible in Firebase Test Lab but not on dev devices &#x2014; the same pattern, different layer of the stack.</li></ul><p>Nobody wrote the article that connects all five: R8 restructures your code.&#xA0;<code>verify</code>&#xA0;mode slows it down. Firebase init moves to a background coroutine. Hilt resolves synchronously. The race window expands from invisible to catastrophic.</p><p>Until now.</p><hr><h2 id="the-takeaway">The Takeaway</h2><p>When the Play Store Pre-Launch crawler boots your app in&#xA0;<code>verify</code>&#xA0;mode, the&#xA0;<code>CountDownLatch</code>&#xA0;absorbs the timing variance. If the JIT compiler stalls the main thread, the latch waits. If the emulated CPU is starved for cycles and the coroutine takes 200ms to launch, the latch waits.</p><p>The Play Store pre-launch environment runs your app in a fundamentally different way than your development machine. R8 full mode restructures your code, and&#xA0;<code>verify</code>&#xA0;compilation mode changes execution timing. Together, they expose initialization race conditions that are invisible locally.</p><p>The fix is not to suppress the crashes, but to eliminate the timing dependencies:</p><ul><li><strong>Use explicit initialization ordering</strong>&#xA0;&#x2014;&#xA0;<code>CountDownLatch</code>&#xA0;or Jetpack App Startup&apos;s dependency graph.</li><li><strong>Never block the main thread indefinitely</strong>&#xA0;during DI resolution &#x2014; always use a timeout.</li><li><strong>Test under&#xA0;<code>verify</code>&#xA0;mode</strong>&#xA0;locally before upload &#x2014;&#xA0;<code>adb shell cmd package compile -m verify -f your.package</code>.</li><li><strong>Regenerate Baseline Profiles every release</strong>&#xA0;&#x2014; stale profiles against R8-restructured code are worse than no profile.</li><li><strong>Cover your critical user journeys</strong>&#xA0;in the profile generator, not just&#xA0;<code>startActivityAndWait()</code>.</li></ul><hr><p>This article is based on a real root cause analysis from a production Android app. The crash appeared during Play Store pre-review, was traced to a three-piece race condition between FirebaseInitProvider removal, background coroutine initialization, and synchronous Hilt DI resolution, and was fixed with the cross-module latch pattern described above.</p><h3 id="about-the-author">About the Author&#xA0;</h3><p><br><strong>Adit Lal</strong>&#xA0;is the CTO and Co-Founder of Travv World, with over 14 years of experience in Android development. When he isn&apos;t hunting down Heisenbugs, architecting reactive state machines at scale, or pushing the limits of Kotlin Multiplatform and Jetpack Compose, you can find him sharing mobile performance insights on&#xA0;<a href="https://x.com/aditlal?ref=aditlal.dev" rel="noopener">X/Twitter</a>&#xA0;and&#xA0;<a href="https://github.com/aldefy?ref=aditlal.dev" rel="noopener">GitHub</a>.</p><hr><h3 id="references">References</h3><p><strong>Official Documentation</strong></p><ul><li><a href="https://source.android.com/docs/core/runtime/configure?ref=aditlal.dev">AOSP: Configure ART</a>&#xA0;&#x2014; Compiler filter definitions</li><li><a href="https://source.android.com/docs/core/runtime/configure/art-service?ref=aditlal.dev">AOSP: ART Service Configuration</a>&#xA0;&#x2014; Background dexopt threading</li><li><a href="https://support.google.com/googleplay/android-developer/answer/9844487?ref=aditlal.dev">Play Console: Pre-Launch Report</a>&#xA0;&#x2014; Google&apos;s docs (no compilation mode disclosure)</li><li><a href="https://firebase.google.com/docs/test-lab?ref=aditlal.dev">Firebase Test Lab</a>&#xA0;&#x2014; Pre-launch testing infrastructure</li><li><a href="https://developer.android.com/topic/performance/baselineprofiles/overview?ref=aditlal.dev">Baseline Profiles Overview</a>&#xA0;&#x2014; Profile-guided optimization</li><li><a href="https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile?ref=aditlal.dev">Create Baseline Profiles</a>&#xA0;&#x2014; Generation best practices</li><li><a href="https://developer.android.com/topic/performance/app-optimization/enable-app-optimization?ref=aditlal.dev">Enable App Optimization with R8</a>&#xA0;&#x2014; R8 full mode documentation</li><li><a href="https://developer.android.com/topic/libraries/app-startup?ref=aditlal.dev">Jetpack App Startup</a>&#xA0;&#x2014; ContentProvider replacement</li></ul><p><strong>GitHub Issues (All Verified)</strong></p><ul><li><a href="https://github.com/firebase/firebase-android-sdk/issues/4693?ref=aditlal.dev">firebase-android-sdk #4693</a>&#xA0;&#x2014; &quot;FirebaseApp is not initialized&quot; on first Play Store install</li><li><a href="https://github.com/firebase/firebase-android-sdk/issues/6145?ref=aditlal.dev">firebase-android-sdk #6145</a>&#xA0;&#x2014;&#xA0;<code>awaitEvenIfOnMainThread()</code>&#xA0;100% reproducible ANR</li><li><a href="https://github.com/firebase/firebase-android-sdk/issues/4834?ref=aditlal.dev">firebase-android-sdk #4834</a>&#xA0;&#x2014; Concurrent SDK init deadlock</li><li><a href="https://github.com/firebase/firebase-android-sdk/issues/5936?ref=aditlal.dev">firebase-android-sdk #5936</a>&#xA0;&#x2014; 3rd-party SDK race before&#xA0;<code>FirebaseInitProvider</code>&#xA0;completes</li><li><a href="https://github.com/firebase/firebase-android-sdk/issues/6039?ref=aditlal.dev">firebase-android-sdk #6039</a>&#xA0;&#x2014; Separate process init variant</li><li><a href="https://github.com/firebase/flutterfire/issues/8837?ref=aditlal.dev">FlutterFire #8837</a>&#xA0;&#x2014; 7.5s Firebase init in pre-launch</li><li><a href="https://github.com/facebook/redex/issues/528?ref=aditlal.dev">Redex #528</a>&#xA0;&#x2014; Firebase classes failing lock verification, 200-300ms startup hit</li><li><a href="https://github.com/square/retrofit/issues/3751?ref=aditlal.dev">Retrofit #3751</a>&#xA0;&#x2014; R8 full mode breaks reflection-based method resolution</li><li><a href="https://github.com/Kotlin/kotlinx.coroutines/issues/1304?ref=aditlal.dev">kotlinx.coroutines #1304</a>&#xA0;&#x2014; R8 vertical class merging breaks coroutine dispatch</li><li><a href="https://github.com/google/dagger/issues/1859?ref=aditlal.dev">Dagger #1859</a>&#xA0;&#x2014;&#xA0;<code>DoubleCheck.get()</code>&#xA0;ANR contention</li><li><a href="https://github.com/android/tuningfork/issues/42?ref=aditlal.dev">android/tuningfork #42</a>&#xA0;&#x2014; Native crash in pre-launch, not reproducible on dev devices</li><li><a href="https://issuetracker.google.com/issues/160907013?ref=aditlal.dev">Google Issue Tracker #160907013</a>&#xA0;&#x2014; Pre-launch report false positives</li></ul><p><strong>Engineering Blog Posts</strong></p><ul><li><a href="https://engineering.fb.com/2025/10/01/android/accelerating-our-android-apps-with-baseline-profiles/?ref=aditlal.dev">Meta: Accelerating Android Apps with Baseline Profiles</a>&#xA0;&#x2014; Production-scale profile infrastructure</li><li><a href="https://firebase.blog/posts/2016/12/how-does-firebase-initialize-on-android/?ref=aditlal.dev">Firebase Blog: How Does Firebase Initialize on Android?</a>&#xA0;&#x2014; ContentProvider mechanism (no race condition discussion)</li><li><a href="https://medium.com/androiddevelopers/mitigating-soft-verification-issues-in-r8-and-d8-7e9e06827dfd?ref=aditlal.dev">Android Developers: Mitigating Soft Verification Issues in R8 and D8</a>&#xA0;&#x2014; 22% degradation from soft verification</li><li><a href="https://medium.com/groupon-eng/android-s-multidex-slows-down-app-startup-d9f10b46770f?ref=aditlal.dev">Groupon: Android&apos;s Multidex Slows Down App Startup</a>&#xA0;&#x2014; ~10x interpreted slowdown documented</li></ul>]]></content:encoded></item><item><title><![CDATA[Building StickerExplode(Part 1): Gestures, physics, and making stickers feel real]]></title><description><![CDATA[<p><em>Part 1 of three. This one covers the gesture system, spring physics, peel-off animation, and die-cut rendering. </em><a href="https://aditlal.dev/stickerexplode-part-2/" rel="noreferrer"><em>Part 2</em></a><em> gets into the holographic shader, tilt sensing, haptics, and cross-platform architecture.</em></p><hr><p>I built a sticker canvas app. You slap stickers on a surface, drag them around, pinch to resize, rotate with</p>]]></description><link>https://aditlal.dev/building-stickerexplode-part-1-gestures-physics-and-making-stickers-feel-real/</link><guid isPermaLink="false">6999d5818b1b8505361c03fc</guid><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sat, 21 Feb 2026 16:30:00 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1753430708971-892048584cf3?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1753430708971-892048584cf3?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="Building StickerExplode(Part 1): Gestures, physics, and making stickers feel real"><p><em>Part 1 of three. This one covers the gesture system, spring physics, peel-off animation, and die-cut rendering. </em><a href="https://aditlal.dev/stickerexplode-part-2/" rel="noreferrer"><em>Part 2</em></a><em> gets into the holographic shader, tilt sensing, haptics, and cross-platform architecture.</em></p><hr><p>I built a sticker canvas app. You slap stickers on a surface, drag them around, pinch to resize, rotate with two fingers, and when you grab one it peels up like you&apos;re lifting real vinyl off a sheet. There&apos;s a holographic shimmer that responds to your phone&apos;s tilt, spring physics on everything, haptic feedback on every interaction.</p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/sticker_demo.gif" class="kg-image" alt="Building StickerExplode(Part 1): Gestures, physics, and making stickers feel real" loading="lazy" width="320" height="374"></figure><p>I was watching Apple&apos;s WWDC sticker segment and thinking about how good the peel-and-stick interactions felt. Then I saw this tweet by Daniel Korpai:</p><figure class="kg-card kg-embed-card"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Sticker Wall &#x2013; Playing with SwiftUI for the first time trying to recreate <a href="https://twitter.com/Apple?ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">@Apple</a> iMessage holo stickers &#x1F60D;<br><br>Huge thank you for <a href="https://twitter.com/philipcdavis?ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">@philipcdavis</a> for his incredible course, <a href="https://twitter.com/jmtrivedi?ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">@jmtrivedi</a> and <a href="https://twitter.com/alexwidua?ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">@alexwidua</a> for the open source projects and <a href="https://twitter.com/Gavmn?ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">@Gavmn</a> for the inspiring prototypes! &#x1F64C;&#x1F525; <a href="https://t.co/mYI9aoyd7q?ref=aditlal.dev">pic.twitter.com/mYI9aoyd7q</a></p>&#x2014; Daniel Korpai (@danielkorpai) <a href="https://twitter.com/danielkorpai/status/1786040516599030022?ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">May 2, 2024</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script></figure><p>And I thought: can I build that in Compose? Not a static grid. Something where stickers feel like physical objects you can grab, lift, and stick down. Shadows that grow as they rise. Shimmer when you tilt the phone.</p><p>It also felt like the right project to push Compose Multiplatform past the usual form-app demos. I wanted to hit the hard parts: platform sensors, native haptics, custom shaders, layered gestures. All shared between Android and iOS.</p><h2 id="what-it-actually-does">What it actually does</h2><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/sticker_tray.jpg" class="kg-image" alt="Building StickerExplode(Part 1): Gestures, physics, and making stickers feel real" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/sticker_tray.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/sticker_tray.jpg 1000w, https://aditlal.dev/content/images/2026/02/sticker_tray.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>Tap the + button, and a bottom sheet slides up with 16 stickers: Emoji, text, Canvas-drawn shapes, Material icons on gradient backgrounds. Pick one, and it drops onto the canvas at a random position with a slight rotation.</p><p>From there, you can drag with one finger, pinch to resize (0.5x to 3x), rotate with two fingers, tap to bring a sticker to the front, or double-tap for a bouncy 2x zoom. Grab and release to feel the peel-off with haptics.</p><p>Every sticker has a white die-cut border (like a real vinyl sticker), a dynamic drop shadow that grows when you lift it, and an iridescent holographic shimmer that shifts as you tilt your phone.</p><p>There&apos;s also a history screen that logs every sticker you&apos;ve placed, with relative timestamps. The entire canvas state (positions, rotations, scales, z-ordering, history) persists across app launches.</p><h2 id="tech-stack">Tech stack</h2><p>I kept the dependencies minimal:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Library</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td>Compose Multiplatform 1.7</td>
<td>UI across Android and iOS</td>
</tr>
<tr>
<td>Navigation Compose</td>
<td>Two screens: canvas and history</td>
</tr>
<tr>
<td>Lifecycle ViewModel</td>
<td>Shared state management with coroutine scope</td>
</tr>
<tr>
<td>DataStore Preferences</td>
<td>Persistence across launches</td>
</tr>
<tr>
<td>kotlinx.serialization</td>
<td>JSON for canvas state</td>
</tr>
<tr>
<td>Material 3</td>
<td>Bottom sheet, FABs, top bar, icons</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p>No image loading library. No third-party gesture library. No external animation framework. Every sticker is rendered with pure Compose: <code>Text</code> for emoji, <code>Canvas</code> for the Kotlin logo path, <code>Icon</code> in a gradient <code>Box</code> for the tool stickers. Zero image assets to manage.</p><h2 id="architecture-at-a-glance">Architecture at a glance</h2><p>The architecture is simple on purpose. One <code>CanvasViewModel</code> owns two <code>StateFlow</code>s, one for the sticker list and one for the history log. A <code>CanvasRepository</code> wraps DataStore for persistence. The view model loads saved state on init and debounce-saves after every change.</p><pre><code>commonMain/
  App.kt                    -- NavHost with two routes
  StickerCanvas.kt          -- Canvas composable with draggable stickers
  StickerTray.kt            -- Bottom sheet picker
  ShimmerGlow.kt            -- Holographic modifier node
  HistoryScreen.kt          -- History log
  model/                    -- StickerItem, StickerType, HistoryEntry
  data/                     -- CanvasRepository, DataStore factory
  viewmodel/                -- CanvasViewModel
  sensor/                   -- TiltSensor expect/actual
  haptics/                  -- HapticFeedback expect/actual

androidMain/                -- SensorManager, View haptics, AGSL shader
iosMain/                    -- CMMotionManager, UIKit haptics, fallback shader
</code></pre><p>Five <code>expect/actual</code> boundaries handle the platform differences:</p><ol><li>Tilt sensors (Android SensorManager vs iOS CMMotionManager)</li><li>Haptic feedback (Android View.performHapticFeedback vs iOS UIImpactFeedbackGenerator)</li><li>Holographic rendering (AGSL shader on Android 13+ vs ShaderBrush fallback)</li><li>DataStore file paths</li><li>System clock</li></ol><p>The common code never imports anything from Android or iOS. Each platform implementation is a thin wrapper around native APIs.</p><h2 id="the-sticker-data-model">The sticker data model</h2><p>Each sticker on the canvas is a data class:</p><pre><code class="language-kotlin">@Serializable
data class StickerItem(
    val id: Int,
    val type: StickerType,
    val initialFractionX: Float,
    val initialFractionY: Float,
    val rotation: Float = 0f,
    val offsetX: Float = Float.NaN,
    val offsetY: Float = Float.NaN,
    val pinchScale: Float = 1f,
    val zIndex: Float = 0f,
)
</code></pre><p>New stickers start with fractional coordinates (0..1 relative to canvas size) so the default layout works on any screen. Once you drag a sticker, pixel offsets take over. The composable checks for <code>Float.NaN</code> on first render to decide which positioning to use.</p><p>Z-ordering is a monotonically increasing counter. Tap a sticker and it gets the next value. Compose&apos;s <code>.zIndex()</code> modifier handles the rest.</p><h2 id="whats-coming-in-part-2">What&apos;s coming in Part 2</h2><p>Part 2 goes deep on two features: the peel-off grab (spring physics, dynamic shadows, layered gesture handling) and the holographic shimmer (thin-film optics, AGSL shader, cross-platform fallback).<br><br><a href="https://aditlal.dev/stickerexplode-part-2/" rel="noreferrer">Read Part 2: The peel-off effect and holographic shimmer</a></p><p></p><hr><p><em>Built with Kotlin 2.1 and Compose Multiplatform 1.7.</em></p>]]></content:encoded></item><item><title><![CDATA[Building StickerExplode(Part 2): The peel-off effect and holographic shimmer]]></title><description><![CDATA[<p><em>Part 2 of three. </em><a href="https://aditlal.dev/building-stickerexplode-part-1-gestures-physics-and-making-stickers-feel-real/" rel="noreferrer"><em>Part 1</em></a><em> covers what the app is and the tech stack. This one goes deep on two features. </em><a href="https://aditlal.dev/stickerexplode-part-3/" rel="noreferrer"><em>Part 3</em></a><em> is the full end-to-end build.</em></p><hr><p>Two things people ask about most: the peel-off grab (sticker lifts with a shadow when you touch it) and the holographic</p>]]></description><link>https://aditlal.dev/stickerexplode-part-2/</link><guid isPermaLink="false">6999db2e8b1b8505361c0436</guid><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sat, 21 Feb 2026 16:20:00 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1553544200-503cdda843bf?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1553544200-503cdda843bf?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="Building StickerExplode(Part 2): The peel-off effect and holographic shimmer"><p><em>Part 2 of three. </em><a href="https://aditlal.dev/building-stickerexplode-part-1-gestures-physics-and-making-stickers-feel-real/" rel="noreferrer"><em>Part 1</em></a><em> covers what the app is and the tech stack. This one goes deep on two features. </em><a href="https://aditlal.dev/stickerexplode-part-3/" rel="noreferrer"><em>Part 3</em></a><em> is the full end-to-end build.</em></p><hr><p>Two things people ask about most: the peel-off grab (sticker lifts with a shadow when you touch it) and the holographic shimmer (tilt your phone and stickers glimmer like foil cards). Both took more work than I expected.</p><h2 id="feature-1-the-peel-off-grab">Feature 1: The peel-off grab</h2><p>When you touch a sticker, I wanted it to feel like peeling vinyl off a sheet. That means several things have to happen at once.</p><h3 id="what-happens-when-you-grab-a-sticker">What happens when you grab a sticker</h3><p>Four properties animate simultaneously the moment your finger touches down:</p><ol><li>The sticker <strong>scales up to 1.08x</strong>. It&apos;s coming toward you.</li><li>It <strong>tilts -6 degrees on the X axis</strong>. The top edge lifts first, like peeling from the top.</li><li>It <strong>translates </strong>to<strong> 8 pixels</strong>. Physical lift.</li><li>The <strong>drop shadow grows larger and shifts downward</strong>. Farther from the surface means a bigger, softer shadow.</li></ol><p>When you release, everything reverses.<br><br>The gesture system: three pointerInput blocks</p><p>Each sticker needs drag, pinch, rotate, single-tap, and double-tap. Compose doesn&apos;t have one detector that does all of these, so each <code>DraggableSticker</code> composable chains three separate <code>pointerInput</code> blocks.</p><p>The first block handles drag, pinch, and rotate:</p><pre><code class="language-kotlin">.pointerInput(Unit) {
    detectTransformGestures { _, pan, zoom, gestureRotation -&gt;
        if (!isDragging) {
            isDragging = true
            haptics.perform(HapticType.LightTap)
        }
        pinchScale = (pinchScale * zoom).coerceIn(0.5f, 3f)
        rotation += gestureRotation
        offset += pan
    }
}
</code></pre><p><code>detectTransformGestures</code> gives you deltas each frame: how far the centroid moved (<code>pan</code>), how much fingers spread (<code>zoom</code> as a multiplier), and the angle change between two fingers (<code>gestureRotation</code>). The zoom multiplier means you multiply the existing scale, not set it. <code>1.1</code> means &quot;10% bigger than before.&quot;</p><p>The second block detects the drop (all fingers lifted):</p><pre><code class="language-kotlin">.pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.all { !it.pressed } &amp;&amp; isDragging) {
                isDragging = false
                haptics.perform(HapticType.MediumImpact)
                onTransformChanged(offset.x, offset.y, pinchScale, rotation)
            }
        }
    }
}
</code></pre><p>Why? <code>detectTransformGestures</code> fires during the gesture but has no <code>onGestureEnd</code> callback. This raw pointer watcher fills that gap. It checks if every pointer has lifted while we were dragging. That&apos;s the &quot;drop&quot; moment, which triggers the heavier haptic and persists the new position.</p><p>The third block handles taps:</p><pre><code class="language-kotlin">.pointerInput(Unit) {
    detectTapGestures(
        onTap = {
            haptics.perform(HapticType.SelectionClick)
            onTapped()
        },
        onDoubleTap = {
            haptics.perform(HapticType.MediumImpact)
            isZoomedIn = !isZoomedIn
        },
    )
}
</code></pre><p>Tap detection needs its own block because it watches for a quick down-then-up without movement. If you mixed it into the transform handler, every drag would also register as a tap.</p><p>Why don&apos;t these conflict? Each <code>pointerInput</code> block runs in its own coroutine. They process the same event stream concurrently. The transform handler waits for movement or a second finger before claiming the gesture. The tap handler waits for a quick lift. If you drag, transforms win. If you tap, taps win. Block 2 is passive: it never consumes events, just watches.</p><h3 id="spring-physics-why-not-tween">Spring physics: why not tween</h3><p>Every animation in the peel effect uses <code>spring()</code> instead of a duration-based <code>tween()</code>. The difference matters.</p><p>A <code>tween(300.millis)</code> has a fixed timeline. If you interrupt it (grab a sticker mid-bounce from a previous drop), the animation restarts awkwardly. Springs are velocity-aware. When interrupted, they pick up the current position and velocity and continue naturally. You can grab a bouncing sticker and it doesn&apos;t stutter.</p><p>Compose&apos;s <code>spring()</code> takes two parameters:</p><ul><li><code>dampingRatio</code>: below 1.0 = bouncy (undershoots target, oscillates). 1.0 = fastest path without overshoot. Above 1.0 = sluggish.</li><li><code>stiffness</code>: how fast it responds. Higher = snappier.</li></ul><p>The peel-off effect uses four springs with slightly different parameters:</p><pre><code class="language-kotlin">// Scale: bouncy pop
val peelScale by animateFloatAsState(
    targetValue = if (isDragging) 1.08f else 1f,
    animationSpec = spring(dampingRatio = 0.55f, stiffness = 300f),
)

// Tilt: slightly bouncier, slightly slower
val peelRotationX by animateFloatAsState(
    targetValue = if (isDragging) -6f else 0f,
    animationSpec = spring(dampingRatio = 0.5f, stiffness = 250f),
)

// Lift: less bouncy, smooth
val peelTranslateY by animateFloatAsState(
    targetValue = if (isDragging) -8f else 0f,
    animationSpec = spring(dampingRatio = 0.6f, stiffness = 300f),
)

// Shadow: nearly critically damped, no bounce
val liftFraction by animateFloatAsState(
    targetValue = if (isDragging) 1f else 0f,
    animationSpec = spring(dampingRatio = 0.7f, stiffness = 200f),
)
</code></pre><p>The staggered damping is intentional. Scale (0.55) overshoots more than tilt (0.50), which overshoots more than translate (0.60). They all start at the same instant but settle differently. The sticker pops up, then tilts, then the lift smooths out. If you&apos;re not looking for it you won&apos;t notice the stagger consciously, but the motion feels less robotic than if everything animated identically.</p><h3 id="applying-the-transforms">Applying the transforms</h3><p>These four values feed into <code>graphicsLayer</code>:</p><pre><code class="language-kotlin">.graphicsLayer {
    scaleX = combinedScale * peelScale
    scaleY = combinedScale * peelScale
    rotationZ = rotation           // user&apos;s pinch rotation
    rotationX = peelRotationX      // peel tilt
    translationY = peelTranslateY  // peel lift
    cameraDistance = 12f * density
}
</code></pre><p><code>cameraDistance = 12f * density</code> matters. <code>rotationX</code> tilts the sticker in 3D space. Without setting the camera distance, the default perspective makes the tilt look warped and flat. Pushing the camera back gives a subtler, more physical-looking tilt.</p><h3 id="the-dynamic-shadow">The dynamic shadow</h3><p>The shadow is what makes the peel effect actually convincing. It&apos;s driven by <code>liftFraction</code> (0 when resting, 1 when fully lifted):</p><pre><code class="language-kotlin">val shadowAlpha = 0.06f + liftFraction * 0.06f   // darker when lifted
val shadowSpread = outlinePx + (1.dp + liftFraction * 3.dp)  // wider when lifted
val shadowOffsetY = 1.dp + liftFraction * 3.dp    // shifts down when lifted
</code></pre><p>Real contact shadows work this way. An object resting on a surface casts a tight, dark shadow. Lift it and the shadow gets softer, wider, and offsets downward. The interpolation from <code>liftFraction</code> handles the transition smoothly.</p><hr><h2 id="feature-2-the-holographic-shimmer">Feature 2: The holographic shimmer</h2><p>Tilt your phone and every sticker glimmers with an iridescent effect. Building this meant understanding some actual optics.</p><h3 id="the-three-layers">The three layers</h3><p>Real holographic foil gets its look from three optical phenomena happening at once. I simulate all three and composite them.</p><p>First, thin-film iridescence. When light hits a thin transparent film (soap bubble, oil slick, holographic foil), some reflects off the top surface and some off the bottom. These two reflected waves interfere. At certain angles, specific wavelengths add up (constructive interference) and others cancel (destructive interference). Tilting the surface changes which wavelengths survive. That&apos;s why you see shifting rainbow bands.</p><p>In code, I approximate this with phase-shifted cosine waves:</p><pre><code class="language-glsl">vec3 iridescence(float t) {
    float phase = t * 6.2832;  // 2*PI
    return vec3(
        0.7 + 0.3 * cos(phase + 0.0),     // red
        0.7 + 0.3 * cos(phase + 2.094),    // green, 120&#xB0; offset
        0.75 + 0.25 * cos(phase + 4.189)   // blue, 240&#xB0; offset
    );
}
</code></pre><p>Each color channel peaks at a different value of <code>t</code>. As <code>t</code> varies across the sticker (driven by tilt and pixel position), you get a color sweep through lavender, sky blue, mint, cream, blush. The <code>0.7 + 0.3*cos()</code> range keeps everything pastel. Real holographic foil is desaturated, not neon.</p><p>Second, specular reflection. A bright spot where light bounces directly toward your eye. On holographic foil this is a white glint that slides around as you tilt.</p><pre><code class="language-glsl">float2 specCenter = float2(0.5 + roll * 0.4, 0.5 + pitch * 0.4);
float dist = distance(uv, specCenter);
float specular = exp(-dist * dist * 8.0);
</code></pre><p>Gaussian falloff centered on a tilt-driven point. Bright in the middle, fades smoothly.</p><p>Third, Fresnel edge glow. Surfaces are more reflective at grazing angles. Look straight down at water and you see through it. Look across at a shallow angle and it&apos;s a mirror. On stickers, the edges glow slightly brighter.</p><pre><code class="language-glsl">float edgeDist = distance(uv, float2(0.5, 0.5)) * 2.0;
float fresnel = pow(clamp(edgeDist, 0.0, 1.0), 2.5);
</code></pre><p>Distance from center approximates viewing angle. The <code>pow(_, 2.5)</code> exponent concentrates the effect at the edges.</p><h3 id="the-agsl-shader-android-13">The AGSL shader (Android 13+)</h3><p>On Android API 33+, all three layers run in one AGSL shader on the GPU:</p><pre><code class="language-glsl">uniform float2 resolution;
uniform float2 tilt;  // roll, pitch, each -1..1

half4 main(float2 fragCoord) {
    float2 uv = fragCoord / resolution;
    float roll = tilt.x;
    float pitch = tilt.y;
    float tiltMag = clamp(length(tilt), 0.0, 1.0);

    // Iridescent sweep, angle rotates with roll
    float angle = radians(45.0 + roll * 45.0);
    float2 dir = float2(cos(angle), sin(angle));
    float gradientT = dot(uv - 0.5, dir) + 0.5 + pitch * 0.3;
    vec3 iriColor = iridescence(gradientT);
    float iriAlpha = 0.12 + tiltMag * 0.06;

    // Specular glint, tracks tilt position
    float2 specCenter = float2(0.5 + roll * 0.4, 0.5 + pitch * 0.4);
    float dist = distance(uv, specCenter);
    float specular = exp(-dist * dist * 8.0);
    float specAlpha = specular * 0.25;

    // Fresnel edge glow
    float edgeDist = distance(uv, float2(0.5, 0.5)) * 2.0;
    float fresnel = pow(clamp(edgeDist, 0.0, 1.0), 2.5);
    float fresnelAlpha = fresnel * (0.03 + tiltMag * 0.06);

    // Composite
    vec3 color = iriColor * iriAlpha
               + vec3(1.0) * specAlpha
               + vec3(1.0) * fresnelAlpha;
    float alpha = iriAlpha + specAlpha + fresnelAlpha;

    return half4(half3(color), half(clamp(alpha, 0.0, 0.45)));
}
</code></pre><p>The <code>clamp(alpha, 0.0, 0.45)</code> cap prevents the overlay from washing out the sticker content. Where all three layers overlap, combined alpha could hit near 1.0 without the cap.</p><p>The gradient direction rotates with roll. At rest it runs diagonal (45 degrees). Tilt left and it goes horizontal. Tilt right and it goes vertical. <code>dot(uv - 0.5, dir)</code> projects pixel position onto the direction vector. <code>pitch * 0.3</code> shifts the gradient based on forward/back tilt.</p><h3 id="the-fallback-ios-older-android">The fallback (iOS + older Android)</h3><p>No AGSL on iOS or Android below 33. The fallback uses three Compose <code>ShaderBrush</code> subclasses that produce the same visual with <code>LinearGradientShader</code> and <code>RadialGradientShader</code>:</p><pre><code class="language-kotlin">class HolographicFallbackNode(
    override var tiltState: State&lt;TiltData&gt;,
) : HolographicBaseNode() {
    override fun ContentDrawScope.draw() {
        drawContent()

        val tilt = tiltState.value
        val roll = tilt.roll.coerceIn(-1f, 1f)
        val pitch = tilt.pitch.coerceIn(-1f, 1f)

        // Layer 1: Iridescent gradient
        drawRect(
            brush = IridescentBrush(angleDeg = 45f + roll * 45f,
                offset = Offset(roll * size.width * 0.4f, pitch * size.height * 0.4f)),
            alpha = 0.12f + tiltMagnitude * 0.06f,
            blendMode = BlendMode.SrcAtop,
        )

        // Layer 2: Specular glint
        drawRect(
            brush = SpecularBrush(0.5f + roll * 0.4f, 0.5f + pitch * 0.4f),
            alpha = 0.25f,
            blendMode = BlendMode.Screen,
        )

        // Layer 3: Fresnel edge glow
        drawRect(
            brush = FresnelBrush(intensity = fresnelAlpha * 2.5f),
            alpha = fresnelAlpha,
            blendMode = BlendMode.SrcAtop,
        )
    }
}
</code></pre><p>Three draw calls instead of one GPU pass. The AGSL version is smoother during fast tilts, but the fallback looks good in practice.</p><h3 id="how-the-shader-gets-wired-into-compose">How the shader gets wired into Compose</h3><p>The holographic effect is a single modifier call: <code>.holographicShine(tiltState)</code>. Under the hood it uses Compose&apos;s modifier node system:</p><pre><code class="language-kotlin">fun Modifier.holographicShine(tiltState: State&lt;TiltData&gt;): Modifier =
    this then HolographicShineElement(tiltState)

private data class HolographicShineElement(
    val tiltState: State&lt;TiltData&gt;,
) : ModifierNodeElement&lt;HolographicBaseNode&gt;() {
    override fun create(): HolographicBaseNode = createHolographicNode(tiltState)
    override fun update(node: HolographicBaseNode) {
        node.tiltState = tiltState
    }
}
</code></pre><p><code>createHolographicNode()</code> is an <code>expect/actual</code> function. Android returns the AGSL node on API 33+ and the fallback on older versions. iOS always returns the fallback.</p><p>The node reads <code>tiltState</code> during drawing. When the tilt value changes, Compose invalidates the draw pass for that node only. No recomposition of the composable tree. This matters because the tilt sensor fires 30 times per second on every sticker simultaneously.</p><hr><h2 id="next-up">Next up</h2><p>Part 3 covers everything else: the die-cut outline rendering technique, tilt sensor bridging across platforms, the haptic feedback system, DataStore persistence with debounced saves, and the full <code>expect/actual</code> architecture.</p><p><a href="https://aditlal.dev/stickerexplode-part-3" rel="noreferrer">Read Part 3: The full end-to-end build</a></p><hr><p><em>Built with Kotlin 2.1 and Compose Multiplatform 1.7.</em></p>]]></content:encoded></item><item><title><![CDATA[Building StickerExplode(Part 3): The full end-to-end build]]></title><description><![CDATA[<p><em>Part 3 of three. </em><a href="https://aditlal.dev/building-stickerexplode-part-1-gestures-physics-and-making-stickers-feel-real/" rel="noreferrer"><em>Part 1</em></a><em> covers what the app is. </em><a href="https://aditlal.dev/stickerexplode-part-2/" rel="noreferrer"><em>Part 2</em></a><em> goes deep on the peel-off effect and holographic shimmer. This one walks through everything else: die-cut rendering, tilt sensors, haptics, persistence, and the cross-platform architecture.</em></p><hr><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/sticker_demo-1.gif" class="kg-image" alt="StickerExplode demo" loading="lazy" width="320" height="374"></figure><p>Part 2 covered the peel-off grab and holographic shimmer. But a sticker</p>]]></description><link>https://aditlal.dev/stickerexplode-part-3/</link><guid isPermaLink="false">6999dc7f8b1b8505361c044c</guid><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sat, 21 Feb 2026 16:15:00 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1721244653493-29609ecaded9?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1721244653493-29609ecaded9?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="Building StickerExplode(Part 3): The full end-to-end build"><p><em>Part 3 of three. </em><a href="https://aditlal.dev/building-stickerexplode-part-1-gestures-physics-and-making-stickers-feel-real/" rel="noreferrer"><em>Part 1</em></a><em> covers what the app is. </em><a href="https://aditlal.dev/stickerexplode-part-2/" rel="noreferrer"><em>Part 2</em></a><em> goes deep on the peel-off effect and holographic shimmer. This one walks through everything else: die-cut rendering, tilt sensors, haptics, persistence, and the cross-platform architecture.</em></p><hr><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/sticker_demo-1.gif" class="kg-image" alt="Building StickerExplode(Part 3): The full end-to-end build" loading="lazy" width="320" height="374"></figure><p>Part 2 covered the peel-off grab and holographic shimmer. But a sticker canvas that feels complete needs more than two cool effects. Die-cut outlines that work on any shape. Tilt sensors bridged across platforms. Haptic feedback mapped to the right gestures. Canvas state that survives app restarts. All wired together through five <code>expect/actual</code> boundaries.</p><p>This post is the rest of the build.</p><h2 id="die-cut-outlines-the-stamp-technique">Die-cut outlines: the stamp technique</h2><p>Real vinyl stickers have a white border where they&apos;re cut from the backing sheet. In StickerExplode, every sticker gets this treatment. The challenge: stickers aren&apos;t simple rectangles. They&apos;re emoji, Canvas-drawn paths, icons. Arbitrary shapes.</p><h3 id="how-it-works">How it works</h3><p>The <code>stickerCutout</code> modifier uses <code>drawWithContent</code> to draw the content multiple times with different effects layered underneath:</p><ol><li>Draw the content 16 times, each offset in a different direction around a circle, all tinted black. This creates the shadow.</li><li>Draw the content 16 more times, offset around a circle at a smaller radius, all tinted white. This creates the outline.</li><li>Draw the actual content on top.</li></ol><pre><code class="language-kotlin">private fun Modifier.stickerCutout(
    outlineWidth: Dp = 3.dp,
    liftFraction: Float = 0f,
) = this.drawWithContent {
    val outlinePx = outlineWidth.toPx()
    val pad = outlinePx * 3
    val layerBounds = Rect(-pad, -pad, size.width + pad, size.height + pad)

    // Shadow properties change with lift
    val shadowAlpha = 0.06f + liftFraction * 0.06f
    val shadowSpread = outlinePx + (1.dp.toPx() + liftFraction * 3.dp.toPx())
    val shadowOffsetY = 1.dp.toPx() + liftFraction * 3.dp.toPx()

    // Shadow: 16 copies, black tint
    val shadowPaint = Paint().apply {
        colorFilter = ColorFilter.tint(
            Color.Black.copy(alpha = shadowAlpha / 2f), BlendMode.SrcIn
        )
    }
    for (i in 0 until 16) {
        val angle = (2.0 * PI * i / 16).toFloat()
        val dx = shadowSpread * cos(angle)
        val dy = shadowSpread * sin(angle) + shadowOffsetY
        drawIntoCanvas { canvas -&gt;
            canvas.save()
            canvas.translate(dx, dy)
            canvas.saveLayer(layerBounds, shadowPaint)
        }
        drawContent()
        drawIntoCanvas { canvas -&gt;
            canvas.restore()
            canvas.restore()
        }
    }

    // White outline: 16 copies, white tint
    val whitePaint = Paint().apply {
        colorFilter = ColorFilter.tint(Color.White, BlendMode.SrcIn)
    }
    for (i in 0 until 16) {
        val angle = (2.0 * PI * i / 16).toFloat()
        val dx = outlinePx * cos(angle)
        val dy = outlinePx * sin(angle)
        drawIntoCanvas { canvas -&gt;
            canvas.save()
            canvas.translate(dx, dy)
            canvas.saveLayer(layerBounds, whitePaint)
        }
        drawContent()
        drawIntoCanvas { canvas -&gt;
            canvas.restore()
            canvas.restore()
        }
    }

    drawContent()
}
</code></pre><h3 id="why-16-copies">Why 16 copies</h3><p>With <code>n</code> copies evenly spaced at radius <code>r</code>, the maximum gap between adjacent stamps is <code>2r * sin(PI/n)</code>. At 16 copies and 3dp radius:</p><pre><code>gap = 2 * 3dp * sin(PI/16) &#x2248; 1.17dp
</code></pre><p>Sub-pixel on any phone screen. The outline looks perfectly smooth. 8 copies leaves visible scalloping at the corners. 32 looks the same as 16 but doubles the draw calls.</p><h3 id="blendmodesrcin">BlendMode.SrcIn</h3><p><code>BlendMode.SrcIn</code> is why this works on arbitrary shapes. <code>ColorFilter.tint(Color.White, BlendMode.SrcIn)</code> replaces every opaque pixel with white while preserving the alpha channel. Transparent areas stay transparent. The white stamp exactly matches the shape of whatever content you drew, whether it&apos;s a heart emoji, the Kotlin logo path, or a gradient-filled rounded rectangle.</p><p>Same trick for the shadow: tint with semi-transparent black instead of white.</p><h3 id="dynamic-shadow-tied-to-lift">Dynamic shadow tied to lift</h3><p>The shadow parameters interpolate with <code>liftFraction</code> from the peel-off effect (covered in Part 2). Resting stickers have a tight, faint shadow. Lifted stickers have a wider, darker shadow offset further down. This connects the die-cut rendering to the drag interaction. They&apos;re two separate systems but <code>liftFraction</code> ties them together.</p><h2 id="tilt-sensing-across-platforms">Tilt sensing across platforms</h2><p>The holographic shimmer (Part 2) needs real-time tilt data from the device&apos;s motion sensors. Android and iOS have completely different sensor APIs.</p><h3 id="the-common-interface">The common interface</h3><pre><code class="language-kotlin">data class TiltData(val pitch: Float = 0f, val roll: Float = 0f)

expect class TiltSensorProvider {
    fun start(callback: (TiltData) -&gt; Unit)
    fun stop()
}

@Composable
expect fun rememberTiltSensorProvider(): TiltSensorProvider
</code></pre><p>Both <code>pitch</code> and <code>roll</code> are normalized to [-1, 1], where the extremes are 90-degree tilts. The holographic shader only sees this normalized data. It doesn&apos;t know or care which platform it&apos;s on.</p><h3 id="android-sensormanager">Android: SensorManager</h3><pre><code class="language-kotlin">actual class TiltSensorProvider(private val context: Context) {
    actual fun start(callback: (TiltData) -&gt; Unit) {
        val sm = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
        val sensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) ?: return

        val rotationMatrix = FloatArray(9)
        val orientation = FloatArray(3)

        listener = object : SensorEventListener {
            override fun onSensorChanged(event: SensorEvent) {
                SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
                SensorManager.getOrientation(rotationMatrix, orientation)
                val pitch = (orientation[1] / (PI / 2.0)).toFloat().coerceIn(-1f, 1f)
                val roll = (orientation[2] / (PI / 2.0)).toFloat().coerceIn(-1f, 1f)
                callback(TiltData(pitch, roll))
            }
            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
        }
        sm.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME)
    }

    actual fun stop() {
        listener?.let { sensorManager?.unregisterListener(it) }
    }
}
</code></pre><p><code>TYPE_ROTATION_VECTOR</code> is a fusion sensor. Android combines accelerometer, gyroscope, and magnetometer data internally. Much more stable than reading raw accelerometer values. <code>SENSOR_DELAY_GAME</code> (~20ms) is fast enough for smooth animation without burning the battery.</p><h3 id="ios-cmmotionmanager">iOS: CMMotionManager</h3><pre><code class="language-kotlin">actual class TiltSensorProvider {
    private val motionManager = CMMotionManager()

    actual fun start(callback: (TiltData) -&gt; Unit) {
        if (!motionManager.isDeviceMotionAvailable()) return
        motionManager.deviceMotionUpdateInterval = 1.0 / 30.0
        motionManager.startDeviceMotionUpdatesToQueue(
            NSOperationQueue.mainQueue
        ) { motion, _ -&gt;
            motion?.let {
                val pitch = (it.attitude.pitch / (PI / 2.0)).toFloat().coerceIn(-1f, 1f)
                val roll = (it.attitude.roll / (PI / 2.0)).toFloat().coerceIn(-1f, 1f)
                callback(TiltData(pitch, roll))
            }
        }
    }

    actual fun stop() {
        motionManager.stopDeviceMotionUpdates()
    }
}
</code></pre><p>Core Motion gives Euler angles in radians. Dividing by <code>PI/2</code> normalizes to [-1, 1]. 30 Hz update rate.</p><h3 id="spring-smoothed-sensor-data">Spring-smoothed sensor data</h3><p>Raw sensor readings are jittery. Instead of writing a manual low-pass filter, I feed the values into Compose&apos;s spring animation:</p><pre><code class="language-kotlin">@Composable
fun rememberTiltState(): State&lt;TiltData&gt; {
    val provider = rememberTiltSensorProvider()
    var rawPitch by remember { mutableStateOf(0f) }
    var rawRoll by remember { mutableStateOf(0f) }

    DisposableEffect(provider) {
        provider.start { data -&gt;
            rawPitch = data.pitch
            rawRoll = data.roll
        }
        onDispose { provider.stop() }
    }

    val smoothPitch by animateFloatAsState(
        targetValue = rawPitch,
        animationSpec = spring(dampingRatio = 0.8f, stiffness = 200f),
    )
    val smoothRoll by animateFloatAsState(
        targetValue = rawRoll,
        animationSpec = spring(dampingRatio = 0.8f, stiffness = 200f),
    )

    return remember {
        derivedStateOf { TiltData(smoothPitch, smoothRoll) }
    }
}
</code></pre><p>This works because <code>animateFloatAsState</code> continuously animates toward its target. When sensor readings jump, the spring absorbs the noise. Damping of 0.8 (nearly critically damped) tracks the actual tilt closely without oscillating. Stiffness of 200 keeps latency to about 50ms, which is imperceptible.</p><p>A damped harmonic oscillator is actually what a low-pass filter approximates anyway. The spring spec just lets you tune it with two intuitive parameters instead of figuring out cutoff frequencies.</p><p><code>derivedStateOf</code> wraps the output so the shader sees a single <code>State&lt;TiltData&gt;</code> that updates smoothly.</p><h2 id="haptic-feedback">Haptic feedback</h2><p>Every interaction has a corresponding haptic. The common code defines four types:</p><pre><code class="language-kotlin">enum class HapticType {
    LightTap,       // grab a sticker
    MediumImpact,   // drop a sticker, double-tap zoom
    HeavyImpact,    // reserved for future use
    SelectionClick, // tap to bring forward, open tray, pick from tray
}

expect class HapticFeedbackProvider {
    fun perform(type: HapticType)
}
</code></pre><h3 id="android-implementation">Android implementation</h3><pre><code class="language-kotlin">actual class HapticFeedbackProvider(private val view: View) {
    actual fun perform(type: HapticType) {
        val constant = when (type) {
            HapticType.LightTap -&gt; HapticFeedbackConstants.CLOCK_TICK
            HapticType.MediumImpact -&gt; HapticFeedbackConstants.CONFIRM
            HapticType.HeavyImpact -&gt; HapticFeedbackConstants.LONG_PRESS
            HapticType.SelectionClick -&gt; {
                if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
                    HapticFeedbackConstants.GESTURE_START
                } else {
                    HapticFeedbackConstants.CONTEXT_CLICK
                }
            }
        }
        view.performHapticFeedback(constant)
    }
}
</code></pre><p>Uses <code>View.performHapticFeedback()</code>, which respects the user&apos;s system haptic settings. On API 34+, <code>GESTURE_START</code> gives a crisper click for selection actions.</p><h3 id="ios-implementation">iOS implementation</h3><pre><code class="language-kotlin">actual class HapticFeedbackProvider {
    private val lightGenerator = UIImpactFeedbackGenerator(
        style = UIImpactFeedbackStyle.UIImpactFeedbackStyleLight)
    private val mediumGenerator = UIImpactFeedbackGenerator(
        style = UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium)
    private val heavyGenerator = UIImpactFeedbackGenerator(
        style = UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy)
    private val selectionGenerator = UISelectionFeedbackGenerator()

    actual fun perform(type: HapticType) {
        when (type) {
            HapticType.LightTap -&gt; lightGenerator.impactOccurred()
            HapticType.MediumImpact -&gt; mediumGenerator.impactOccurred()
            HapticType.HeavyImpact -&gt; heavyGenerator.impactOccurred()
            HapticType.SelectionClick -&gt; selectionGenerator.selectionChanged()
        }
    }
}
</code></pre><p>Generators are pre-allocated. Apple&apos;s docs recommend this to avoid latency on first trigger.</p><h3 id="why-the-mapping-matters">Why the mapping matters</h3><p>The grab/drop pairing is the most important one. Light haptic on grab, medium on drop. It creates a physical narrative: you picked something up (light touch) and put it down (heavier thud). This is the same principle iOS uses for its own drag-and-drop haptics.</p><p>Selection clicks are for UI actions (tap to front, open tray, pick a sticker). They feel distinct from impact haptics, which are for physical interactions. Mixing them up makes the app feel wrong even if you can&apos;t articulate why.</p><h2 id="state-persistence">State persistence</h2><p>The entire canvas state persists across launches. Every sticker position, rotation, scale, z-index, the complete history log, and the ID/z counters.</p><h3 id="the-repository">The repository</h3><pre><code class="language-kotlin">@Serializable
data class CanvasState(
    val stickers: List&lt;StickerItem&gt; = emptyList(),
    val history: List&lt;HistoryEntry&gt; = emptyList(),
    val nextId: Int = 0,
    val zCounter: Int = 0,
)

class CanvasRepository(private val dataStore: DataStore&lt;Preferences&gt;) {
    private val json = Json { ignoreUnknownKeys = true }

    companion object {
        private val CANVAS_STATE_KEY = stringPreferencesKey(&quot;canvas_state&quot;)
    }

    suspend fun loadCanvasState(): CanvasState? {
        val prefs = dataStore.data.first()
        val raw = prefs[CANVAS_STATE_KEY] ?: return null
        return try {
            json.decodeFromString&lt;CanvasState&gt;(raw)
        } catch (_: Exception) { null }
    }

    suspend fun saveCanvasState(state: CanvasState) {
        dataStore.edit { prefs -&gt;
            prefs[CANVAS_STATE_KEY] = json.encodeToString(state)
        }
    }
}
</code></pre><p>Everything serialized as a single JSON string in one DataStore key. Not the most efficient storage format, but simple and debuggable (you can read the raw JSON if something goes wrong).</p><p><code>ignoreUnknownKeys = true</code> on the JSON config is forward-compatibility insurance. If I add a new field to <code>StickerItem</code> in a future version, old persisted data still loads. Unknown keys get skipped, new fields get their default values.</p><h3 id="debounced-saves">Debounced saves</h3><p>During a drag gesture, <code>updateStickerTransform</code> fires every frame (60 times per second). Serializing the full canvas state on every frame would thrash DataStore. Instead, saves are debounced with a 500ms delay:</p><pre><code class="language-kotlin">private var saveJob: Job? = null

private fun debouncedSave() {
    saveJob?.cancel()
    saveJob = viewModelScope.launch {
        delay(500)
        repository.saveCanvasState(
            CanvasState(
                stickers = _stickers.value,
                history = _history.value,
                nextId = nextId,
                zCounter = zCounter,
            )
        )
    }
}
</code></pre><p>Every call cancels the previous pending save and schedules a new one. The actual write only happens when the user stops interacting for 500ms or drops the sticker (which also calls <code>debouncedSave</code>).</p><h3 id="platform-specific-datastore-paths">Platform-specific DataStore paths</h3><p>DataStore needs a file path, which is platform-dependent.</p><pre><code class="language-kotlin">// commonMain
const val DATA_STORE_FILE_NAME = &quot;sticker_explode_prefs.preferences_pb&quot;

fun createDataStore(producePath: () -&gt; String): DataStore&lt;Preferences&gt; =
    PreferenceDataStoreFactory.createWithPath(
        produceFile = { producePath().toPath() }
    )

expect fun createPlatformDataStore(): DataStore&lt;Preferences&gt;
</code></pre><p>Android resolves the path from <code>Activity.filesDir</code>:</p><pre><code class="language-kotlin">// androidMain
private lateinit var appDataStore: DataStore&lt;Preferences&gt;

fun initDataStore(filesDir: String) {
    if (!::appDataStore.isInitialized) {
        appDataStore = createDataStore { &quot;$filesDir/$DATA_STORE_FILE_NAME&quot; }
    }
}

actual fun createPlatformDataStore(): DataStore&lt;Preferences&gt; = appDataStore
</code></pre><p>iOS resolves from <code>NSDocumentDirectory</code>:</p><pre><code class="language-kotlin">// iosMain
actual fun createPlatformDataStore(): DataStore&lt;Preferences&gt; {
    val directory = NSFileManager.defaultManager.URLForDirectory(
        directory = NSDocumentDirectory,
        inDomain = NSUserDomainMask,
        appropriateForURL = null,
        create = false,
        error = null,
    )!!.path!!
    return createDataStore { &quot;$directory/$DATA_STORE_FILE_NAME&quot; }
}
</code></pre><p>Android needs the extra <code>initDataStore()</code> step because <code>filesDir</code> comes from the Activity context. It gets called in <code>MainActivity.onCreate()</code>. iOS can resolve the documents directory statically.</p><h2 id="the-sticker-tray">The sticker tray</h2><p>The picker is a Material 3 <code>ModalBottomSheet</code> with a <code>LazyVerticalGrid</code> of all 16 sticker types.</p><figure class="kg-card kg-image-card"><img src="https://aditlal.dev/content/images/2026/02/sticker_tray-1.jpg" class="kg-image" alt="Building StickerExplode(Part 3): The full end-to-end build" loading="lazy" width="1080" height="2424" srcset="https://aditlal.dev/content/images/size/w600/2026/02/sticker_tray-1.jpg 600w, https://aditlal.dev/content/images/size/w1000/2026/02/sticker_tray-1.jpg 1000w, https://aditlal.dev/content/images/2026/02/sticker_tray-1.jpg 1080w" sizes="(min-width: 720px) 720px"></figure><p>Each grid item has a press animation:</p><pre><code class="language-kotlin">val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
    targetValue = if (isPressed) 0.85f else 1f,
    animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
)
val bgColor by animateColorAsState(
    targetValue = if (isPressed) Color(0xFFE8E8FF) else Color(0xFFF5F5FA),
)
</code></pre><p>Squish to 85% on press, spring back on release. Background tints purple at the same time. High stiffness (400) makes it snappy.</p><p>Each tray item renders the same <code>StickerVisual</code> composable used on the canvas, without the die-cut outline or holographic effect. What you see in the tray is what ends up on the canvas.</p><h2 id="the-viewmodel">The ViewModel</h2><p><code>CanvasViewModel</code> owns two <code>StateFlow</code>s and handles all mutations:</p><pre><code class="language-kotlin">class CanvasViewModel(private val repository: CanvasRepository) : ViewModel() {

    private val _stickers = MutableStateFlow&lt;List&lt;StickerItem&gt;&gt;(emptyList())
    val stickers: StateFlow&lt;List&lt;StickerItem&gt;&gt; = _stickers.asStateFlow()

    private val _history = MutableStateFlow&lt;List&lt;HistoryEntry&gt;&gt;(emptyList())
    val history: StateFlow&lt;List&lt;HistoryEntry&gt;&gt; = _history.asStateFlow()

    private var nextId = 0
    private var zCounter = 0

    init {
        viewModelScope.launch {
            val saved = repository.loadCanvasState()
            if (saved != null &amp;&amp; saved.stickers.isNotEmpty()) {
                _stickers.value = saved.stickers
                _history.value = saved.history
                nextId = saved.nextId
                zCounter = saved.zCounter
            } else {
                _stickers.value = defaultStickers
                nextId = defaultStickers.size
            }
        }
    }

    fun addSticker(type: StickerType) {
        val id = nextId++
        zCounter++
        val sticker = StickerItem(
            id = id,
            type = type,
            initialFractionX = 0.15f + Random.nextFloat() * 0.5f,
            initialFractionY = 0.2f + Random.nextFloat() * 0.4f,
            rotation = -15f + Random.nextFloat() * 30f,
            zIndex = zCounter.toFloat(),
        )
        _stickers.value = _stickers.value + sticker
        _history.value = _history.value + HistoryEntry(
            stickerType = type,
            timestampMillis = currentTimeMillis(),
        )
        debouncedSave()
    }

    fun updateStickerTransform(
        id: Int, offsetX: Float, offsetY: Float, scale: Float, rotation: Float,
    ) {
        _stickers.value = _stickers.value.map { s -&gt;
            if (s.id == id) s.copy(
                offsetX = offsetX, offsetY = offsetY,
                pinchScale = scale, rotation = rotation,
            ) else s
        }
        debouncedSave()
    }

    fun bringToFront(id: Int) {
        zCounter++
        _stickers.value = _stickers.value.map { s -&gt;
            if (s.id == id) s.copy(zIndex = zCounter.toFloat()) else s
        }
        debouncedSave()
    }
}
</code></pre><p>New stickers get random positions within the center area of the canvas (<code>0.15..0.65</code> horizontal, <code>0.2..0.6</code> vertical) and random rotation between -15 and +15 degrees. This scatters them naturally instead of stacking them on top of each other.</p><p>Every mutation calls <code>debouncedSave()</code>. The view model doesn&apos;t know when or if the save actually happens. It just signals intent and the debounce logic handles the rest.</p><h2 id="the-five-expectactual-boundaries">The five expect/actual boundaries</h2><p>Stepping back, the full project has five places where common code delegates to platform code:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Boundary</th>
<th>Common type</th>
<th>Android</th>
<th>iOS</th>
</tr>
</thead>
<tbody>
<tr>
<td>Tilt sensor</td>
<td><code>TiltSensorProvider</code></td>
<td><code>SensorManager</code> + <code>TYPE_ROTATION_VECTOR</code></td>
<td><code>CMMotionManager</code></td>
</tr>
<tr>
<td>Haptics</td>
<td><code>HapticFeedbackProvider</code></td>
<td><code>View.performHapticFeedback()</code></td>
<td><code>UIImpactFeedbackGenerator</code></td>
</tr>
<tr>
<td>Holographic renderer</td>
<td><code>HolographicBaseNode</code></td>
<td>AGSL <code>RuntimeShader</code> (API 33+) or fallback</td>
<td>Fallback only</td>
</tr>
<tr>
<td>DataStore path</td>
<td><code>createPlatformDataStore()</code></td>
<td><code>Activity.filesDir</code></td>
<td><code>NSDocumentDirectory</code></td>
</tr>
<tr>
<td>System clock</td>
<td><code>currentTimeMillis()</code></td>
<td><code>System.currentTimeMillis()</code></td>
<td><code>NSDate().timeIntervalSince1970 * 1000</code></td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<p>Each boundary is a narrow interface. The common code defines what it needs (normalized tilt data, a <code>perform(HapticType)</code> function, a <code>DataStore</code> instance) and doesn&apos;t care how it&apos;s implemented. The platform code is a thin wrapper around native APIs.</p><p>None of these boundaries leaked during development. I never had to import an Android or iOS type in common code, and I never needed to pass platform context through the UI layer. The <code>rememberTiltSensorProvider()</code> and <code>rememberHapticFeedback()</code> composables handle context injection on each platform.</p><h2 id="what-id-change">What I&apos;d change</h2><p>Modifier nodes were worth the boilerplate. The holographic shimmer redraws 30 times per second on every sticker. <code>Modifier.composed {}</code> would have triggered recomposition each time. Modifier nodes run <code>draw()</code> directly in the draw pass. If I&apos;d used the older API I think I would have hit performance problems pretty quickly.</p><p>I was about to write a manual low-pass filter for the tilt sensors before realizing that a nearly-critically-damped spring does the same thing. Ended up using springs for physics simulation, UI feedback, sensor smoothing, and state transitions. Two parameters, covers everything. They also handle interruption without any special state machines, which I didn&apos;t appreciate until I started grabbing stickers mid-bounce.</p><p>The ShaderBrush fallback works on iOS but the AGSL version on Android is noticeably smoother during fast tilts. If I start this over I&apos;d look at Metal shaders for iOS.</p><p>There&apos;s also no undo/redo. The debounced save handles persistence fine but if you accidentally drag a sticker off screen, tough luck. A command stack would fix that.</p><hr><p>The <a href="https://github.com/aldefy/StickerExplode?ref=aditlal.dev" rel="noreferrer">full project</a> is about 800 lines of shared Compose code across commonMain. MIT licensed.</p><p><em>Built with Kotlin 2.1 and Compose Multiplatform 1.7. Tested on Pixel 8 Pro and iPhone 15 Pro.</em></p><p><a href="https://aditlal.dev/building-stickerexplode-part-1-gestures-physics-and-making-stickers-feel-real/" rel="noreferrer"><em>Part 1</em></a><em> | </em><a href="https://aditlal.dev/stickerexplode-part-2/" rel="noreferrer"><em>Part 2</em></a><em> | Part 3</em></p>]]></content:encoded></item><item><title><![CDATA[Introducing Lumen: Transparent Coachmarks for Jetpack Compose]]></title><description><![CDATA[Open source Jetpack Compose coachmark library with real transparent cutouts, 5 shapes, 6 animations, and multi-step onboarding. No screenshot hacks.]]></description><link>https://aditlal.dev/introducing-lumen-transparent-coachmarks-for-jetpack-compose/</link><guid isPermaLink="false">69837c532e24034344d3770a</guid><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Wed, 04 Feb 2026 17:18:44 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1453090927415-5f45085b65c0?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDExfHxzcG90bGlnaHR8ZW58MHx8fHwxNzcwMjI1Mjg1fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1453090927415-5f45085b65c0?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDExfHxzcG90bGlnaHR8ZW58MHx8fHwxNzcwMjI1Mjg1fDA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="Introducing Lumen: Transparent Coachmarks for Jetpack Compose"><p>Every coachmark library I tried felt incomplete&#x2014;not customizable enough for what I needed.</p>
<p>Screenshot-based overlays that feel disconnected. Solid scrims that hide the actual UI. Z-index battles that never end.</p>
<p>I saw the gap. <strong>So I built Lumen.</strong></p>
<p><em>Notice how the FAB remains tappable through the overlay:</em></p>
<figure class="kg-card kg-video-card">
<video src="/content/images/2025/02/Screen_recording_20260204_225107.mp4" poster="/content/images/2025/02/Screenshot_20260204_225051.png" controls playsinline preload="metadata"></video>
</figure>
<p><strong>Lumen</strong> renders real transparent cutouts in Jetpack Compose. Your buttons pulse. Your animations play. Your UI <em>breathes</em>&#x2014;all visible through the spotlight.</p>
<h2 id="what-makes-lumen-different">What Makes Lumen Different</h2>
<h3 id="genuine-transparent-cutouts">Genuine Transparent Cutouts</h3>
<p>No screenshots. No faking it. Lumen renders real transparent regions in its scrim overlay.</p>
<h3 id="five-cutout-shapes">Five Cutout Shapes</h3>
<table>
<thead>
<tr>
<th>Shape</th>
<th>Use Case</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Circle</code></td>
<td>FABs, icon buttons</td>
</tr>
<tr>
<td><code>RoundedRect</code></td>
<td>Cards, text fields</td>
</tr>
<tr>
<td><code>Squircle</code></td>
<td>iOS-style, modern apps</td>
</tr>
<tr>
<td><code>Star</code></td>
<td>Gamification, achievements</td>
</tr>
<tr>
<td><code>Rect</code></td>
<td>Full-width elements</td>
</tr>
</tbody>
</table>
<h3 id="six-highlight-animations">Six Highlight Animations</h3>
<p><code>Pulse</code> &#xB7; <code>Glow</code> &#xB7; <code>Ripple</code> &#xB7; <code>Shimmer</code> &#xB7; <code>Bounce</code> &#xB7; <code>None</code></p>
<h3 id="multi-step-sequences">Multi-Step Sequences</h3>
<p>Build complete onboarding flows with progress indicators. Users navigate forward and back at their own pace.</p>
<h3 id="dialog-coordination">Dialog Coordination</h3>
<p>Lumen automatically dismisses coachmarks when dialogs appear&#x2014;no awkward z-index battles.</p>
<h2 id="the-api">The API</h2>
<p>Three steps. That&apos;s it.</p>
<p><strong>1. Create a controller</strong></p>
<pre><code class="language-kotlin">val controller = rememberCoachmarkController()
</code></pre>
<p><strong>2. Tag your target</strong></p>
<pre><code class="language-kotlin">CoachmarkHost(controller = controller) {
    IconButton(
        onClick = { /* ... */ },
        modifier = Modifier.coachmarkTarget(controller, &quot;settings&quot;)
    ) {
        Icon(Icons.Default.Settings, &quot;Settings&quot;)
    }
}
</code></pre>
<p><strong>3. Show the coachmark</strong></p>
<pre><code class="language-kotlin">controller.show(
    CoachmarkTarget(
        id = &quot;settings&quot;,
        title = &quot;Settings&quot;,
        description = &quot;Customize your preferences here.&quot;,
        shape = CutoutShape.Circle(),
    )
)
</code></pre>
<h2 id="what-you-can-build">What You Can Build</h2>
<table>
<thead>
<tr>
<th>Single Spotlight</th>
<th>Multi-Step Tours</th>
</tr>
</thead>
<tbody>
<tr>
<td><img src="https://raw.githubusercontent.com/aldefy/Lumen/main/demo_basic.png" alt="Introducing Lumen: Transparent Coachmarks for Jetpack Compose" style="max-width:100%"></td>
<td><img src="https://raw.githubusercontent.com/aldefy/Lumen/main/demo_sequence.png" alt="Introducing Lumen: Transparent Coachmarks for Jetpack Compose" style="max-width:100%"></td>
</tr>
<tr>
<td>Highlight a FAB with pulse animation</td>
<td>Full onboarding with progress dots</td>
</tr>
</tbody>
</table>
<p>The <a href="https://github.com/aldefy/Lumen/tree/main/sample?ref=aditlal.dev">sample app</a> includes 11 interactive demos covering animations, connectors, theming, LazyColumn support, and dialog coordination.</p>
<h2 id="get-started">Get Started</h2>
<pre><code class="language-gradle">implementation(&quot;io.github.aldefy:lumen:1.0.0-beta01&quot;)
</code></pre>
<p>Open source under Apache 2.0. Available on Maven Central.</p>
<p><strong><a href="https://github.com/aldefy/Lumen?ref=aditlal.dev">GitHub</a></strong> &#xB7; <strong><a href="https://aldefy.github.io/Lumen/?ref=aditlal.dev">Docs</a></strong> &#xB7; <strong><a href="https://github.com/aldefy/Lumen/tree/main/sample?ref=aditlal.dev">Sample App</a></strong></p>]]></content:encoded></item><item><title><![CDATA[Compose Performance Bottlenecks: Anti-Patterns That Ship Bugs]]></title><description><![CDATA[Common Jetpack Compose performance mistakes that cause jank and unnecessary recomposition. Learn to identify and fix composition bottlenecks in Android.]]></description><link>https://aditlal.dev/compose-bottleneck-antipatterns-performance/</link><guid isPermaLink="false">829594572bbd7ceae93a114b</guid><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sat, 17 Jan 2026 10:02:35 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200" medium="image"/><content:encoded><![CDATA[
<img src="https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200" alt="Compose Performance Bottlenecks: Anti-Patterns That Ship Bugs"><p>Jetpack Compose is elegant. It&apos;s also a landmine.</p>

<p>The same reactive model that makes Compose declarative can silently swallow your coroutines, fire effects multiple times, and leave users staring at frozen spinners. I&apos;ve seen these bugs ship to production&#x2014;in my own apps and in code reviews across teams.</p>

<p>At Droidcon India 2025, I presented a talk on these patterns. This post goes deeper: the actual bugs, why Compose behaves this way internally, and how we fixed them in production at Equal AI.</p>

<h2>The OTP That Never Verified: LaunchedEffect Self-Cancellation</h2>

<p>This bug cost us a week of debugging. Users reported that SMS auto-fill &quot;didn&apos;t work&quot;&#x2014;the OTP would populate, but verification never happened. The spinner just spun forever.</p>

<p>Here&apos;s the code that shipped:</p>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #569cd6;">var</span> wasAutoFilled <span style="color: #569cd6;">by</span> remember { mutableStateOf(<span style="color: #569cd6;">false</span>) }

LaunchedEffect(wasAutoFilled) {
    <span style="color: #c586c0;">if</span> (!wasAutoFilled) <span style="color: #c586c0;">return</span>@LaunchedEffect

    wasAutoFilled = <span style="color: #569cd6;">false</span>   <span style="color: #6a9955;">// Reset for next time</span>
    delay(<span style="color: #b5cea8;">300</span>)              <span style="color: #6a9955;">// Small delay to feel natural</span>
    onVerifyOTP(otpCode)    <span style="color: #6a9955;">// Verify the OTP</span>
    isVerifying = <span style="color: #569cd6;">false</span>     <span style="color: #6a9955;">// Hide spinner</span>
}</code></pre>

<p><strong>What we expected:</strong></p>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 16px 20px; background: #f8f9fa; border-radius: 6px; margin: 16px 0; font-family: -apple-system, system-ui, sans-serif; font-size: 14px;">
  <span style="background: #343a40; color: #fff; padding: 6px 12px; border-radius: 4px;">SMS fills OTP</span>
  <span style="color: #adb5bd;">&#x2192;</span>
  <span style="background: #343a40; color: #fff; padding: 6px 12px; border-radius: 4px;">Effect triggers</span>
  <span style="color: #adb5bd;">&#x2192;</span>
  <span style="background: #343a40; color: #fff; padding: 6px 12px; border-radius: 4px;">OTP verified</span>
  <span style="color: #adb5bd;">&#x2192;</span>
  <span style="background: #228be6; color: #fff; padding: 6px 12px; border-radius: 4px; font-weight: 600;">Done &#x2713;</span>
</div>

<p><strong>What actually happened:</strong></p>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 16px 20px; background: #fff5f5; border-radius: 6px; margin: 16px 0; font-family: -apple-system, system-ui, sans-serif; font-size: 14px; border: 1px solid #ffc9c9;">
  <span style="background: #343a40; color: #fff; padding: 6px 12px; border-radius: 4px;">SMS fills OTP</span>
  <span style="color: #adb5bd;">&#x2192;</span>
  <span style="background: #343a40; color: #fff; padding: 6px 12px; border-radius: 4px;">Effect triggers</span>
  <span style="color: #adb5bd;">&#x2192;</span>
  <span style="background: #e67700; color: #fff; padding: 6px 12px; border-radius: 4px;">Flag resets</span>
  <span style="color: #adb5bd;">&#x2192;</span>
  <span style="background: #c92a2a; color: #fff; padding: 6px 12px; border-radius: 4px; font-weight: 600;">CANCELLED &#x2717;</span>
</div>

<h3>Why This Happens: Compose&apos;s Recomposition Timing</h3>

<p>Here&apos;s the timeline:</p>

<div style="background: #1e1e1e; border-radius: 8px; padding: 20px; margin: 24px 0; font-family: &apos;JetBrains Mono&apos;, monospace; font-size: 13px; line-height: 1.8; color: #d4d4d4; overflow-x: auto;">
  <div><span style="color: #6a9955;">// 0ms</span></div>
  <div>wasAutoFilled = <span style="color: #569cd6;">true</span> <span style="color: #6a9955;">&#x2190; SMS arrives, LaunchedEffect starts</span></div>
  <div style="height: 12px;"></div>
  <div><span style="color: #6a9955;">// ~1ms</span></div>
  <div>wasAutoFilled = <span style="color: #569cd6;">false</span> <span style="color: #ce9178;">&#x2190; KEY CHANGES, recomposition scheduled</span></div>
  <div style="height: 12px;"></div>
  <div><span style="color: #6a9955;">// ~1ms</span></div>
  <div>delay(<span style="color: #b5cea8;">300</span>) <span style="color: #6a9955;">&#x2190; coroutine suspends here...</span></div>
  <div style="height: 12px;"></div>
  <div><span style="color: #6a9955;">// ~16ms &#x2014; FRAME BOUNDARY</span></div>
  <div style="color: #f14c4c;">Recomposition runs &#x2192; key changed &#x2192; effect recreates</div>
  <div style="color: #f14c4c;">Old coroutine: CancellationException</div>
  <div style="color: #f14c4c; font-weight: 600;">onVerifyOTP() NEVER RUNS</div>
</div>

<p>The bug is subtle: <strong>changing the LaunchedEffect key schedules cancellation, but cancellation executes at the next suspension point.</strong> If you have no suspension, the code runs to completion before the frame boundary. Add a <code style="background: #2d2d2d; color: #e6e6e6; padding: 2px 6px; border-radius: 4px; font-size: 0.9em;">delay()</code>, and you&apos;re dead.</p>

<h3>The Fix: snapshotFlow Decouples Observation from Lifecycle</h3>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;">LaunchedEffect(<span style="color: #569cd6;">Unit</span>) {  <span style="color: #6a9955;">// Key is Unit &#x2014; NEVER changes</span>
    snapshotFlow { wasAutoFilled }
        .filter { it }
        .collect {
            wasAutoFilled = <span style="color: #569cd6;">false</span>   <span style="color: #6a9955;">// Safe! Just emits to the flow</span>
            delay(<span style="color: #b5cea8;">300</span>)              <span style="color: #6a9955;">// Completes normally</span>
            onVerifyOTP(otpCode)    <span style="color: #6a9955;">// Actually executes</span>
            isVerifying = <span style="color: #569cd6;">false</span>
        }
}</code></pre>

<p><strong>Why this works:</strong></p>
<ul>
<li><code style="background: #2d2d2d; color: #e6e6e6; padding: 2px 6px; border-radius: 4px; font-size: 0.9em;">LaunchedEffect(Unit)</code> starts once and never restarts</li>
<li><code style="background: #2d2d2d; color: #e6e6e6; padding: 2px 6px; border-radius: 4px; font-size: 0.9em;">snapshotFlow</code> observes state changes as Flow emissions</li>
<li>Changing <code style="background: #2d2d2d; color: #e6e6e6; padding: 2px 6px; border-radius: 4px; font-size: 0.9em;">wasAutoFilled</code> emits a new value&#x2014;it doesn&apos;t cancel the collector</li>
</ul>

<hr style="border: none; border-top: 1px solid #e9ecef; margin: 48px 0;">

<h2>The Shopping Cart That Lost Items: Mutable Collection Mutation</h2>

<p>A user reported: &quot;I added 5 items to my cart, but only 2 showed up.&quot; We checked the database&#x2014;all 5 were there. The bug was in the UI.</p>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #569cd6;">var</span> cartItems <span style="color: #569cd6;">by</span> remember {
    mutableStateOf(mutableListOf&lt;CartItem&gt;())
}

Button(onClick = {
    cartItems.add(newItem)  <span style="color: #6a9955;">// Items added internally...</span>
    <span style="color: #6a9955;">// ...but UI never updates!</span>
})</code></pre>

<h3>Why This Happens: Reference Equality</h3>

<p>Compose uses <strong>reference equality</strong> to detect state changes. When you call <code style="background: #2d2d2d; color: #e6e6e6; padding: 2px 6px; border-radius: 4px; font-size: 0.9em;">cartItems.add()</code>, you&apos;re mutating the same list object:</p>

<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0;">
  <div style="background: #f8f9fa; padding: 20px; border-radius: 8px; border: 1px solid #e9ecef;">
    <div style="font-weight: 600; color: #495057; margin-bottom: 16px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">&#x274C; Mutation</div>
    <div style="font-family: &apos;JetBrains Mono&apos;, monospace; font-size: 13px; line-height: 1.8; color: #495057;">
      <div>List@<span style="color: #868e96;">a1b2c3</span> &#x2192; [2 items]</div>
      <div>List@<span style="color: #868e96;">a1b2c3</span> &#x2192; [3 items]</div>
    </div>
    <div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #e9ecef; font-size: 12px; color: #868e96;">Same reference = no recomposition</div>
  </div>
  <div style="background: #f8f9fa; padding: 20px; border-radius: 8px; border: 1px solid #e9ecef;">
    <div style="font-weight: 600; color: #495057; margin-bottom: 16px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">&#x2713; New list</div>
    <div style="font-family: &apos;JetBrains Mono&apos;, monospace; font-size: 13px; line-height: 1.8; color: #495057;">
      <div>List@<span style="color: #868e96;">a1b2c3</span> &#x2192; [2 items]</div>
      <div>List@<span style="color: #228be6; font-weight: 600;">d4e5f6</span> &#x2192; [3 items]</div>
    </div>
    <div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #e9ecef; font-size: 12px; color: #228be6;">New reference = recomposition &#x2713;</div>
  </div>
</div>

<h3>The Fix: Immutable Updates or mutableStateListOf</h3>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #6a9955;">// Option 1: Create new list</span>
<span style="color: #569cd6;">var</span> cartItems <span style="color: #569cd6;">by</span> remember { mutableStateOf(listOf&lt;CartItem&gt;()) }
cartItems = cartItems + newItem  <span style="color: #6a9955;">// New reference</span>

<span style="color: #6a9955;">// Option 2: Use Compose&apos;s observable list</span>
<span style="color: #569cd6;">val</span> cartItems = remember { mutableStateListOf&lt;CartItem&gt;() }
cartItems.add(newItem)  <span style="color: #6a9955;">// Automatically triggers recomposition</span></code></pre>

<hr style="border: none; border-top: 1px solid #e9ecef; margin: 48px 0;">

<h2>The Duplicate Snackbar: Events Are Not State</h2>

<p>Error handling seemed simple:</p>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #6a9955;">// In ViewModel</span>
<span style="color: #569cd6;">var</span> errorMessage <span style="color: #569cd6;">by</span> mutableStateOf&lt;String?&gt;(<span style="color: #569cd6;">null</span>)

<span style="color: #6a9955;">// In Composable</span>
LaunchedEffect(viewModel.errorMessage) {
    viewModel.errorMessage?.let { error -&gt;
        snackbarHostState.showSnackbar(error)
    }
}</code></pre>

<p><strong>Bug:</strong> Rotate the device while the snackbar is showing. It shows again. And again on every configuration change.</p>

<h3>Why This Happens</h3>

<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 24px 0; border: 1px solid #e9ecef;">
  <div style="font-weight: 600; margin-bottom: 16px; color: #495057;">Configuration Change Timeline:</div>
  <div style="font-family: &apos;JetBrains Mono&apos;, monospace; font-size: 13px; line-height: 2; color: #495057;">
    <div>1. Activity recreates</div>
    <div>2. ViewModel survives &#x2192; <code style="background: #e9ecef; padding: 2px 8px; border-radius: 4px;">errorMessage = &quot;Save failed&quot;</code></div>
    <div>3. LaunchedEffect observes non-null</div>
    <div style="color: #c92a2a; font-weight: 600;">4. Snackbar shows AGAIN</div>
  </div>
</div>

<h3>The Fix: Use Channels for One-Time Events</h3>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #6a9955;">// In ViewModel</span>
<span style="color: #569cd6;">private val</span> _events = Channel&lt;UiEvent&gt;(Channel.BUFFERED)
<span style="color: #569cd6;">val</span> events = _events.receiveAsFlow()

<span style="color: #6a9955;">// In Composable</span>
LaunchedEffect(<span style="color: #569cd6;">Unit</span>) {
    viewModel.events.collect { event -&gt;
        <span style="color: #c586c0;">when</span> (event) {
            <span style="color: #c586c0;">is</span> UiEvent.ShowError -&gt; snackbarHostState.showSnackbar(event.message)
        }
    }
}</code></pre>

<hr style="border: none; border-top: 1px solid #e9ecef; margin: 48px 0;">

<h2>The Janky Scroll: State Read Too High</h2>

<p>Performance profiling showed our list was recomposing on every frame during scroll. 60 recompositions per second.</p>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #dcdcaa;">@Composable</span>
<span style="color: #569cd6;">fun</span> <span style="color: #dcdcaa;">ProductListScreen</span>() {
    <span style="color: #569cd6;">val</span> scrollState = rememberLazyListState()
    <span style="color: #569cd6;">val</span> showScrollToTop = scrollState.firstVisibleItemIndex &gt; <span style="color: #b5cea8;">5</span>  <span style="color: #ce9178;">// &#x2190; Read here!</span>

    Column {
        TopBar(showScrollToTop)   <span style="color: #6a9955;">// Recomposes on scroll</span>
        ProductList(scrollState)  <span style="color: #6a9955;">// Recomposes on scroll</span>
        BottomNav()               <span style="color: #6a9955;">// Recomposes on scroll (!)</span>
    }
}</code></pre>

<h3>Why This Happens: Recomposition Scope</h3>

<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 24px 0; font-family: &apos;JetBrains Mono&apos;, monospace; font-size: 13px; border: 1px solid #e9ecef;">
  <div style="color: #c92a2a;">ProductListScreen <span style="color: #868e96; font-weight: normal;">(reads scrollState)</span></div>
  <div style="margin-left: 20px; padding: 12px 0 0 16px; border-left: 2px solid #c92a2a;">
    <div style="color: #c92a2a;">&#x251C;&#x2500;&#x2500; TopBar <span style="background: #fff5f5; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 8px;">recomposes</span></div>
    <div style="color: #c92a2a;">&#x251C;&#x2500;&#x2500; ProductList <span style="background: #fff5f5; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 8px;">recomposes</span></div>
    <div style="color: #c92a2a;">&#x2514;&#x2500;&#x2500; BottomNav <span style="background: #fff5f5; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 8px;">recomposes</span></div>
  </div>
  <div style="margin-top: 16px; color: #868e96; font-size: 12px;">Every scroll = entire tree recomposes</div>
</div>

<h3>The Fix: Push State Reads Down</h3>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #dcdcaa;">@Composable</span>
<span style="color: #569cd6;">fun</span> <span style="color: #dcdcaa;">ScrollAwareTopBar</span>(scrollState: LazyListState) {
    <span style="color: #569cd6;">val</span> showScrollToTop <span style="color: #569cd6;">by</span> remember {
        derivedStateOf { scrollState.firstVisibleItemIndex &gt; <span style="color: #b5cea8;">5</span> }
    }
    <span style="color: #6a9955;">// Only THIS composable recomposes on scroll</span>
    TopBar(showScrollToTop)
}</code></pre>

<hr style="border: none; border-top: 1px solid #e9ecef; margin: 48px 0;">

<h2>State Machines: Making Impossible States Impossible</h2>

<p>All these bugs share a root cause: <strong>invalid state combinations that shouldn&apos;t exist.</strong></p>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #6a9955;">// 4 booleans = 16 combinations. Valid states? Maybe 5.</span>
<span style="color: #569cd6;">data class</span> CheckoutState(
    <span style="color: #569cd6;">val</span> isLoading: Boolean,
    <span style="color: #569cd6;">val</span> isError: Boolean,
    <span style="color: #569cd6;">val</span> isSuccess: Boolean,
    <span style="color: #569cd6;">val</span> isProcessingPayment: Boolean
)</code></pre>

<h3>The Fix: Sealed Interfaces</h3>

<pre style="background: #1e1e1e; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 24px 0;"><code style="color: #d4d4d4; font-family: &apos;JetBrains Mono&apos;, &apos;Fira Code&apos;, monospace; font-size: 14px; line-height: 1.6;"><span style="color: #569cd6;">sealed interface</span> CheckoutState {
    <span style="color: #569cd6;">data object</span> Idle : CheckoutState
    <span style="color: #569cd6;">data object</span> Loading : CheckoutState
    <span style="color: #569cd6;">data class</span> ProcessingPayment(<span style="color: #569cd6;">val</span> order: Order) : CheckoutState
    <span style="color: #569cd6;">data class</span> Success(<span style="color: #569cd6;">val</span> receipt: Receipt) : CheckoutState
    <span style="color: #569cd6;">data class</span> Error(<span style="color: #569cd6;">val</span> message: String) : CheckoutState
}</code></pre>

<p>Now only valid states can exist. The compiler enforces exhaustive handling.</p>

<hr style="border: none; border-top: 1px solid #e9ecef; margin: 48px 0;">

<h2>Production Results</h2>

<p>After implementing these patterns at Equal AI:</p>

<table style="width: 100%; border-collapse: collapse; margin: 24px 0; font-size: 14px;">
  <thead>
    <tr style="border-bottom: 2px solid #dee2e6;">
      <th style="padding: 12px 16px; text-align: left; font-weight: 600; color: #adb5bd;">Metric</th>
      <th style="padding: 12px 16px; text-align: center; font-weight: 600; color: #adb5bd;">Before</th>
      <th style="padding: 12px 16px; text-align: center; font-weight: 600; color: #adb5bd;">After</th>
    </tr>
  </thead>
  <tbody>
    <tr style="border-bottom: 1px solid #e9ecef;">
      <td style="padding: 12px 16px;">Crash rate</td>
      <td style="padding: 12px 16px; text-align: center; color: #868e96;">0.4%</td>
      <td style="padding: 12px 16px; text-align: center; font-weight: 600;">0.1%</td>
    </tr>
    <tr style="border-bottom: 1px solid #e9ecef;">
      <td style="padding: 12px 16px;">ANR rate</td>
      <td style="padding: 12px 16px; text-align: center; color: #868e96;">0.2%</td>
      <td style="padding: 12px 16px; text-align: center; font-weight: 600;">0.05%</td>
    </tr>
    <tr style="border-bottom: 1px solid #e9ecef;">
      <td style="padding: 12px 16px;">&quot;UI stuck&quot; reports</td>
      <td style="padding: 12px 16px; text-align: center; color: #868e96;">23/week</td>
      <td style="padding: 12px 16px; text-align: center; font-weight: 600;">3/week</td>
    </tr>
    <tr>
      <td style="padding: 12px 16px;">Test coverage (state)</td>
      <td style="padding: 12px 16px; text-align: center; color: #868e96;">34%</td>
      <td style="padding: 12px 16px; text-align: center; font-weight: 600;">89%</td>
    </tr>
  </tbody>
</table>

<hr style="border: none; border-top: 1px solid #e9ecef; margin: 48px 0;">

<h2>Resources</h2>

<p><strong>Slides:</strong> <a href="https://speakerdeck.com/aldefy/compose-beyond-the-ui-architecting-reactive-state-machines-at-scale?ref=aditlal.dev">View on SpeakerDeck</a></p>

<p><strong>Code:</strong> <a href="https://github.com/aldefy/compose-patterns-playground?ref=aditlal.dev">compose-patterns-playground</a></p>

<p>The playground includes interactive broken/fixed demos for all 12 anti-patterns.</p>

<h2>Key Takeaways</h2>

<ol>
<li><strong>LaunchedEffect keys control lifecycle</strong> &#x2014; Changing the key cancels the coroutine</li>
<li><strong>Compose uses reference equality</strong> &#x2014; Mutating collections doesn&apos;t trigger recomposition</li>
<li><strong>Events are not state</strong> &#x2014; Use Channel for one-time events</li>
<li><strong>State reads define recomposition scope</strong> &#x2014; Read state as low as possible</li>
<li><strong>Sealed interfaces prevent impossible states</strong> &#x2014; Boolean combinations explode</li>
</ol>

<p>Compose isn&apos;t slow. Misusing Compose is slow. Learn the patterns, avoid the traps, ship fewer bugs.</p>

<p><em>Presented at Droidcon India 2025</em></p>
]]></content:encoded></item><item><title><![CDATA[The OkHttp API You're Not Using]]></title><description><![CDATA[Your logging interceptor is lying. Here is what actually happens inside OkHttp - and how to finally see it.]]></description><link>https://aditlal.dev/okhttp-network-observability-android/</link><guid isPermaLink="false">d08e645fa4b85b5257ce54e4</guid><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Fri, 16 Jan 2026 15:08:17 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1200" medium="image"/><content:encoded><![CDATA[<h2 id="the-45-second-mystery">The 45-Second Mystery</h2>
<img src="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1200" alt="The OkHttp API You&apos;re Not Using"><p>Last month, our Crashlytics lit up:</p>
<pre><code>UnknownHostException: Unable to resolve host &quot;api.example.com&quot;
  Occurrences: 2,847
  Users affected: 1,203
  Context: &#xAF;\_(&#x30C4;)_/&#xAF;
</code></pre>
<p>Backend team checked their dashboards: <strong>&quot;API response time is 47ms p95. Not our problem.&quot;</strong></p>
<p>They were right. The API was fast. But users were staring at spinners for <strong>45 seconds</strong> before seeing &quot;Something went wrong.&quot;</p>
<p>Where did those 45 seconds go?</p>
<hr>
<h2 id="the-visibility-gap">The Visibility Gap</h2>
<p>Here&apos;s what most Android apps measure:</p>
<table>
<thead>
<tr>
<th>What You Track</th>
<th>What Actually Happens</th>
</tr>
</thead>
<tbody>
<tr>
<td>Total request time</td>
<td>Yes</td>
</tr>
<tr>
<td>HTTP status code</td>
<td>Yes</td>
</tr>
<tr>
<td>DNS resolution time</td>
<td>No</td>
</tr>
<tr>
<td>TCP handshake time</td>
<td>No</td>
</tr>
<tr>
<td>TLS negotiation time</td>
<td>No</td>
</tr>
<tr>
<td>Time waiting for first byte</td>
<td>No</td>
</tr>
<tr>
<td>Which phase failed</td>
<td>No</td>
</tr>
</tbody>
</table>
<p><strong>You&apos;re measuring the destination, but you&apos;re blind to the journey.</strong></p>
<p>That <code>UnknownHostException</code>? It could mean:</p>
<ul>
<li>DNS server unreachable (2-30 second timeout)</li>
<li>Domain doesn&apos;t exist (instant failure)</li>
<li>Network switched mid-request (random timing)</li>
<li>DNS poisoning in certain regions (varies)</li>
</ul>
<p>Without phase-level visibility, you&apos;re debugging with a blindfold.</p>
<hr>
<h2 id="where-time-actually-goes">Where Time Actually Goes</h2>
<p>We instrumented 50,000 requests across different network conditions. Here&apos;s what we found:</p>
<h3 id="good-network-wifi-4g-lte">Good Network (WiFi, 4G LTE)</h3>
<table>
<thead>
<tr>
<th>Phase</th>
<th>P50</th>
<th>P95</th>
<th>P99</th>
</tr>
</thead>
<tbody>
<tr>
<td>DNS Lookup</td>
<td>5ms</td>
<td>45ms</td>
<td>120ms</td>
</tr>
<tr>
<td>TCP Connect</td>
<td>23ms</td>
<td>89ms</td>
<td>156ms</td>
</tr>
<tr>
<td>TLS Handshake</td>
<td>67ms</td>
<td>142ms</td>
<td>203ms</td>
</tr>
<tr>
<td>Time to First Byte</td>
<td>52ms</td>
<td>187ms</td>
<td>412ms</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>147ms</strong></td>
<td><strong>463ms</strong></td>
<td><strong>891ms</strong></td>
</tr>
</tbody>
</table>
<h3 id="degraded-network-3g-poor-signal">Degraded Network (3G, Poor Signal)</h3>
<table>
<thead>
<tr>
<th>Phase</th>
<th>P50</th>
<th>P95</th>
<th>P99</th>
</tr>
</thead>
<tbody>
<tr>
<td>DNS Lookup</td>
<td>234ms</td>
<td><strong>8,200ms</strong></td>
<td><strong>29,000ms</strong></td>
</tr>
<tr>
<td>TCP Connect</td>
<td>456ms</td>
<td>2,100ms</td>
<td>5,600ms</td>
</tr>
<tr>
<td>TLS Handshake</td>
<td>312ms</td>
<td>890ms</td>
<td>1,400ms</td>
</tr>
<tr>
<td>Time to First Byte</td>
<td>178ms</td>
<td>1,200ms</td>
<td>3,400ms</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>1,180ms</strong></td>
<td><strong>12,390ms</strong></td>
<td><strong>39,400ms</strong></td>
</tr>
</tbody>
</table>
<p><strong>The culprit in our 45-second mystery? DNS timeout on degraded networks.</strong></p>
<p>But we only discovered this <em>after</em> adding proper instrumentation.</p>
<hr>
<h2 id="the-logging-interceptor-trap">The Logging Interceptor Trap</h2>
<p>This is in 90% of Android codebases:</p>
<pre><code class="language-kotlin">class LoggingInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val start = System.currentTimeMillis()
        val response = chain.proceed(chain.request())
        val duration = System.currentTimeMillis() - start

        Timber.d(&quot;Request took ${duration}ms&quot;) // &lt;-- This number lies
        return response
    }
}
</code></pre>
<p><strong>Why it lies:</strong></p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>What Happened</th>
<th>What You See</th>
</tr>
</thead>
<tbody>
<tr>
<td>3 retries due to connection reset</td>
<td>3 separate failures, then success</td>
<td>&quot;Request took 12,000ms&quot;</td>
</tr>
<tr>
<td>Cache hit</td>
<td>Instant response from disk</td>
<td>&quot;Request took 2ms&quot; (good!)</td>
</tr>
<tr>
<td>Redirect chain (3 hops)</td>
<td>3 network round trips</td>
<td>Single timing</td>
</tr>
<tr>
<td>DNS timeout + success</td>
<td>30s DNS, 200ms request</td>
<td>&quot;Request took 30,200ms&quot;</td>
</tr>
</tbody>
</table>
<p>You&apos;re seeing the <em>outcome</em>, not the <em>story</em>.</p>
<hr>
<h2 id="the-okhttp-timeout-trap">The OkHttp Timeout Trap</h2>
<p>Here&apos;s a &quot;reasonable&quot; timeout configuration:</p>
<pre><code class="language-kotlin">val client = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .writeTimeout(30, TimeUnit.SECONDS)
    .build()
</code></pre>
<p><strong>Pop quiz:</strong> What&apos;s the maximum time a user could wait?</p>
<p>If you said 70 seconds, you&apos;re wrong. It&apos;s potentially <strong>infinite</strong>.</p>
<h3 id="the-timeout-truth-table">The Timeout Truth Table</h3>
<table>
<thead>
<tr>
<th>Timeout</th>
<th>What It Actually Controls</th>
<th>Resets?</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>connectTimeout</code></td>
<td>DNS + TCP + TLS combined</td>
<td>No</td>
</tr>
<tr>
<td><code>readTimeout</code></td>
<td>Max time <em>between</em> bytes</td>
<td><strong>Yes, per chunk</strong></td>
</tr>
<tr>
<td><code>writeTimeout</code></td>
<td>Max time <em>between</em> bytes</td>
<td><strong>Yes, per chunk</strong></td>
</tr>
<tr>
<td><code>callTimeout</code></td>
<td>Entire operation end-to-end</td>
<td><strong>No</strong></td>
</tr>
</tbody>
</table>
<p>A server trickling 1 byte every 25 seconds will <strong>never</strong> trigger your 30-second <code>readTimeout</code>. Each byte resets the clock.</p>
<p><strong><code>callTimeout</code> is the only timeout that represents actual user experience.</strong></p>
<pre><code class="language-kotlin">val client = OkHttpClient.Builder()
    .connectTimeout(15, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .writeTimeout(30, TimeUnit.SECONDS)
    .callTimeout(45, TimeUnit.SECONDS)  // &lt;-- The one that matters
    .build()
</code></pre>
<hr>
<h2 id="the-solution-eventlistener">The Solution: EventListener</h2>
<p>OkHttp has a hidden API that most developers don&apos;t know exists. <code>EventListener</code> gives you callbacks for <strong>every phase</strong> of the request lifecycle.</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Events</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Start</strong></td>
<td><code>callStart</code></td>
</tr>
<tr>
<td><strong>DNS</strong></td>
<td><code>dnsStart</code> &#x2192; <code>dnsEnd</code></td>
</tr>
<tr>
<td><strong>TCP</strong></td>
<td><code>connectStart</code> &#x2192; <code>connectEnd</code></td>
</tr>
<tr>
<td><strong>TLS</strong></td>
<td><code>secureConnectStart</code> &#x2192; <code>secureConnectEnd</code></td>
</tr>
<tr>
<td><strong>Request</strong></td>
<td><code>connectionAcquired</code> &#x2192; <code>requestHeadersStart</code> &#x2192; <code>requestHeadersEnd</code> &#x2192; <code>requestBodyStart</code> &#x2192; <code>requestBodyEnd</code></td>
</tr>
<tr>
<td><strong>Response</strong></td>
<td><code>responseHeadersStart</code> &#x2192; <code>responseHeadersEnd</code> &#x2192; <code>responseBodyStart</code> &#x2192; <code>responseBodyEnd</code></td>
</tr>
<tr>
<td><strong>Cleanup</strong></td>
<td><code>connectionReleased</code> &#x2192; <code>callEnd</code></td>
</tr>
</tbody>
</table>
<h3 id="production-implementation">Production Implementation</h3>
<pre><code class="language-kotlin">class NetworkMetricsListener(
    private val onMetrics: (NetworkMetrics) -&gt; Unit
) : EventListener() {

    private var callStart = 0L
    private var dnsStart = 0L
    private var connectStart = 0L
    private var secureConnectStart = 0L
    private var requestStart = 0L
    private var responseStart = 0L

    private var connectionReused = false

    override fun callStart(call: Call) {
        callStart = System.nanoTime()
    }

    override fun dnsStart(call: Call, domainName: String) {
        dnsStart = System.nanoTime()
    }

    override fun dnsEnd(call: Call, domainName: String, inetAddressList: List&lt;InetAddress&gt;) {
        // DNS complete - connection reuse skips this entirely
    }

    override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) {
        connectStart = System.nanoTime()
    }

    override fun secureConnectStart(call: Call) {
        secureConnectStart = System.nanoTime()
    }

    override fun connectionAcquired(call: Call, connection: Connection) {
        connectionReused = (connectStart == 0L)  // No connect phase = reused
    }

    override fun requestHeadersStart(call: Call) {
        requestStart = System.nanoTime()
    }

    override fun responseHeadersStart(call: Call) {
        responseStart = System.nanoTime()
    }

    override fun callEnd(call: Call) {
        emitMetrics(success = true)
    }

    override fun callFailed(call: Call, ioe: IOException) {
        emitMetrics(success = false, error = ioe)
    }

    private fun emitMetrics(success: Boolean, error: IOException? = null) {
        val now = System.nanoTime()
        onMetrics(NetworkMetrics(
            dnsMs = if (dnsStart &gt; 0) (connectStart - dnsStart).toMillis() else 0,
            connectMs = if (connectStart &gt; 0) (secureConnectStart - connectStart).toMillis() else 0,
            tlsMs = if (secureConnectStart &gt; 0) (requestStart - secureConnectStart).toMillis() else 0,
            ttfbMs = (responseStart - requestStart).toMillis(),
            totalMs = (now - callStart).toMillis(),
            connectionReused = connectionReused,
            success = success,
            errorType = error?.javaClass?.simpleName
        ))
    }

    private fun Long.toMillis() = TimeUnit.NANOSECONDS.toMillis(this)
}
</code></pre>
<h3 id="what-you-get">What You Get</h3>
<p><strong>Before EventListener:</strong></p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Duration</td>
<td>32,450ms</td>
</tr>
<tr>
<td>Error</td>
<td>UnknownHostException</td>
</tr>
<tr>
<td>Context</td>
<td>???</td>
</tr>
</tbody>
</table>
<p><strong>After EventListener:</strong></p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Duration</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>DNS Lookup</td>
<td>30,120ms</td>
<td>TIMEOUT</td>
</tr>
<tr>
<td>TCP Connect</td>
<td>--</td>
<td>--</td>
</tr>
<tr>
<td>TLS Handshake</td>
<td>--</td>
<td>--</td>
</tr>
<tr>
<td>TTFB</td>
<td>--</td>
<td>--</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>30,120ms</strong></td>
<td></td>
</tr>
<tr>
<td><strong>Error</strong></td>
<td>UnknownHostException</td>
<td></td>
</tr>
<tr>
<td><strong>Failed Phase</strong></td>
<td><strong>DNS</strong></td>
<td></td>
</tr>
</tbody>
</table>
<p><strong>Now you know:</strong> The DNS resolver on this user&apos;s network is broken. Not your API. Not your code. Their ISP.</p>
<hr>
<h2 id="level-up-distributed-tracing-with-opentelemetry">Level Up: Distributed Tracing with OpenTelemetry</h2>
<p>EventListener tells you what happened on the client. But what about the full journey?</p>
<p><strong>Android App</strong> &#x2192; <strong>CDN</strong> &#x2192; <strong>API Gateway</strong> &#x2192; <strong>Service</strong> &#x2192; <strong>Database</strong></p>
<p><em>Where is the slowness?</em></p>
<p>With OpenTelemetry, you can trace a request from button tap to database query:</p>
<pre><code class="language-kotlin">class TracingEventListener(
    private val tracer: Tracer
) : EventListener() {

    private var rootSpan: Span? = null

    override fun callStart(call: Call) {
        rootSpan = tracer.spanBuilder(&quot;HTTP ${call.request().method}&quot;)
            .setSpanKind(SpanKind.CLIENT)
            .setAttribute(&quot;http.url&quot;, call.request().url.toString())
            .startSpan()
    }

    override fun dnsStart(call: Call, domainName: String) {
        tracer.spanBuilder(&quot;DNS Lookup&quot;)
            .setParent(Context.current().with(rootSpan!!))
            .startSpan()
    }

    // ... create child spans for each phase

    override fun callEnd(call: Call) {
        rootSpan?.setStatus(StatusCode.OK)
        rootSpan?.end()
    }
}
</code></pre>
<h3 id="the-trace-waterfall">The Trace Waterfall</h3>
<p><strong>Now you can answer:</strong> &quot;Is it DNS, the network, or the backend?&quot;</p>
<div style="background: #1e1e1e; border-radius: 8px; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, &apos;Segoe UI&apos;, Roboto, monospace; color: #e0e0e0; width: 100%; min-width: 800px;">
  <div style="margin-bottom: 16px; font-size: 14px; color: #888;">
    <span style="font-weight: 600; color: #fff;">HTTP GET /api/users</span>
    <span style="margin-left: 12px;">Total: <span style="color: #4ade80; font-weight: 600;">1,247ms</span></span>
  </div>
  <!-- Timeline header -->
  <div style="display: flex; justify-content: space-between; font-size: 11px; color: #666; margin-bottom: 8px; padding-left: 140px;">
    <span>0ms</span>
    <span>250ms</span>
    <span>500ms</span>
    <span>750ms</span>
    <span>1000ms</span>
    <span>1247ms</span>
  </div>
  <!-- DNS Lookup -->
  <div style="display: flex; align-items: center; margin-bottom: 8px;">
    <div style="width: 130px; font-size: 12px; color: #aaa; text-align: right; padding-right: 10px; flex-shrink: 0;">DNS Lookup</div>
    <div style="flex: 1; height: 24px; background: #2d2d2d; border-radius: 4px; position: relative;">
      <div style="position: absolute; left: 0%; width: 3.6%; height: 100%; background: linear-gradient(90deg, #60a5fa, #3b82f6); border-radius: 4px; display: flex; align-items: center; justify-content: center;">
        <span style="font-size: 10px; color: #fff; font-weight: 500; white-space: nowrap;">45ms</span>
      </div>
    </div>
  </div>
  <!-- TCP Connect -->
  <div style="display: flex; align-items: center; margin-bottom: 8px;">
    <div style="width: 130px; font-size: 12px; color: #aaa; text-align: right; padding-right: 10px; flex-shrink: 0;">TCP Connect</div>
    <div style="flex: 1; height: 24px; background: #2d2d2d; border-radius: 4px; position: relative;">
      <div style="position: absolute; left: 3.6%; width: 7.1%; height: 100%; background: linear-gradient(90deg, #fb923c, #f97316); border-radius: 4px; display: flex; align-items: center; justify-content: center;">
        <span style="font-size: 10px; color: #fff; font-weight: 500; white-space: nowrap;">89ms</span>
      </div>
    </div>
  </div>
  <!-- TLS Handshake -->
  <div style="display: flex; align-items: center; margin-bottom: 8px;">
    <div style="width: 130px; font-size: 12px; color: #aaa; text-align: right; padding-right: 10px; flex-shrink: 0;">TLS Handshake</div>
    <div style="flex: 1; height: 24px; background: #2d2d2d; border-radius: 4px; position: relative;">
      <div style="position: absolute; left: 10.7%; width: 12.5%; height: 100%; background: linear-gradient(90deg, #c084fc, #a855f7); border-radius: 4px; display: flex; align-items: center; justify-content: center;">
        <span style="font-size: 10px; color: #fff; font-weight: 500; white-space: nowrap;">156ms</span>
      </div>
    </div>
  </div>
  <!-- Request Send -->
  <div style="display: flex; align-items: center; margin-bottom: 8px;">
    <div style="width: 130px; font-size: 12px; color: #aaa; text-align: right; padding-right: 10px; flex-shrink: 0;">Request Send</div>
    <div style="flex: 1; height: 24px; background: #2d2d2d; border-radius: 4px; position: relative;">
      <div style="position: absolute; left: 23.3%; width: 1%; height: 100%; background: linear-gradient(90deg, #4ade80, #22c55e); border-radius: 4px; display: flex; align-items: center; justify-content: flex-end; padding-right: 4px;">
      </div>
      <span style="position: absolute; left: 25%; font-size: 10px; color: #4ade80; font-weight: 500; top: 50%; transform: translateY(-50%);">12ms</span>
    </div>
  </div>
  <!-- Response Receive -->
  <div style="display: flex; align-items: center; margin-bottom: 8px;">
    <div style="width: 130px; font-size: 12px; color: #aaa; text-align: right; padding-right: 10px; flex-shrink: 0;">Response Receive</div>
    <div style="flex: 1; height: 24px; background: #2d2d2d; border-radius: 4px; position: relative;">
      <div style="position: absolute; left: 24.2%; width: 75.8%; height: 100%; background: linear-gradient(90deg, #38bdf8, #0ea5e9); border-radius: 4px; display: flex; align-items: center; justify-content: center;">
        <span style="font-size: 10px; color: #fff; font-weight: 500;">945ms</span>
      </div>
    </div>
  </div>
  <!-- Legend -->
  <div style="display: flex; flex-wrap: wrap; gap: 16px; margin-top: 16px; padding-top: 16px; border-top: 1px solid #333; font-size: 11px;">
    <div style="display: flex; align-items: center; gap: 6px;">
      <div style="width: 12px; height: 12px; background: #3b82f6; border-radius: 2px;"></div>
      <span style="color: #888;">DNS</span>
    </div>
    <div style="display: flex; align-items: center; gap: 6px;">
      <div style="width: 12px; height: 12px; background: #f97316; border-radius: 2px;"></div>
      <span style="color: #888;">TCP</span>
    </div>
    <div style="display: flex; align-items: center; gap: 6px;">
      <div style="width: 12px; height: 12px; background: #a855f7; border-radius: 2px;"></div>
      <span style="color: #888;">TLS</span>
    </div>
    <div style="display: flex; align-items: center; gap: 6px;">
      <div style="width: 12px; height: 12px; background: #22c55e; border-radius: 2px;"></div>
      <span style="color: #888;">Request</span>
    </div>
    <div style="display: flex; align-items: center; gap: 6px;">
      <div style="width: 12px; height: 12px; background: #0ea5e9; border-radius: 2px;"></div>
      <span style="color: #888;">Response</span>
    </div>
  </div>
</div>
<hr>
<h2 id="observability-stack-options">Observability Stack Options</h2>
<table>
<thead>
<tr>
<th>Solution</th>
<th>Cost</th>
<th>Setup</th>
<th>Best For</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Honeycomb</strong></td>
<td>Paid (20M events free)</td>
<td>5 min</td>
<td>Best query experience</td>
</tr>
<tr>
<td><strong>Grafana Cloud</strong></td>
<td>Free 50GB/mo</td>
<td>10 min</td>
<td>Already using Grafana</td>
</tr>
<tr>
<td><strong>Jaeger</strong></td>
<td>Free (self-host)</td>
<td>1-2 hrs</td>
<td>Full control</td>
</tr>
<tr>
<td><strong>Datadog</strong></td>
<td>Paid</td>
<td>15 min</td>
<td>Enterprise, existing DD</td>
</tr>
</tbody>
</table>
<hr>
<h2 id="results">Results</h2>
<p>After implementing EventListener + OpenTelemetry in our production app:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before</th>
<th>After</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr>
<td>MTTR for network issues</td>
<td>4.2 hours</td>
<td>23 minutes</td>
<td><strong>-91%</strong></td>
</tr>
<tr>
<td>&quot;Network error&quot; bug reports</td>
<td>847/week</td>
<td>312/week</td>
<td><strong>-63%</strong></td>
</tr>
<tr>
<td>P95 false timeout errors</td>
<td>2.3%</td>
<td>0.4%</td>
<td><strong>-83%</strong></td>
</tr>
</tbody>
</table>
<p><strong>The biggest win?</strong> We stopped blaming the backend for DNS problems.</p>
<hr>
<h2 id="real-device-testing-the-proof">Real Device Testing: The Proof</h2>
<p>Theory is nice. Data is better. I built a test app and ran it on real devices to see what actually happens.</p>
<p><strong>Test App:</strong> <a href="https://github.com/aldefy/okhttp-network-metrics?ref=aditlal.dev">github.com/aldefy/okhttp-network-metrics</a></p>
<h3 id="eventlistener-see-what-interceptors-cant">EventListener: See What Interceptors Can&apos;t</h3>
<table>
<thead>
<tr>
<th>What Interceptor Sees</th>
<th>What EventListener Reveals</th>
</tr>
</thead>
<tbody>
<tr>
<td>Request &#x2192; Response</td>
<td>DNS: <strong>5081ms</strong></td>
</tr>
<tr>
<td>Total: <strong>7362ms</strong></td>
<td>TCP: <strong>1313ms</strong></td>
</tr>
<tr>
<td></td>
<td>TLS: <strong>964ms</strong></td>
</tr>
<tr>
<td></td>
<td>TTFB: <strong>7359ms</strong></td>
</tr>
</tbody>
</table>
<h3 id="the-doze-mode-discovery">The Doze Mode Discovery</h3>
<div style="background: #1e1e1e; border-radius: 8px; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, &apos;Segoe UI&apos;, Roboto, monospace; color: #e0e0e0; width: 100%; min-width: 800px;">
  <div style="margin-bottom: 20px;">
    <div style="font-size: 16px; font-weight: 600; color: #fff; margin-bottom: 4px;">Doze Mode Recovery Timeline</div>
    <div style="font-size: 12px; color: #888;">Pixel 9 Pro Fold &#x2022; JIO 4G</div>
  </div>
  <!-- Timeline Scale -->
  <div style="display: flex; align-items: center; margin-bottom: 8px; padding-left: 120px;">
    <div style="flex: 1; display: flex; justify-content: space-between; font-size: 10px; color: #666;">
      <span>0s</span>
      <span>5s</span>
      <span>10s</span>
      <span>15s</span>
    </div>
  </div>
  <!-- Immediate - FAILED -->
  <div style="display: flex; align-items: center; margin-bottom: 12px;">
    <div style="width: 110px; text-align: right; padding-right: 10px; flex-shrink: 0;">
      <div style="font-size: 12px; color: #aaa;">Immediate</div>
      <div style="font-size: 10px; color: #666;">after doze exit</div>
    </div>
    <div style="flex: 1; height: 40px; background: #2d2d2d; border-radius: 6px; position: relative; overflow: hidden;">
      <!-- 15s timeout bar -->
      <div style="position: absolute; left: 0; width: 100%; height: 100%; background: linear-gradient(90deg, #ef4444 0%, #dc2626 60%, #7f1d1d 100%); border-radius: 6px; display: flex; align-items: center; padding-left: 16px;">
        <span style="font-size: 12px; color: #fff; font-weight: 600;">TCP TIMEOUT</span>
        <span style="font-size: 11px; color: #fca5a5; margin-left: 8px;">15,000ms</span>
      </div>
      <!-- Pulse animation effect -->
      <div style="position: absolute; right: 16px; top: 50%; transform: translateY(-50%);">
        <span style="font-size: 18px;">&#x274C;</span>
      </div>
    </div>
  </div>
  <!-- After 5s - SUCCESS -->
  <div style="display: flex; align-items: center; margin-bottom: 12px;">
    <div style="width: 110px; text-align: right; padding-right: 10px; flex-shrink: 0;">
      <div style="font-size: 12px; color: #aaa;">After 5s</div>
      <div style="font-size: 10px; color: #666;">wait then retry</div>
    </div>
    <div style="flex: 1; height: 40px; background: #2d2d2d; border-radius: 6px; position: relative; overflow: hidden;">
      <!-- 1431ms bar (roughly 10% of 15s scale) -->
      <div style="position: absolute; left: 0; width: 9.5%; height: 100%; background: linear-gradient(90deg, #4ade80, #22c55e); border-radius: 6px; display: flex; align-items: center; justify-content: center;">
      </div>
      <span style="position: absolute; left: 11%; top: 50%; transform: translateY(-50%); font-size: 12px; color: #4ade80; font-weight: 600;">1,431ms &#x2713;</span>
    </div>
  </div>
  <!-- After 30s - SUCCESS -->
  <div style="display: flex; align-items: center; margin-bottom: 16px;">
    <div style="width: 110px; text-align: right; padding-right: 10px; flex-shrink: 0;">
      <div style="font-size: 12px; color: #aaa;">After 30s</div>
      <div style="font-size: 10px; color: #666;">fully recovered</div>
    </div>
    <div style="flex: 1; height: 40px; background: #2d2d2d; border-radius: 6px; position: relative; overflow: hidden;">
      <!-- 1777ms bar -->
      <div style="position: absolute; left: 0; width: 11.8%; height: 100%; background: linear-gradient(90deg, #4ade80, #22c55e); border-radius: 6px; display: flex; align-items: center; justify-content: center;">
      </div>
      <span style="position: absolute; left: 13%; top: 50%; transform: translateY(-50%); font-size: 12px; color: #4ade80; font-weight: 600;">1,777ms &#x2713;</span>
    </div>
  </div>
  <!-- Insight Box -->
  <div style="background: #2d2d2d; border-radius: 6px; padding: 16px; border-left: 4px solid #f97316;">
    <div style="font-size: 13px; color: #e0e0e0; line-height: 1.5;">
      <strong style="color: #f97316;">Key Insight:</strong> Pixel fails immediately after doze exit because the radio is still waking up.
      <span style="color: #4ade80;">Wait 5 seconds</span> and the connection succeeds.
    </div>
  </div>
  <!-- Comparison hint -->
  <div style="margin-top: 16px; padding: 12px; background: #1a1a2e; border-radius: 6px; border: 1px dashed #444;">
    <div style="font-size: 11px; color: #888; display: flex; align-items: center; gap: 8px;">
      <span>&#x1F4A1;</span>
      <span><strong style="color: #aaa;">Moto Razr behavior is opposite:</strong> Works immediately, then loses network after 5s. Same error, different root cause.</span>
    </div>
  </div>
</div>
<p><strong>Same error message. Completely different root causes.</strong> This is why EventListener matters.</p>
<h3 id="baseline-performance">Baseline Performance</h3>
<table>
<thead>
<tr>
<th>Device</th>
<th>Network</th>
<th>Cold DNS</th>
<th>TCP</th>
<th>TLS</th>
<th>TTFB</th>
<th>Total</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pixel 9 Pro Fold</td>
<td>JIO 4G</td>
<td>229ms</td>
<td>972ms</td>
<td>665ms</td>
<td>1989ms</td>
<td>1991ms</td>
</tr>
<tr>
<td>Moto Razr 40 Ultra</td>
<td>Airtel</td>
<td><strong>5081ms</strong></td>
<td>1313ms</td>
<td>964ms</td>
<td>7359ms</td>
<td>7362ms</td>
</tr>
</tbody>
</table>
<p>That 5-second DNS on Motorola? Thats not a typo. Airtels DNS is slow on first lookup.</p>
<p><strong>Completely opposite behavior!</strong></p>
<ul>
<li><strong>Pixel</strong>: Fails immediately post-doze, then recovers after 5 seconds</li>
<li><strong>Moto</strong>: Works immediately, then loses network entirely</li>
</ul>
<p>Without EventListener, both would show the same error. With it, you can see the Pixel fails at TCP (SocketTimeoutException), while Moto loses the network interface completely.</p>
<hr>
<h2 id="tldr">TL;DR</h2>
<ol>
<li>
<p><strong>Your logging interceptor is lying.</strong> It shows outcomes, not phases.</p>
</li>
<li>
<p><strong><code>callTimeout</code> is the only timeout that matters</strong> for user experience.</p>
</li>
<li>
<p><strong>EventListener exists.</strong> Use it. You&apos;ll finally understand your network failures.</p>
</li>
<li>
<p><strong>Add distributed tracing</strong> if you need end-to-end visibility across services.</p>
</li>
<li>
<p><strong>DNS is usually the culprit</strong> for those mysterious 30+ second timeouts.</p>
</li>
</ol>
<hr>
<p><em>The <code>UnknownHostException</code> you&apos;ve been catching with a generic error message? It deserves better. Your users certainly do.</em></p>
<hr>
<h2 id="what-this-means-for-you">What This Means For You</h2>
<p>EventListener transforms network debugging from guesswork into science. Instead of:</p>
<blockquote>
<p>&quot;Users are reporting slow network. Maybe its the backend?&quot;</p>
</blockquote>
<p>You get:</p>
<blockquote>
<p>&quot;Airtel users on Motorola devices have 5s DNS resolution. Jio users on Pixel fail TCP immediately after doze but recover in 5s. Backend is fine - its carrier DNS and OS power management.&quot;</p>
</blockquote>
<p>Thats the difference between filing a ticket with your backend team and actually fixing the problem.</p>
<h3 id="your-action-items">Your Action Items</h3>
<ol>
<li><strong>Add EventListener to your OkHttp client</strong> - 50 lines of code, infinite debugging value</li>
<li><strong>Log phase timings to your analytics</strong> - segment by carrier, device, network type</li>
<li><strong>Test doze recovery on YOUR devices</strong> - the behavior varies wildly</li>
<li><strong>Set callTimeout</strong> - its the only timeout that reflects user experience</li>
</ol>
<h3 id="try-it-yourself">Try It Yourself</h3>
<p>I open-sourced the test app: <strong><a href="https://github.com/aldefy/okhttp-network-metrics?ref=aditlal.dev">github.com/aldefy/okhttp-network-metrics</a></strong></p>
<p>Run it on your devices. See what YOUR carriers DNS looks like. Find out how YOUR devices recover from doze.</p>
<p><strong>Download the test app &#x2014; run Baseline + Post-Doze, and <a href="https://x.com/aditlal?ref=aditlal.dev">DM me your results on Twitter</a>. Ill add your device to this post.</strong></p>
<p>Because the network will always be unreliable - but now you can see exactly where and why.</p>
<hr>
<p><em>The next time someone says &quot;its a network issue&quot; youll know which part of the network, on which carrier, on which device, under which conditions. Thats not debugging - thats engineering.</em></p>
<hr>
<p><strong>Tags:</strong> Android, OkHttp, Kotlin, Network, Observability, OpenTelemetry</p>
]]></content:encoded></item><item><title><![CDATA[Building a Design System with Jetpack Compose - Andromeda]]></title><description><![CDATA[Blog series explaining the thought process of how to build a custom design system and what went into building a new Jetpack compose open-source library called Andromeda.]]></description><link>https://aditlal.dev/design-systems-with-jetpack-compose/</link><guid isPermaLink="false">696a519f5bc9cc89ec009d74</guid><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Tue, 08 Feb 2022 09:47:23 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1551650975-87deedd944c3?w=1200" medium="image"/><content:encoded><![CDATA[<!--kg-card-begin: markdown--><h1 id="buildingadesignsystemwithjetpackcomposeandromeda">Building a Design System with Jetpack Compose - Andromeda</h1>
<img src="https://images.unsplash.com/photo-1551650975-87deedd944c3?w=1200" alt="Building a Design System with Jetpack Compose - Andromeda"><p>Feb 08, 2022<a href="https://www.facebook.com/sharer/sharer.php?u=https://aditlal.dev/design-systems-with-jetpack-compose/"></a><a href="https://twitter.com/intent/tweet?text=Building+a+Design+System+with+Jetpack+Compose+-+Andromeda&amp;url=https%3A%2F%2Faditlal.dev%2Fdesign-systems-with-jetpack-compose%2F&amp;ref=aditlal.dev"></a></p>
<p>In today&apos;s world of Modern Android Development, a consistent user interface layer of our mobile app is now more critical than ever. Moreover, with the Jetpack Compose framework, things have never been more fun and straightforward.</p>
<p>In this post, we look at building a complex Design system for our Android apps. What is a design system, one may ask? It is a set of standards to manage design at scale by reducing redundancy while creating a shared language and visual consistency across different pages and channels.</p>
<p>Design systems, when implemented well, can provide many benefits to a design team, and thereby the usage of a Design system can then benefit the engineers on the team as follows:</p>
<ul>
<li>It can help create a visual consistency across products, channels, and different teams.</li>
<li>It can be a tailor-made solution based explicitly on product teams&apos; requirements. An in-house Design System will adapt to the company&apos;s needs and not the other way around.</li>
<li>An open-source Design system allows high-quality and consistent products built with less effort in design and development as it is a ready-made solution waiting to be adopted.</li>
<li>Most importantly, it allows everyone on the team to create/reuse user interface components that give consistency to products, thereby bringing focus to a consistent user experience.</li>
</ul>
<p>Google provides an excellent Design System framework called <a href="https://material.io/?ref=aditlal.dev">Material Design</a> which lets us start with simple yet powerful drop-in components to cover most of the everyday use cases. However, if we focus on a complex world use case, a question arises - What if the Design System that we use can be platform-independent, i.e., Most of the components and colors and branding used across not just for Android apps but also on cross-platform such as Web, iOS, Desktop - the Material Design does not fit perfectly in such cases in my opinion.</p>
<blockquote></blockquote>
<p>What design system do you use with your Jetpack compose apps?Also what would you customise <a href="https://twitter.com/hashtag/AndroidDev?src=hash&amp;ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">#AndroidDev</a><a href="https://twitter.com/hashtag/JetpackCompose?src=hash&amp;ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">#JetpackCompose</a><br>
&#x2014; Adit Lal (@aditlal) <a href="https://twitter.com/aditlal/status/1486397524164616192?ref_src=twsrc%5Etfw&amp;ref=aditlal.dev">January 26, 2022</a></p>
<p>Investing to build a design system in-house can get very time-consuming, and you need a dedicated member/s of your team to always keep things updated, documented, and answer any questions that may arise during the development lifecycle. For example, when components need to be tweaked, with the intention being that once these components are built, it becomes very simple to rapidly build new feature screens. Anyone designing or building pages should have a very clear understanding of what is and isn&apos;t a &quot;top-level&quot; component, how interactions with these components work, how these components would react to new semantics provided by call sites, and more.</p>
<p>So, today I introduce you to a brand new library for Jetpack Compose Andromeda - an open-source design system with custom components and a customizable theme that focuses on the following key areas:</p>
<ul>
<li>The library should adhere to a clear design spec to ensure &quot;top-level&quot; components and their subcomponents variations are easy to use and adopt well to provided semantic colors and can be operated very easily as there would be documented guidelines.</li>
<li>We should be able to cross-reference components and their implementation via a Catalog app which would be ever-growing with new and improved versions as the library evolves.</li>
<li>The library should be clear and concise with easy solutions for components.</li>
<li>Have I mentioned that there is plenty of documentation for every tiny bit?</li>
<li>Add tools / CLI companion helpers to enable library users to scale the design and customize to need and build a brand with their own typography/colors/illustrations and/or icons representing the Tokens in this Design system.</li>
<li>Top-level or, in other words, first-class citizens of the library can be basic stateful composable components that just work out of the box and alongside them are more complex and compound components that are use case/feature specific.</li>
<li>Reusability and well-defined structure are vital.</li>
</ul>
<p>I will be covering more such details in the following posts in this Blog series, which will help detail the thought process on how I managed to build a custom design system for Jetpack Compose. Stay tuned, in the meanwhile check this out a snapshot of the Catalog app :</p>
<p><img src="https://res-2.cloudinary.com/hbkfylujb/image/upload/q_auto/v1/ghost-blog-images/video_cast-2.gif" alt="Building a Design System with Jetpack Compose - Andromeda" loading="lazy"><br>
Catalog app showcasing a circular reval and different components working closely with custom theme.</p>
<p><a href="https://aditlal.dev/author/adit/"></a></p>
<h4 id="aditlal"><a href="https://aditlal.dev/author/adit/">Adit Lal</a></h4>
<ul>
<li>
<p><a href="https://aditlal.dev/"></a></p>
</li>
<li>
<p><a href="https://twitter.com/@aditlal?ref=aditlal.dev"></a></p>
<pre><code>No results for your search, please try with something else.
</code></pre>
</li>
</ul>
<p>Adit Lal &#xA9; 2022&#xA0; &#x2022; &#xA0;Published with <a href="https://ghost.org/?ref=aditlal.dev">Ghost</a><br>
<a href="https://twitter.com/aditlal?ref=aditlal.dev"></a></p>
<p><a href="https://aditlal.dev/web/20220208101530/https://www.aditlal.dev/assets/html/javascript.html?v=d417ba5633">JavaScript license information</a></p>
<p><img src="https://ghostboard.io/api/noscript/5c91196b2037d703f241becd/pixel.gif" alt="Building a Design System with Jetpack Compose - Andromeda" loading="lazy"></p>
<h1 id="cookiebarbackground090a0bheightautolineheight24pxcolorffftextaligncenterpadding3px0">cookie-bar {background:#090a0b; height:auto; line-height:24px; color:#fff; text-align:center; padding:3px 0;}</h1>
<h1 id="cookiebarfixedpositionfixedtop0left0width100">cookie-bar.fixed {position:fixed; top:0; left:0; width:100%;}</h1>
<h1 id="cookiebarfixedbottombottom0topauto">cookie-bar.fixed.bottom {bottom:0; top:auto;}</h1>
<h1 id="cookiebarpmargin0padding0">cookie-bar p {margin:0; padding:0;}</h1>
<h1 id="cookiebaracolorffffffdisplayinlineblockborderradius3pxtextdecorationnonepadding06pxmarginleft8px">cookie-bar a {color:#ffffff; display:inline-block; border-radius:3px; text-decoration:none; padding:0 6px; margin-left:8px;}</h1>
<h1 id="cookiebarcbenablebackground26a8ed">cookie-bar .cb-enable {background:#26a8ed;}</h1>
<h1 id="cookiebarcbenablehoverbackground26a8ed">cookie-bar .cb-enable:hover {background:#26a8ed;}</h1>
<h1 id="cookiebarcbdisablebackground26a8ed">cookie-bar .cb-disable {background:#26a8ed;}</h1>
<h1 id="cookiebarcbdisablehoverbackground26a8ed">cookie-bar .cb-disable:hover {background:#26a8ed;}</h1>
<h1 id="cookiebarcbpolicybackground26a8ed">cookie-bar .cb-policy {background:#26a8ed;}</h1>
<h1 id="cookiebarcbpolicyhoverbackground26a8ed">cookie-bar .cb-policy:hover {background:#26a8ed;}</h1>
<!--kg-card-end: markdown-->]]></content:encoded></item><item><title><![CDATA[Learning from failures at scale]]></title><description><![CDATA[Some fun and interesting bits of how do we manage a super app at @gojek  - What goes into making and maintaining large projects.  - How does a developer cater to new features.  - What are some of the troubles they face.  - How do junior developers collaborate with senior]]></description><link>https://aditlal.dev/learning-from-failures-at-scale/</link><guid isPermaLink="false">696a519f5bc9cc89ec009d73</guid><category><![CDATA[Testing]]></category><category><![CDATA[Failures]]></category><category><![CDATA[Scale]]></category><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sat, 07 Nov 2020 12:22:01 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1504639725590-34d0984388bd?w=1200" medium="image"/><content:encoded><![CDATA[<!--kg-card-begin: markdown--><h1 id="learningfromfailuresatscale">Learning from failures at scale</h1>
<img src="https://images.unsplash.com/photo-1504639725590-34d0984388bd?w=1200" alt="Learning from failures at scale"><p><a href="https://aditlal.dev/tag/testing/">testing</a>&#x2022;Nov 07, 2020<a href="https://www.facebook.com/sharer/sharer.php?u=https://aditlal.dev/learning-from-failures-at-scale/"></a><a href="https://twitter.com/intent/tweet?text=Learning+from+failures+at+scale&amp;url=https%3A%2F%2Faditlal.dev%2Flearning-from-failures-at-scale%2F&amp;ref=aditlal.dev"></a></p>
<p>Some fun and interesting bits of how do we manage a super app at <a href="https://www.gojek.io/?ref=aditlal.dev">@gojek</a>- What goes into making and maintaining large projects.- How does a developer cater to new features.- What are some of the troubles they face.- How do junior developers collaborate with senior developers.- What does it take to cut it and work with an amazing team.- How do engineers troubleshoot and some tips to managing tech debts and finding balance.- Avoiding burnout and enjoy the process.Watch it <a href="https://youtu.be/S9zNw1EQa_4?ref=aditlal.dev">here</a><br>
<a href="https://aditlal.dev/author/adit/"></a></p>
<h4 id="aditlal"><a href="https://aditlal.dev/author/adit/">Adit Lal</a></h4>
<h3 id="recommendedforyou">Recommended for you</h3>
<h3 id="nopostsfound">No posts found</h3>
<pre><code>    Apparently there are no posts at the moment, check again later.
  

  No results for your search, please try with something else.
</code></pre>
<p>Adit Lal &#xA9; 2020&#xA0; &#x2022; &#xA0;Published with <a href="https://ghost.org/?ref=aditlal.dev">Ghost</a><br>
<a href="https://twitter.com/aditlal?ref=aditlal.dev"></a></p>
<p><a href="https://aditlal.dev/web/20201111104751/https://www.aditlal.dev/assets/html/javascript.html?v=6542312e47">JavaScript license information</a></p>
<p><img src="https://ghostboard.io/api/noscript/5c91196b2037d703f241becd/pixel.gif" alt="Learning from failures at scale" loading="lazy"></p>
<h1 id="cookiebarbackground090a0bheightautolineheight24pxcolorffftextaligncenterpadding3px0">cookie-bar {background:#090a0b; height:auto; line-height:24px; color:#fff; text-align:center; padding:3px 0;}</h1>
<h1 id="cookiebarfixedpositionfixedtop0left0width100">cookie-bar.fixed {position:fixed; top:0; left:0; width:100%;}</h1>
<h1 id="cookiebarfixedbottombottom0topauto">cookie-bar.fixed.bottom {bottom:0; top:auto;}</h1>
<h1 id="cookiebarpmargin0padding0">cookie-bar p {margin:0; padding:0;}</h1>
<h1 id="cookiebaracolorffffffdisplayinlineblockborderradius3pxtextdecorationnonepadding06pxmarginleft8px">cookie-bar a {color:#ffffff; display:inline-block; border-radius:3px; text-decoration:none; padding:0 6px; margin-left:8px;}</h1>
<h1 id="cookiebarcbenablebackground26a8ed">cookie-bar .cb-enable {background:#26a8ed;}</h1>
<h1 id="cookiebarcbenablehoverbackground26a8ed">cookie-bar .cb-enable:hover {background:#26a8ed;}</h1>
<h1 id="cookiebarcbdisablebackground26a8ed">cookie-bar .cb-disable {background:#26a8ed;}</h1>
<h1 id="cookiebarcbdisablehoverbackground26a8ed">cookie-bar .cb-disable:hover {background:#26a8ed;}</h1>
<h1 id="cookiebarcbpolicybackground26a8ed">cookie-bar .cb-policy {background:#26a8ed;}</h1>
<h1 id="cookiebarcbpolicyhoverbackground26a8ed">cookie-bar .cb-policy:hover {background:#26a8ed;}</h1>
<!--kg-card-end: markdown-->]]></content:encoded></item><item><title><![CDATA[Ad-hoc polymorphism in JSON with Kotlin]]></title><description><![CDATA[This post describes a way of using the GSON and Mosi library with Kotlin data classes with Polymorphic JSON data with null-safety and default values.]]></description><link>https://aditlal.dev/polymorphic-json/</link><guid isPermaLink="false">696a519f5bc9cc89ec009d71</guid><category><![CDATA[Json]]></category><category><![CDATA[Android]]></category><category><![CDATA[Kotlin]]></category><category><![CDATA[Gson]]></category><category><![CDATA[Moshi]]></category><category><![CDATA[Parsing]]></category><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sun, 02 Jun 2019 10:18:00 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=1200" medium="image"/><content:encoded><![CDATA[<!--kg-card-begin: markdown--><h1 id="adhocpolymorphisminjsonwithkotlin">Ad-hoc polymorphism in JSON with Kotlin</h1>
<img src="https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=1200" alt="Ad-hoc polymorphism in JSON with Kotlin"><p><a href="https://aditlal.dev/tag/json/">json</a>&#x2022;Jun 02, 2019<a href="https://www.facebook.com/sharer/sharer.php?u=https://aditlal.dev/polymorphic-json/"></a><a href="https://twitter.com/intent/tweet?text=Ad-hoc+polymorphism+in+JSON+with+Kotlin&amp;url=https%3A%2F%2Faditlal.dev%2Fpolymorphic-json%2F&amp;ref=aditlal.dev"></a></p>
<p>For a long time now JSON is a de facto standard for all kinds of data serialization between client and server. Among other, its strengths are simplicity and human-readability. But with simplicity comes some limitations, one of them I would like to talk about today: storing and retrieving polymorphic objects.</p>
<p>The need to parse JSON and also convert objects to JSON is pretty much universal, so in all likeliness, you are already using a JSON library in your code.</p>
<p>First of, there is the <a href="https://github.com/KotlinBy/awesome-kotlin?ref=aditlal.dev#libraries-frameworks-json">Awesome-Kotlin</a> list about JSON libraries. Then, there are multiple articles like this one, talking about how to handle <a href="https://engineering.kitchenstories.io/data-classes-and-parsing-json-a-story-about-converting-models-to-kotlin-caf8a599df9e?ref=aditlal.dev">Kotlin data classes with JSON</a>.</p>
<p>We want to use Kotlin data classes for concise code, non-nullable types for null-safety and default arguments for the data class constructor to work when a field is missing in a given JSON. We also would probably want explicit exceptions when the mapping fails completely (required field missing). We also want near zero overhead automatic mapping from JSON to objects and in reverse. On android, we also want a small APK size, so a reduced number of dependencies and small libraries. Therefore:</p>
<ul>
<li>We don&#x2019;t want to use android&#x2019;s org.json , because it has very limited capabilities and no mapping functionality at all.</li>
<li>To my knowledge, to make use of the described Kotlin features like null-safety and default arguments, all libraries supporting Kotlin fully use kotlin-reflect , which is around 2MB in size and therefore might not be an option.</li>
<li>We might not have the ability to use a library like Moshi with integrated Kotlin support, because we already use the popular Gson or Jackson library used in the project.</li>
</ul>
<p>This post describes a way of using the GSON and Mosi library with Kotlin data classes and the least amount of overhead possible of achieving a mapping of JSON to Kotlin data classes with null-safety and default values with Polymorphic JSON data.</p>
<p>First we need to understand the &#xA0;polymorphism on the data we are trying to parse:</p>
<blockquote>
<p>Polymorphism by field value, aka discriminator - help&apos;s detect the object type, an API can add the discriminator/propertyName keyword to model definitions. This keyword points to the property that specifies the data type.</p>
</blockquote>
<h4 id="discriminatorembeddedinobjectpolymorphicclasses">Discriminator embedded in object -polymorphic classes</h4>
<pre><code>[
   {
      &quot;type&quot;:&quot;CIRCLE&quot;,
      &quot;radius&quot;:10.0
   },
   {
      &quot;type&quot;:&quot;RECTANGLE&quot;,
      &quot;width&quot;:20.0
   }
]

</code></pre>
<p>In this case , because only Circle has radius field, first object from list will be deserialized in Circle class.</p>
<p>One solution for this could be :</p>
<pre><code>sealed class Shape
class Circle(val radius: Double) : Shape
class Rectangle(val width: Double) : Shape

</code></pre>
<h4 id="discriminatorisexternalpolymorphicfields">Discriminator is external - &#xA0;polymorphic fields</h4>
<pre><code>[
   {
      &quot;type&quot;:&quot;CIRCLE&quot;,
      &quot;data&quot;:{
         &quot;radius&quot;:10.0
      }
   },
   {
      &quot;type&quot;:&quot;RECTANGLE&quot;,
      &quot;data&quot;:{
         &quot;width&quot;:20.0
      }
   }
]

</code></pre>
<p>In this case , since the discriminator is external , we need a mechanism to decide the data type and deserialize our JSON to our respective data classes.</p>
<p>GSON performs the serialization/deserialization of objects using its inbuilt adapters. It also supports custom adapters.</p>
<p>Imagine the API returns a list of family members, which have few different types of members. There are a few dogs, cats and some humans , an there is no particular order.</p>
<pre><code>{
   &quot;family&quot;:[
      {
         &quot;id&quot;:&quot;5c91012fdbd7835c6720a578&quot;,
         &quot;members&quot;:[
            {
               &quot;id&quot;:&quot;5c91012f57e3c8f1f54499be&quot;,
               &quot;type&quot;:2,
               &quot;data&quot;:{
                  &quot;photo&quot;:&quot;http://placehold.it/32x32&quot;,
                  &quot;name&quot;:&quot;sit&quot;,
                  &quot;tag&quot;:{
                     &quot;id&quot;:&quot;5c91012fb0ae1089c92057a4&quot;,
                     &quot;city&quot;:&quot;Manchester&quot;
                  }
               }
            },
            {
               &quot;id&quot;:&quot;5c91012fb79ec88645ad7f69&quot;,
               &quot;type&quot;:3,
               &quot;data&quot;:{
                  &quot;photo&quot;:&quot;http://placehold.it/32x32&quot;,
                  &quot;name&quot;:&quot;tempor&quot;,
                  &quot;color&quot;:&quot;black&quot;
               }
            },
            {
               &quot;id&quot;:&quot;5c91012fb2e05582cbb207da&quot;,
               &quot;type&quot;:1,
               &quot;data&quot;:{
                  &quot;photo&quot;:&quot;http://placehold.it/32x32&quot;,
                  &quot;name&quot;:&quot;magna&quot;,
                  &quot;sex&quot;:&quot;male&quot;
               }
            },
            {
               &quot;id&quot;:&quot;5c91012fa77bba8d3a2f7e1a&quot;,
               &quot;type&quot;:1,
               &quot;data&quot;:{
                  &quot;photo&quot;:&quot;http://placehold.it/32x32&quot;,
                  &quot;name&quot;:&quot;aliqua&quot;,
                  &quot;sex&quot;:&quot;female&quot;
               }
            }
         ]
      }
   ],
   &quot;total_count&quot;:4
}

</code></pre>
<p>To parse this , we would :</p>
<p>Parse the JSON and break down each type into subtypes , we have 3 subtypes - Dog , Cat , and Human.</p>
<pre><code>const val HUMAN_TYPE = &quot;human&quot;
const val DOG_TYPE = &quot;dog&quot;
const val CAT_TYPE = &quot;cat&quot;

</code></pre>
<p>We need to register our JSON sub types with GSON</p>
<pre><code>private fun initGSON() {
    GsonBuilder().registerTypeAdapterFactory(getTypeAdapterFactory())
            .create()
}

private fun getTypeAdapterFactory(): RuntimeTypeAdapterFactory&lt;DataT&gt; {
    return RuntimeTypeAdapterFactory
        .of&lt;DataT&gt;(DataT::class.java, &quot;data_type&quot;)
        .registerSubtype(DogDataT::class.java, DOG_TYPE)
        .registerSubtype(CatDataT::class.java, CAT_TYPE)
        .registerSubtype(HumanDataT::class.java, HUMAN_TYPE)
}

</code></pre>
<p>For each of these subtypes we have few parameters common and can be abstracted in our base type class :</p>
<pre><code>sealed class Data(
    @SerializedName(&quot;name&quot;)
    val name: String = &quot;&quot;,
    @SerializedName(&quot;photo&quot;)
    val photo: String = &quot;&quot;,
    @SerializedName(&quot;type&quot;)
    val type: Int
)

</code></pre>
<p>This sealed class becomes our base to extend functionality for classifying our types</p>
<pre><code>name 
photo

</code></pre>
<p>name and photo are common for all 3 types , type becomes our discriminator that our JSON library can parse.</p>
<p>The following classes are extending functionality from Data</p>
<pre><code>data class CatData(val color: String) : Data(type = CAT_TYPE)
data class DogData(val tag: Tag) : Data(type = DOG_TYPE)
data class HumanData(val sex: String) : Data(type = HUMAN_TYPE)

</code></pre>
<p>To deserialize our family response JSON , our call site would be :</p>
<pre><code>familyResponse.members.forEach { familyMember -&gt;
            when (familyMember.data.type) {
                DOG_TYPE -&gt; Log.d(
                    &quot;TYPEConverter&quot;,
                    &quot;${familyMember.data.name} is dog ${(familyMember.data as DogData).tag}&quot;
                )
                CAT_TYPE -&gt; Log.d(
                    &quot;TYPEConverter&quot;,
                    &quot;${familyMember.data.name} is cat ${(familyMember.data as CatData).color}&quot;
                )
                HUMAN_TYPE -&gt; Log.d(
                    &quot;TYPEConverter&quot;,
                    &quot;${familyMember.data.name} is human ${(familyMember.data as HumanData).sex} &quot;
                )
            }
        }

</code></pre>
<p>Similarly with Moshi :</p>
<pre><code>private fun initMoshi() {
    Moshi.Builder()
   .add(PolymorphicJsonAdapterFactory.of(DataT::class.java, &quot;data_type&quot;)
       .withSubtype(DogDataT::class.java, DOG_TYPE)
       .withSubtype(CatDataT::class.java, CAT_TYPE)
       .withSubtype(HumanDataT::class.java, HUMAN_TYPE)
      )
   //if you have more adapters, add them before this line:
   .add(KotlinJsonAdapterFactory())
   .build()
}

private fun parseData() {
    val adapter = moshi.adapter&lt;FamilyResponse&gt;(FamilyResponse::class.java)
    val familyResponse = adapter.fromJson(jsonData)
}

</code></pre>
<p>The value offered by ad-hoc polymorphism is very closely tied to the language you&#x2019;re using it in. In other words, it&#x2019;s not a universal tool but one that&#x2019;s heavily dependent on how well supported it is in your language. Ad-hoc polymorphism is obviously a critical component of Haskell and it has given rise to high amounts of reuse and elegant abstractions in that language but I&#x2019;m not sure Kotlin would benefit as much from it.<br>
<a href="https://aditlal.dev/author/adit/"></a></p>
<h4 id="aditlal"><a href="https://aditlal.dev/author/adit/">Adit Lal</a></h4>
<h3 id="recommendedforyou">Recommended for you</h3>
<p><a href="https://aditlal.dev/web/20200814174329/https://www.aditlal.dev/kotlin-dsl-part-5/"></a><a href="https://aditlal.dev/author/adit/"></a><a href="https://aditlal.dev/tag/dsl/">dsl</a>[</p>
<h2 id></h2>
<pre><code>        Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 5 of 5
</code></pre>
<p>a year ago&#x2022;3 min read](/web/20200814174329/<a href="https://www.aditlal.dev/kotlin-dsl-part-5/?ref=aditlal.dev">https://www.aditlal.dev/kotlin-dsl-part-5/</a>)<a href="https://aditlal.dev/web/20200814174329/https://www.aditlal.dev/kotlin-dsl-part-4/"></a><a href="https://aditlal.dev/author/adit/"></a><a href="https://aditlal.dev/tag/dsl/">dsl</a>[</p>
<h2 id></h2>
<pre><code>        Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 4 of 5
</code></pre>
<p>a year ago&#x2022;2 min read](/web/20200814174329/<a href="https://www.aditlal.dev/kotlin-dsl-part-4/?ref=aditlal.dev">https://www.aditlal.dev/kotlin-dsl-part-4/</a>)<a href="https://aditlal.dev/web/20200814174329/https://www.aditlal.dev/kotlin-dsl-part-3/"></a><a href="https://aditlal.dev/author/adit/"></a><a href="https://aditlal.dev/tag/dsl/">dsl</a>[</p>
<h2 id></h2>
<pre><code>        Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 3 of 5
</code></pre>
<p>a year ago&#x2022;3 min read](/web/20200814174329/<a href="https://www.aditlal.dev/kotlin-dsl-part-3/?ref=aditlal.dev">https://www.aditlal.dev/kotlin-dsl-part-3/</a>)</p>
<pre><code>  No results for your search, please try with something else.
</code></pre>
<p>Adit Lal &#xA9; 2020&#xA0; &#x2022; &#xA0;Published with <a href="https://ghost.org/?ref=aditlal.dev">Ghost</a><br>
<a href="https://twitter.com/aditlal?ref=aditlal.dev"></a></p>
<p><a href="https://aditlal.dev/web/20200814174329/https://www.aditlal.dev/assets/html/javascript.html?v=c8499a1711">JavaScript license information</a></p>
<p><img src="https://ghostboard.io/api/noscript/5c91196b2037d703f241becd/pixel.gif" alt="Ad-hoc polymorphism in JSON with Kotlin" loading="lazy"></p>
<h1 id="cookiebarbackground090a0bheightautolineheight24pxcolorffftextaligncenterpadding3px0">cookie-bar {background:#090a0b; height:auto; line-height:24px; color:#fff; text-align:center; padding:3px 0;}</h1>
<h1 id="cookiebarfixedpositionfixedtop0left0width100">cookie-bar.fixed {position:fixed; top:0; left:0; width:100%;}</h1>
<h1 id="cookiebarfixedbottombottom0topauto">cookie-bar.fixed.bottom {bottom:0; top:auto;}</h1>
<h1 id="cookiebarpmargin0padding0">cookie-bar p {margin:0; padding:0;}</h1>
<h1 id="cookiebaracolorffffffdisplayinlineblockborderradius3pxtextdecorationnonepadding06pxmarginleft8px">cookie-bar a {color:#ffffff; display:inline-block; border-radius:3px; text-decoration:none; padding:0 6px; margin-left:8px;}</h1>
<h1 id="cookiebarcbenablebackground26a8ed">cookie-bar .cb-enable {background:#26a8ed;}</h1>
<h1 id="cookiebarcbenablehoverbackground26a8ed">cookie-bar .cb-enable:hover {background:#26a8ed;}</h1>
<h1 id="cookiebarcbdisablebackground26a8ed">cookie-bar .cb-disable {background:#26a8ed;}</h1>
<h1 id="cookiebarcbdisablehoverbackground26a8ed">cookie-bar .cb-disable:hover {background:#26a8ed;}</h1>
<h1 id="cookiebarcbpolicybackground26a8ed">cookie-bar .cb-policy {background:#26a8ed;}</h1>
<h1 id="cookiebarcbpolicyhoverbackground26a8ed">cookie-bar .cb-policy:hover {background:#26a8ed;}</h1>
<!--kg-card-end: markdown-->]]></content:encoded></item><item><title><![CDATA[Kotlin DSL - let's express code in "mini-language" - Part 5 of 5]]></title><description><![CDATA[In this post, we take a look at building our test cases by using a simpler language-like DSL. I wanted a “simple” low overhead way of setting up, expressing and testing many combinations of inputs and outputs.The goal is simple , create DSL for expressing the tests clearly and to]]></description><link>https://aditlal.dev/kotlin-dsl-part-5/</link><guid isPermaLink="false">696a519f5bc9cc89ec009d70</guid><category><![CDATA[Dsl]]></category><category><![CDATA[Kotlin]]></category><category><![CDATA[Android]]></category><dc:creator><![CDATA[Adit Lal]]></dc:creator><pubDate>Sat, 23 Mar 2019 12:50:16 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200" medium="image"/><content:encoded><![CDATA[<!--kg-card-begin: markdown--><h1 id="kotlindslletsexpresscodeinminilanguagepart5of5">Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 5 of 5</h1>
<img src="https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200" alt="Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 5 of 5"><p><a href="https://aditlal.dev/tag/dsl/">dsl</a>&#x2022;Mar 23, 2019<a href="https://www.facebook.com/sharer/sharer.php?u=https://aditlal.dev/kotlin-dsl-part-5/"></a><a href="https://twitter.com/intent/tweet?text=Kotlin+DSL%E2%80%8A-%E2%80%8Alet%27s+express+code+in+%22mini-language%22+-+Part+5+of+5&amp;url=https%3A%2F%2Faditlal.dev%2Fkotlin-dsl-part-5%2F&amp;ref=aditlal.dev"></a></p>
<p>In this post, we take a look at building our test cases by using a simpler language-like DSL.</p>
<p><img src="https://s3-ap-southeast-1.amazonaws.com/adit.dev/2019/03/6a00d8341d3df553ef01157073a04e970c-800wi-1.jpg" alt="Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 5 of 5" loading="lazy"></p>
<blockquote>
<p>I wanted a &#x201C;simple&#x201D; low overhead way of setting up, expressing and testing many combinations of inputs and outputs.</p>
</blockquote>
<p>The goal is simple , create DSL for expressing the tests clearly and to find a concise way of writing tests that makes creating new cases a breeze.</p>
<p>An example of a DSL test case :</p>
<pre><code>@Test
fun logsInWhenUserSelectsLogin() {
    ...
    resetLoginInPref() //sets login pref key as false

    instrumentation.startActivitySync(loginIntent)

    onView(allOf(withId(R.id.login_button), withText(R.string.login)))
          .perform(click())

    val expectedText = context.getString(R.string.is_logged_in, &quot;true&quot;)
    onView(AllOf.allOf(withId(R.id.label), withText(expectedText)))
          .perform(ViewActions.click())
}

</code></pre>
<p>With this , its still pretty good test case , we could improve the syntax to take advantage of a DSL</p>
<h4 id="benefitsconvertingteststodsl">Benefits : Converting Tests to DSL</h4>
<ul>
<li>Correctness: Fix tests that are not exercising the intended target.</li>
<li>Build Speed: Remove Robolectric and PowerMock where they are not needed.</li>
<li>Cruft Clean Up: Clean up test code , annotations and throws that are unnecessary or unneeded.</li>
<li>Readability: Further enhancement of the test style such as increased readability of tests in either // Given or // Then sections by use of method extraction.</li>
<li>Readability Again: Restating the tests in sentences revealed missing assumptions in their names.</li>
</ul>
<p>Simple <a href="https://github.com/RubyLichtenstein/RxTest?ref=aditlal.dev">RxTest</a> is an example of how internal DSL support can build specific domain grammar to test.</p>
<pre><code>// Example of RxTest
Observable.just(&quot;Hello RxTest!&quot;)
 .test {
        it shouldEmit &quot;Hello RxTest!&quot;  
        it should complete() 
        it shouldHave noErrors() 
}

</code></pre>
<h4 id="letsbreakdownourtestcase">Let&apos;s break down our test case</h4>
<ul>
<li>Update preferences to make sure that the user is logged out before the test starts</li>
<li>The user launches the app</li>
<li>The user clicks on &#x201C;Log In&#x201D;</li>
<li>We assert that the user sees the logged in text</li>
</ul>
<p>Setup, actions and assertions.</p>
<pre><code>.redText
{
    color:red;
    font-size: 15px;
    font-weight:bold;
}
.blueText
{
    color:blue;
    font-size: 15px;
    font-weight:bold;
}
.greenText
{
    color:green;
    font-size: 15px;
    font-weight:bold;
} 
.blackText
{
    color:gray;
    font-weight:italic;
    font-size: 15px;
}
</code></pre>
<p>Given the user is logged out<br>
When the user launches the appWhen the user clicks &#x201C;Log In&#x201D;Then the user sees the logged in text</p>
<p>DSL implementation of the same is as follows :</p>
<pre><code>@Test
fun logsInWhenUserSelectsLogin() {
    
    given(user).has().loggedOut();
    
    when(user).launches().app();
    when(user).selects().login();

    then(user).sees().loggedIn();

}
</code></pre>
<p>For this we would use :</p>
<pre><code>infix fun Any.given(block: () -&gt; Unit) = block.invoke()

infix fun Any.whenever(block: () -&gt; Unit) = block.invoke()

infix fun Any.then(block: () -&gt; Unit) = block.invoke()

</code></pre>
<p>We have an infix &amp; extension function which accepts a function block block :()-&gt;Unit and executes it.</p>
<p>What this lets us do is chain the &#x2018;given, when, then&#x2019; calls like a sentence and gets us one step closer to our DSL.</p>
<p>Next we have our User class which is an object class</p>
<p>An object class is not &quot;a static class per-say&quot;, but rather it is a static instance of a class that there is only one of, otherwise known as a singleton.</p>
<p>Perhaps the best way to show the difference is to look at the decompiled Kotlin code in Java form.</p>
<p>Here is a Kotlin object and class:</p>
<pre><code>object ExampleObject {
  fun example() {
  }
}

class ExampleClass {
  fun example() {
  }
}

</code></pre>
<p>In order to use the ExampleClass, you need to create an instance of it: ExampleClass().example(), but with an object, Kotlin creates a single instance of it for you, and you don&apos;t ever call it&apos;s constructor, instead you just access it&apos;s static instance by using the name: ExampleObject.example().</p>
<pre><code>object User {
  infix fun selects(block: SelectsActions.() -&gt; Unit): User {
    block.invoke(SelectsActions)
    return this
  }
}

</code></pre>
<p>Similarly this is an infix function on User. It takes a function with SelectsActions as the receiver, letting us call functions on SelectsActions in the lambda passed in. We invoke the function and return the User so that we can chain actions. The whole function is an infix function so that we can have spaces and makes the call read more like a sentence.</p>
<p>This just leaves the actions and assertions of the test. This is where the actual Espresso code lives, as below :</p>
<pre><code>object SelectsActions {
    fun logout() {
        onView(allOf(withId(R.id.login_button), withText(R.string.logout)))
        .perform(click())
    }

    fun login() {
        onView(allOf(withId(R.id.login_button), withText(R.string.login)))
        .perform(click())
    }
}

</code></pre>
<p>When you put all of the above pieces together you can write nice, human-readable tests.</p>
<p>This concludes this series , Part 5 of 5 - Thanks for sticking around till the end.</p>
<h3 id="summary">Summary</h3>
<blockquote>
<p>[On Kotlin] A general language with lambda receivers and invoke conventionmeans the ability to support internal DSL&#x2019;s. Internal DSL&#x2019;s give the ability for higher level readability and understandability through the use of structured grammar, but the additional benefit&#x200A;&#x2014;&#x200A;that declared languages cannot provide easily&#x200A;&#x2014;&#x200A;is type safely through compilation.</p>
</blockquote>
<h3 id="extras">Extras :</h3>
<p>Some great libraries available to us that provide a DSL interface are :</p>
<p><a href="https://spekframework.org/?ref=aditlal.dev">Spek</a></p>
<p><a href="https://github.com/Kotlin/kotlinx.html?ref=aditlal.dev">KotlinX.Html</a></p>
<p><a href="https://github.com/Kotlin/anko?ref=aditlal.dev">Anko</a></p>
<p><a href="https://aditlal.dev/author/adit/"></a></p>
<h4 id="aditlal"><a href="https://aditlal.dev/author/adit/">Adit Lal</a></h4>
<h3 id="recommendedforyou">Recommended for you</h3>
<p><a href="https://aditlal.dev/web/20200807205117/https://www.aditlal.dev/polymorphic-json/"></a><a href="https://aditlal.dev/author/adit/"></a><a href="https://aditlal.dev/web/20200807205117/https://www.aditlal.dev/polymorphic-json/"></a><a href="https://aditlal.dev/tag/json/">json</a>[</p>
<h2 id></h2>
<pre><code>        Ad-hoc polymorphism in JSON with Kotlin
</code></pre>
<p>a year ago&#x2022;4 min read](/web/20200807205117/<a href="https://www.aditlal.dev/polymorphic-json/?ref=aditlal.dev">https://www.aditlal.dev/polymorphic-json/</a>)<a href="https://aditlal.dev/web/20200807205117/https://www.aditlal.dev/kotlin-dsl-part-4/"></a><a href="https://aditlal.dev/author/adit/"></a><a href="https://aditlal.dev/tag/dsl/">dsl</a>[</p>
<h2 id></h2>
<pre><code>        Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 4 of 5
</code></pre>
<p>a year ago&#x2022;2 min read](/web/20200807205117/<a href="https://www.aditlal.dev/kotlin-dsl-part-4/?ref=aditlal.dev">https://www.aditlal.dev/kotlin-dsl-part-4/</a>)<a href="https://aditlal.dev/web/20200807205117/https://www.aditlal.dev/kotlin-dsl-part-3/"></a><a href="https://aditlal.dev/author/adit/"></a><a href="https://aditlal.dev/tag/dsl/">dsl</a>[</p>
<h2 id></h2>
<pre><code>        Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 3 of 5
</code></pre>
<p>a year ago&#x2022;3 min read](/web/20200807205117/<a href="https://www.aditlal.dev/kotlin-dsl-part-3/?ref=aditlal.dev">https://www.aditlal.dev/kotlin-dsl-part-3/</a>)</p>
<pre><code>  No results for your search, please try with something else.
</code></pre>
<p>Adit Lal &#xA9; 2020&#xA0; &#x2022; &#xA0;Published with <a href="https://ghost.org/?ref=aditlal.dev">Ghost</a><br>
<a href="https://twitter.com/aditlal?ref=aditlal.dev"></a></p>
<p><a href="https://aditlal.dev/web/20200807205117/https://www.aditlal.dev/assets/html/javascript.html?v=f20991ce59">JavaScript license information</a></p>
<p><img src="https://ghostboard.io/api/noscript/5c91196b2037d703f241becd/pixel.gif" alt="Kotlin DSL&#x200A;-&#x200A;let&apos;s express code in &quot;mini-language&quot; - Part 5 of 5" loading="lazy"></p>
<h1 id="cookiebarbackground090a0bheightautolineheight24pxcolorffftextaligncenterpadding3px0">cookie-bar {background:#090a0b; height:auto; line-height:24px; color:#fff; text-align:center; padding:3px 0;}</h1>
<h1 id="cookiebarfixedpositionfixedtop0left0width100">cookie-bar.fixed {position:fixed; top:0; left:0; width:100%;}</h1>
<h1 id="cookiebarfixedbottombottom0topauto">cookie-bar.fixed.bottom {bottom:0; top:auto;}</h1>
<h1 id="cookiebarpmargin0padding0">cookie-bar p {margin:0; padding:0;}</h1>
<h1 id="cookiebaracolorffffffdisplayinlineblockborderradius3pxtextdecorationnonepadding06pxmarginleft8px">cookie-bar a {color:#ffffff; display:inline-block; border-radius:3px; text-decoration:none; padding:0 6px; margin-left:8px;}</h1>
<h1 id="cookiebarcbenablebackground26a8ed">cookie-bar .cb-enable {background:#26a8ed;}</h1>
<h1 id="cookiebarcbenablehoverbackground26a8ed">cookie-bar .cb-enable:hover {background:#26a8ed;}</h1>
<h1 id="cookiebarcbdisablebackground26a8ed">cookie-bar .cb-disable {background:#26a8ed;}</h1>
<h1 id="cookiebarcbdisablehoverbackground26a8ed">cookie-bar .cb-disable:hover {background:#26a8ed;}</h1>
<h1 id="cookiebarcbpolicybackground26a8ed">cookie-bar .cb-policy {background:#26a8ed;}</h1>
<h1 id="cookiebarcbpolicyhoverbackground26a8ed">cookie-bar .cb-policy:hover {background:#26a8ed;}</h1>
<!--kg-card-end: markdown-->]]></content:encoded></item></channel></rss>