<?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:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[ProAndroidDev - Medium]]></title>
        <description><![CDATA[The latest posts from Android Professionals and Google Developer Experts. - Medium]]></description>
        <link>https://proandroiddev.com?source=rss----c72404660798---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>ProAndroidDev - Medium</title>
            <link>https://proandroiddev.com?source=rss----c72404660798---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 10 Oct 2026 18:41:18 GMT</lastBuildDate>
        <atom:link href="https://proandroiddev.com/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[What Every Android Engineer Needs to Know About SHA-1, SHA-256, and Beyond]]></title>
            <link>https://proandroiddev.com/what-every-android-engineer-needs-to-know-about-sha-1-sha-256-and-beyond-5517317038d9?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/5517317038d9</guid>
            <category><![CDATA[android]]></category>
            <category><![CDATA[blockchain]]></category>
            <category><![CDATA[mobile]]></category>
            <category><![CDATA[security]]></category>
            <category><![CDATA[cryptography]]></category>
            <dc:creator><![CDATA[Vikas Soni]]></dc:creator>
            <pubDate>Sat, 10 Oct 2026 17:33:17 GMT</pubDate>
            <atom:updated>2026-10-10T17:33:16.502Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Q9AR3Zzl3veW9veyzKWSPA.png" /></figure><p>As a mobile engineer, you’ve probably seen terms like SHA-1 and SHA-256 pop up everywhere—in your IDE warnings, API documentation, and app signing configurations. But what do they actually mean, and why should you care?</p><p>Think of a cryptographic hash function as a <strong>digital fingerprint</strong> generator. You can feed it any data — an entire APK, a user’s password, a simple string — and it will spit out a unique, fixed-size string of characters called a hash.</p><p>This article will break down what these hashes are, why the old standard (SHA-1) is dangerous, why SHA-256 is the current champ, and what the fast and exciting future looks like.</p><h3>The 3 Golden Rules of Secure Hashes</h3><p>A secure hash function is like a magical, one-way blender. You can put ingredients in and get a smoothie, but you can never turn that smoothie back into the original ingredients. This “magic” relies on three core security properties:</p><ol><li><strong>Pre-image Resistance (The One-Way Street):</strong> Given a hash, you can’t figure out the original input. This is why we store password <em>hashes</em> instead of the passwords themselves. If a database leaks, attackers get a list of hashes, not the actual passwords.</li><li><strong>Second-Preimage Resistance (The No-Imposter Rule):</strong> If you have a file and its hash, nobody can create a <em>different</em> file that produces the same hash. This is crucial for verifying that an app update you’ve downloaded hasn’t been tampered with.</li><li><strong>Collision Resistance (The No-Evil-Twins Rule):</strong> This is the big one. Nobody should be able to find <em>any two different inputs</em> that create the same hash. A failure here is catastrophic, as it means a hacker could craft a malicious file that has the same “fingerprint” as a legitimate one.</li></ol><h3>The Story of SHA-1: The Fallen Hero</h3><p>For years, SHA-1 was the king. It was used for everything: signing apps, securing websites (SSL/TLS certificates), and verifying file integrity. It produces a 160-bit hash.</p><p><strong><em>So, what went wrong? In short, it became too predictable.</em></strong></p><p>The final nail in the coffin came in 2017 with the <strong>SHAttered</strong> attack. Researchers from <strong>Google and CWI Amsterdam</strong> proved it was practical to break SHA-1&#39;s collision resistance. They created two completely different PDF files that had the exact same SHA-1 hash.</p><p>Why this is terrifying for a mobile dev: Imagine if a hacker could create a malicious APK that has the same digital fingerprint as your legitimate app update. They could upload it somewhere, and systems that rely on SHA-1 for verification would see it as authentic. This is why your IDE screams at you if you use SHA-1 in a security context. It’s broken. Don&#39;t use it.</p><h3>SHA-256: The Current, Secure Champion</h3><p>Enter SHA-256. It&#39;s part of the SHA-2 family and is the modern standard for secure hashing. It produces a larger 256-bit hash and is structurally much stronger than SHA-1.</p><p>Here’s a simple breakdown of why it’s better:</p><p>FeatureSHA-1 (The Old Way)SHA-256 (The Modern Way)Fingerprint Size160-bit256-bitInternal &quot;Blender&quot;Simple, linear recipe.Complex, non-linear recipe.</p><p><strong>Where you see SHA-256 today:</strong></p><ul><li><strong>App Signing:</strong> Modern Android app signing schemes (like v2, v3, and v4) rely on the security of SHA-256 to ensure the integrity of your APK.</li><li><strong>Blockchain:</strong> Bitcoin&#39;s famous proof-of-work algorithm uses a double SHA-256 hash. Its security is a testament to the algorithm&#39;s robustness.</li><li><strong>Networking Security:</strong> When you use libraries like OkHttp to communicate with servers, the TLS certificates that secure your connection are signed using strong hashes like SHA-256.</li></ul><h3>The Future: Faster Hashes and Quantum Threats</h3><p>The world of cryptography never stands still. Here’s a look at what’s next.</p><h4>The Need for Speed: Meet BLAKE3</h4><p>While SHA-256 is secure, it&#39;s not the fastest. BLAKE3 is a modern hash function designed for insane performance.</p><p><strong>Its secret weapon? Parallelism.</strong></p><p><strong>Think of it this way:</strong> Hashing a large file with SHA-256 is like one person reading a very long book from start to finish. BLAKE3 is like tearing the book into chapters and giving one to each of your friends to read at the same time. It&#39;s massively faster, especially on the multi-core CPUs in modern devices and servers.</p><p><strong>For mobile devs,</strong> this could mean faster integrity checks for large game assets, quicker processing of large media files, and more.</p><h4>The Sci-Fi Threat: Quantum Computers</h4><p>You may have heard that quantum computers will &quot;break&quot; cryptography. For hashing, the threat comes from Grover&#39;s algorithm, a quantum search algorithm that makes finding a pre-image (guessing the input from the hash) easier.</p><ul><li><strong>The Impact:</strong> It effectively halves the security level of a hash function. For SHA-256, its 256-bit security drops to a 128-bit level.</li><li><strong>Should you panic?</strong> No. A 128-bit security level is still considered computationally impossible to break for the foreseeable future. So, while SHA-256 is weakened by quantum computers, it isn&#39;t broken.</li></ul><p>The crypto community is already preparing for this future by recommending SHA-512 for long-term security (which would have a 256-bit post-quantum security level) and developing entirely new quantum-resistant standards.</p><h3>Key Takeaways for Mobile Engineers</h3><ol><li>Never Use SHA-1 for Security. It&#39;s broken. Listen to your IDE warnings. The same goes for MD5.</li><li>SHA-256 is Your Go-To Standard. It&#39;s secure, trusted, and the industry standard for everything from app signing to data integrity.</li><li>For High Performance, Keep an Eye on BLAKE3. If your app deals with hashing large files and speed is critical, BLAKE3 is the future.</li><li>Don&#39;t Lose Sleep Over Quantum Computers (Yet). SHA-256 is still safe for now, and the industry is already building the next generation of defenses.</li></ol><p>Understanding which hash function to use is a small but crucial part of building secure and reliable applications. By choosing modern, robust algorithms, you&#39;re helping to protect your app, your company, and your users.</p><p>Follow to stay connected and get timely updates. 🙌🏻</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5517317038d9" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/what-every-android-engineer-needs-to-know-about-sha-1-sha-256-and-beyond-5517317038d9">What Every Android Engineer Needs to Know About SHA-1, SHA-256, and Beyond</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Scaling Android Search: Room @Fts4 vs SQL LIKE]]></title>
            <link>https://proandroiddev.com/scaling-android-search-room-fts4-vs-sql-like-b2a8232cbd12?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/b2a8232cbd12</guid>
            <category><![CDATA[android]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[android-development]]></category>
            <dc:creator><![CDATA[Oğuzhan Aslan]]></dc:creator>
            <pubDate>Sat, 10 Oct 2026 17:27:22 GMT</pubDate>
            <atom:updated>2026-10-10T17:27:21.252Z</atom:updated>
            <content:encoded><![CDATA[<p><em>A practical deep-dive into full-text search indexing, inverted indices, and microsecond query benchmarks using Android Jetpack Room.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*O4Wm35zmkfeyGMGQRDBR7g.jpeg" /></figure><p>When building search into an Android application — whether querying an offline-first inbox, notes catalog, documentation cache, or e-commerce catalog — almost every developer starts with the same query:</p><pre>SELECT * FROM mails <br>WHERE subject LIKE &#39;%&#39; || :query || &#39;%&#39; <br>   OR body LIKE &#39;%&#39; || :query || &#39;%&#39;</pre><p>It is intuitive, requires zero schema alterations, and works out-of-the-box. During early development, when your local database contains 25 dummy rows, search queries return instantly. Tests pass, the UI feels snappy, and the feature gets shipped.</p><p>Then reality strikes.</p><p>As users accumulate months of multiline emails, notes, or cached payloads (climbing from hundreds to thousands of records), complaints begin rolling in:</p><ul><li>Keystroke lag when typing in search fields.</li><li>Skipped frames and main-thread micro-stutters.</li><li>Noticeable battery drain during heavy search workflows.</li></ul><p>Why does an operation that felt blazing fast in development fall apart under real-world volume? To understand this, we have to look under the hood of SQLite’s query planner.</p><h3>Why SQL LIKE Fails at Scale</h3><p>In relational databases, developers rely on indices (B-Trees) to keep lookups at <em>O(log N)</em> time. If you query:</p><pre>WHERE sender LIKE &#39;alice%&#39;</pre><p>SQLite <em>can</em> utilize a standard B-Tree index on sender because it knows the prefix.</p><p>However, users rarely search by prefix alone. Real-world full-text search requires <strong>substring matching</strong>: finding a word anywhere within a subject or paragraph. This forces the query into a leading wildcard:</p><pre>WHERE body LIKE &#39;%architecture%&#39;</pre><blockquote>WARNING: The moment a query contains a leading wildcard (%query%), standard B-Tree indices are completely bypassed. SQLite is forced to execute a <strong>sequential full table scan</strong> O(N).</blockquote><p>In a full table scan:</p><ol><li>SQLite reads every single row off the storage layer into memory.</li><li>The query engine steps through every single byte of text sequentially, comparing character by character looking for the target substring.</li><li>If an email body contains 1,500 words and you have 2,000 emails, SQLite inspects up to <strong>3,000,000 words byte-by-byte</strong> for <em>every single keystroke</em>!</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/336/1*5kKjnzDStbkv-sL1XefF-A.png" /></figure><p>Furthermore, standard SQL LIKE is &quot;dumb&quot;:</p><ul><li><strong>No tokenization:</strong> It doesn’t know what a word boundary is. Searching for &quot;cat&quot; will inadvertently match &quot;catalog&quot;, &quot;scatter&quot;, and &quot;vacation&quot;.</li><li><strong>No stemming:</strong> Searching for &quot;optimize&quot; will miss &quot;optimizing&quot;, &quot;optimizer&quot;, or &quot;optimized&quot;.</li><li><strong>No relevance ranking:</strong> Results are unweighted; a match in the title has the same weight as a match tucked away in paragraph four.</li></ul><p>This is where SQLite’s <strong>FTS4 (Full Text Search 4)</strong> engine changes the game.</p><h3>The Solution: How FTS4 and Inverted Indices Work</h3><p>SQLite includes dedicated full-text search extensions (FTS3/FTS4 and FTS5). In Android&#39;s Room persistence library, this is natively supported via the @Fts4 and @Fts3 annotations.</p><h4>The Inverted Index: From O(N) to O(1)</h4><p>Instead of scanning rows at query time, FTS4 does the heavy lifting at <strong>insert time</strong>.</p><p>When a text column is indexed by FTS4, a <strong>tokenizer</strong> breaks the text down into individual words (tokens), normalizes their case, strips punctuation, and builds an <strong>Inverted Index</strong>.</p><p>An inverted index is essentially a word-to-document dictionary:</p><pre>+--------------+-----------------------+<br>| Token        | DocIDs (Posting List) |<br>+--------------+-----------------------+<br>| architecture | [1, 14, 108, 492]     |<br>| benchmark    | [1, 7, 22, 108, 305]  |<br>| database     | [1, 4, 18, 92, 108]   |<br>| security     | [2, 6, 11, 45, 99]    |<br>+--------------+-----------------------+</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/346/1*vr3VjMq9mkHs9wYRB12fUA.png" /></figure><p>When you issue a search query like MATCH &#39;benchmark*&#39;, FTS4:</p><ol><li>Tokenizes the search string.</li><li>Performs an instantaneous lookup in the token dictionary.</li><li>Retrieves the exact list of docid numbers containing the word.</li><li>Returns the records without scanning a single byte of irrelevant text.</li></ol><p>Whether you have 100 rows or 100,000 rows, lookups happen in fractions of a millisecond.</p><h3>Implementation in Android Room</h3><p>A common misconception is that using FTS requires duplicating your data and doubling your database size. With Room’s contentEntity pattern, this is completely avoided.</p><p>Let’s look at the production implementation from our Mail Showcase application.</p><p>First, create your base table containing all raw fields. This remains your single source of truth:</p><pre>// MailEntity.kt<br>import androidx.room.Entity<br>import androidx.room.PrimaryKey<br><br>@Entity(tableName = &quot;mails&quot;)<br>data class MailEntity(<br>    @PrimaryKey(autoGenerate = true)<br>    val id: Long = 0,<br>    val sender: String,<br>    val recipient: String,<br>    val subject: String,<br>    val body: String,<br>    val timestamp: Long = System.currentTimeMillis()<br>)</pre><p>Next, define the virtual FTS entity. By declaring contentEntity = MailEntity::class, Room instructs SQLite to use an <strong>External Content Table</strong>.</p><p>The FTS table maintains <em>only</em> the index structures and token postings. The text itself is fetched on-demand from the base mails table, eliminating storage duplication:</p><pre>import androidx.room.Entity<br>import androidx.room.Fts4<br><br>@Entity(tableName = &quot;mails_fts&quot;)<br>@Fts4(contentEntity = MailEntity::class)<br>data class MailFtsEntity(<br>    val sender: String,<br>    val subject: String,<br>    val body: String<br>)</pre><blockquote>TIP : Under the hood, Room automatically creates SQLite database triggers (AFTER INSERT, AFTER UPDATE, AFTER DELETE) on the mails table. Whenever you insert or update a MailEntity, the mails_fts index updates automatically!</blockquote><p>Register both classes in your @Database declaration:</p><pre>@Database(<br>    entities = [MailEntity::class, MailFtsEntity::class],<br>    version = 1,<br>    exportSchema = false<br>)<br>abstract class MailDatabase : RoomDatabase() {<br>    abstract fun mailDao(): MailDao<br>}</pre><p>Now we can compare both queries in our MailDao.</p><p>Notice how the FTS query joins mails.id against mails_fts.docid:</p><pre>@Dao<br>interface MailDao {<br><br>    @Insert(onConflict = OnConflictStrategy.REPLACE)<br>    suspend fun insertMails(mails: List&lt;MailEntity&gt;): List&lt;Long&gt;<br><br>    /**<br>     * Standard SQL LIKE operator search.<br>     * Triggers an O(N) sequential scan across every row.<br>     */<br>    @Query(<br>        &quot;&quot;&quot;<br>        SELECT * FROM mails<br>        WHERE subject LIKE &#39;%&#39; || :query || &#39;%&#39;<br>           OR body LIKE &#39;%&#39; || :query || &#39;%&#39;<br>           OR sender LIKE &#39;%&#39; || :query || &#39;%&#39;<br>        ORDER BY timestamp DESC<br>        LIMIT :limit<br>        &quot;&quot;&quot;<br>    )<br>    suspend fun searchMailsLike(query: String, limit: Int = 200): List&lt;MailEntity&gt;<br><br>    /**<br>     * SQLite FTS4 (Full Text Search) MATCH query.<br>     * Uses inverted index token lookups.<br>     */<br>    @Query(<br>        &quot;&quot;&quot;<br>        SELECT mails.* FROM mails<br>        JOIN mails_fts ON mails.id = mails_fts.docid<br>        WHERE mails_fts MATCH :query<br>        ORDER BY mails.timestamp DESC<br>        LIMIT :limit<br>        &quot;&quot;&quot;<br>    )<br>    suspend fun searchMailsFts(query: String, limit: Int = 200): List&lt;MailEntity&gt;<br>}</pre><p>To provide an “as-you-type” search experience, sanitize the input and append a wildcard * to the tokens so that typing &quot;arch&quot; matches &quot;architecture&quot;:</p><pre>private fun sanitizeFtsQuery(query: String): String {<br>    val sanitized = query.replace(&quot;\&quot;&quot;, &quot;&quot;).replace(&quot;&#39;&quot;, &quot;&quot;).trim()<br>    val tokens = sanitized.split(&quot;\\s+&quot;.toRegex()).filter { it.isNotEmpty() }<br>    if (tokens.isEmpty()) return &quot;&quot;<br><br>    // Appending &#39;*&#39; enables fast token prefix matching in FTS<br>    return tokens.joinToString(&quot; &quot;) { &quot;$it*&quot; }<br>}</pre><p>To measure real-world performance, we built a benchmarking suite into the showcase app that measures database execution duration down to the microsecond (measureNanoTime) on identical datasets.</p><p>We populated the database with multiline emails and measured search latency across dataset scales:</p><pre>+--------------+-------------------------+-------------------------+----------------+<br>| Dataset      | SQL LIKE Latency        | Room @Fts4 MATCH        | Speedup Factor |<br>+--------------+-------------------------+-------------------------+----------------+<br>| 100 mails    |  2.840 ms (39 matches)  |  1.800 ms (39 matches)  | 1.6x faster    |<br>| 500 mails    |  8.114 ms (179 matches) |  3.892 ms (179 matches) | 2.1x faster    |<br>| 2,000 mails  | 30.181 ms (200 matches) | 13.071 ms (200 matches) | 2.3x faster    |<br>| 5,000 mails  | 63.762 ms (200 matches) |  6.811 ms (200 matches) | 9.4x faster    |<br>+--------------+-------------------------+-------------------------+----------------+</pre><pre>Query Execution Time vs Dataset Size<br>Latency (ms)<br>  70 ┼                                                  ● SQL LIKE (63.8 ms)<br>  60 ┼                                                 <br>  50 ┼                                                <br>  40 ┼                                              <br>  30 ┼                                  ● (30.2 ms) <br>  20 ┼                                              <br>  10 ┼                    ● (8.1 ms)             ● FTS4 (6.8 ms)<br>   0 ┼───● (2.8 ms)───────● (3.9 ms)────● (13.1 ms)──────<br>     └──────┬─────────────────┬──────────────┬──────────────┬────────<br>          100               500           2,000          5,000<br>                                 Email Records</pre><h4>Key Observations:</h4><ol><li><strong>The 16ms Frame Budget Barrier</strong>: At 2,000 emails, SQL LIKE takes ~30.18 ms, breaching the ~16.6 ms frame target for 60Hz displays (and ~8.3 ms for 120Hz displays). At 5,000 emails (~63.76 ms), executing LIKE on or near the UI thread causes dropped frames and noticeable UI stutter.</li><li><strong>Diverging Performance at Scale</strong>: While FTS4 offers a steady 1.6x–2.3x improvement on smaller to medium datasets, its advantage explodes at 5,000 records, executing <strong>9.4x faster</strong> than LIKE (~6.81 ms vs. ~63.76 ms).</li><li><strong>Prefix Support</strong>: FTS4 handles arch* queries instantly because token prefixes are traversed directly in the index structure.</li></ol><h3>When to Use Which</h3><pre>| Feature                   | SQL LIKE &#39;%query%&#39;                | Room @Fts4 (MATCH)                           |<br>+---------------------------+-----------------------------------+----------------------------------------------+<br>| Algorithmic Complexity    | O(N) Sequential full table scan   | O(1) / Sublinear Inverted index lookup       |<br>| Execution Time at Scale   | Degrades linearly with row count  | Remains consistently in low single-digit ms  |<br>| Word Boundary Awareness   | No (matches substrings blindly)   | Yes (tokenized at word boundaries)           |<br>| Stemming Support          | No                                | Yes (Porter stemmer available)               |<br>| Prefix Matching           | Wildcards break B-Tree index      | Native (MATCH &#39;token*&#39;)                      |<br>| Storage Impact            | Zero overhead                     | Minimal index metadata (via contentEntity)   |<br>| Insert Overhead           | Minimal                           | Slight cost during insert to update index    |<br>+---------------------------+-----------------------------------+----------------------------------------------+</pre><h3>Conclusion</h3><p>SQL LIKE is a wonderful tool for prototyping or querying discrete code columns (like status codes or short identifiers). But the moment your application handles human text—messages, documents, notes, product catalogs—relying on LIKE &#39;%...%&#39; is a ticking performance bottleneck.</p><p>By incorporating Room’s @Fts4 with the contentEntity pattern:</p><ul><li>You retain clean, relational entities as your source of truth.</li><li>You gain high-performance inverted index lookups that scale effortlessly into tens of thousands of items.</li><li>Your UI remains silky smooth, respecting the user’s frame budget and battery life.</li></ul><p>If your Android app has a search bar querying text fields, don’t wait for your dataset to grow in production. Migrate to Room FTS and deliver an instant search experience your users will love.</p><h4>References</h4><p><a href="https://developer.android.com/reference/androidx/room/Fts4">Fts4 | API reference | Android Developers</a></p><p><a href="https://www.linkedin.com/in/o%C4%9Fuzhan-aslan-sofdev/">LinkedIn</a></p><p><a href="https://www.youtube.com/@oguzhanaslannDev">Youtube</a></p><p><strong><em>Love you all.</em></strong></p><p><strong><em>Stay tune for upcoming blogs.</em></strong></p><p><strong><em>Take care.</em></strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b2a8232cbd12" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/scaling-android-search-room-fts4-vs-sql-like-b2a8232cbd12">Scaling Android Search: Room @Fts4 vs SQL LIKE</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How I Built Free and Full Build Flavors for Android in my KMP codebase]]></title>
            <link>https://proandroiddev.com/how-i-built-free-and-full-build-flavors-for-android-in-my-kmp-codebase-511bffdcc28c?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/511bffdcc28c</guid>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[gradle]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[kotlin-multiplatform]]></category>
            <category><![CDATA[android-app-development]]></category>
            <dc:creator><![CDATA[Avik Sharma Chowdhury]]></dc:creator>
            <pubDate>Sat, 10 Oct 2026 17:17:07 GMT</pubDate>
            <atom:updated>2026-10-10T17:17:06.501Z</atom:updated>
            <content:encoded><![CDATA[<h3>How I Built Free and Full Build Flavors for Android in My KMP Codebase</h3><h4>Part 3 of my KMP series: how Android product flavors let the free version of my app leave the sign-in, sync and payment code out of the build</h4><p><a href="https://avik-sharma-chy.medium.com/list/building-an-exam-prep-app-with-kotlin-multiplatform-8f79a357f3d2">List: Building an Exam Prep App with Kotlin Multiplatform | Curated by Avik Sharma Chowdhury | Medium</a></p><p>In <a href="https://avik-sharma-chy.medium.com/from-idea-to-google-play-the-challenges-of-my-first-kmp-app-211d948af33b">Part 1</a>, I shared how my plan changed after completing the closed testing with MVP. I decided to launch the app for free and keep the paid features (sign-in, cloud sync and a premium subscription) ready for later. In <a href="https://avik-sharma-chy.medium.com/how-i-structured-my-first-kmp-app-modules-layers-and-source-sets-43cda8cbebf6?sharedUserId=avik-sharma-chy">Part 2</a>, I walked through how the project is organized.</p><p>This part is about how one codebase builds both versions: a free app with only the core features, and a full app with everything. I’ll cover product flavors, how the free app leaves the premium features out, and how the shared code works with either version without knowing which one it’s in.</p><p>One note before we start: product flavors are an Android feature, and everything in this part is about the Android app. On iOS, I plan to release the full version, so the iOS code simply includes all the features.</p><h3>Why Two Builds?</h3><p>My first attempt was introducing a feature flag that hid the premium features. The free version still included the <strong>Auth</strong> and <strong>Payment</strong> SDKs and still asked for the billing permission, even though no one can buy anything.</p><p>I wanted the free app to contain only what it uses. That meant building two different apps from the same code.</p><h3>Product Flavors</h3><p>A product flavor is Android’s way of building different versions of an app from one codebase. You define flavors in Gradle, and each flavor can have its own code, resources and dependencies.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/0fdcaa2efa27e84b78270d2d428385d9/href">https://medium.com/media/0fdcaa2efa27e84b78270d2d428385d9/href</a></iframe><p>The flavors are grouped into two dimensions:</p><ul><li><strong>environment (dev / prod):</strong> a development version and a release version. The dev build has a different app ID, so both can be installed on the same phone.</li><li><strong>distribution (free / full):</strong> which features the app includes. The free flavor has only the core features, and full flavor includes sign-in, sync and payments.</li></ul><p>Gradle combines them into variants such as <strong>prodFreeRelease</strong>, which is the app on Google Play, and <strong>devFullDebug</strong>, which I use to work on the premium features.</p><h3>Leaving Auth and Payment Code Out</h3><p>The <strong>sign-in</strong> and <strong>sync</strong> code lives in an <strong>auth</strong> module, and the payment code lives in a payment module. Both are added only to the full flavor:</p><pre>dependencies {<br>    // Only the full build includes sign-in, sync and payments<br>    &quot;fullImplementation&quot;(project(&quot;:libs:auth&quot;))<br>    &quot;fullImplementation&quot;(project(&quot;:libs:payment&quot;))<br>}</pre><p><strong>fullImplementation</strong> means only include this in builds of the full flavor. The free app on Google Play doesn’t contain the sign-in, sync or payment code, or the SDKs behind them.<br>The billing permission works the same way. It’s declared in a small manifest file inside the androidFull folder, so only the full app asks for it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Riz6rVAAUgYXHv9VePzkBg.png" /><figcaption>The free build leaves the sign-in, sync and payment modules out entirely. The full build plugs them in behind the same interfaces.</figcaption></figure><h3>Hiding the SDKs Behind Interfaces</h3><p>The shared code never uses the auth or payment modules directly. It only uses interfaces. These interfaces live in a small module called <strong>domain-api</strong>. This module holds only interfaces and simple data classes, with no SDKs:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/0f2bf98a9f31e3c77e8bd8d3d6327d45/href">https://medium.com/media/0f2bf98a9f31e3c77e8bd8d3d6327d45/href</a></iframe><p>The shared code depends on these interfaces. The auth and payment modules implement them with Supabase and RevenueCat. The free build has its own simple versions that do nothing.</p><h3>Picking the Right Code at Startup</h3><p>Something still has to decide which versions of the interfaces the app uses. This is where the flavor source sets come in. Each flavor has its own folder, <strong>androidFree</strong> and <strong>androidFull</strong>, and only one of them is compiled into a build.</p><p>In <strong>commonMain</strong>, there&#39;s a small interface for whatever cloud setup the build has:</p><pre>interface CloudBootstrap {<br>    fun modules(): List&lt;Module&gt;<br>    fun onKoinStarted(koin: Koin) {}<br>}</pre><p>Each flavor folder defines a value with the same name, cloudBootStrap. In <strong>androidFree</strong>, it loads <strong>freeCloudModule</strong>. This module has <strong>no-op</strong> (no operation) versions of the sign-in, sync and payment interfaces.</p><pre>val cloudBootstrap: CloudBootstrap = object : CloudBootstrap {<br>    override fun modules(): List&lt;Module&gt; = listOf(freeCloudModule)<br>}</pre><p>The free version doesn’t override onKoinStarted. The interface already gives it an empty default, so in the free build that call does nothing.<br>In <strong>androidFull</strong>, it loads the real modules and sets up payments once Koin has started:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/12f9a4f117e612c2a1ac060f972bf384/href">https://medium.com/media/12f9a4f117e612c2a1ac060f972bf384/href</a></iframe><p>The app’s startup code in <strong>androidMain</strong> uses cloudBootstrap without knowing which one it got:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/921890c83d3530419ce256c2d4044bfd/href">https://medium.com/media/921890c83d3530419ce256c2d4044bfd/href</a></iframe><p>The free build’s module provides the same interfaces with fakes and no-ops.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/f051c79715fbf35982913ee0ab3ad9c9/href">https://medium.com/media/f051c79715fbf35982913ee0ab3ad9c9/href</a></iframe><h3>A Safety Net for the Free Build</h3><p>The free build should never include the auth and payment code. One wrong line in a Gradle file could bring it back, and the app would still build without any error. To catch that, I added a check to CI.</p><p>On every pull request, GitHub Actions lists every library in the free release build and fails if one of the full-only SDKs shows up:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/6f05a2589303c2c376373f8f671e58df/href">https://medium.com/media/6f05a2589303c2c376373f8f671e58df/href</a></iframe><h3>Lessons Learned</h3><ul><li><strong>A feature flag hides a feature.</strong> A flavor-only dependency removes it. If you don’t want code in an app, keep it out of the build.</li><li><strong>Keep third-party types out of your interfaces.</strong> Convert them into your own types inside the module that uses the library.</li><li><strong>Give shared code interfaces, and let each build choose the implementation.</strong> The shared code stays the same for both versions.</li><li><strong>Add a CI check for every rule you care about.</strong> It’s easy to break a structure rule without noticing.</li></ul><h3>Wrapping Up</h3><p>Building this app taught me more about Kotlin Multiplatform than any tutorial did. Most of the lessons came from real problems, like reorganizing the project as it grew, keeping SDKs out of the shared code, and fixing an iOS build that broke without warning. I hope learning about these pitfalls saves you some time on your own project.</p><p>I might write more about the app later, like offline sync or testing in-app purchases. If you’d like to see that, follow me here on Medium. And if you’re working on a KMP app yourself, I’d love to hear what you’ve learned in the comments.</p><p>Thanks for reading! If you’re preparing for the “Leben in Deutschland” test, you can try the app on <a href="https://play.google.com/store/apps/details?id=org.metalheadcoder.lebenindeutschland">Google Play</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=511bffdcc28c" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/how-i-built-free-and-full-build-flavors-for-android-in-my-kmp-codebase-511bffdcc28c">How I Built Free and Full Build Flavors for Android in my KMP codebase</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Moving Local Storage to Jetpack DataStore Without Paying a Cent in Performance]]></title>
            <link>https://proandroiddev.com/moving-local-storage-to-jetpack-datastore-without-paying-a-cent-in-performance-e709b28719de?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/e709b28719de</guid>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[mobile-performance]]></category>
            <category><![CDATA[jetpack-datastore]]></category>
            <dc:creator><![CDATA[Taufik Amaryansyah]]></dc:creator>
            <pubDate>Sat, 10 Oct 2026 17:14:49 GMT</pubDate>
            <atom:updated>2026-10-10T17:14:47.886Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>How we replaced the storage foundation of a large-scale app, then proved it with numbers.</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gc7uc3zYCq6vXLB4VMSSrw.jpeg" /><figcaption>Illustration Generated by Gemini</figcaption></figure><h3>TL;DR</h3><ul><li>The most important local data in our app now lives in <strong>Jetpack DataStore</strong>, replacing SharedPreferences.</li><li>We measured the impact with an isolated benchmark: <strong>no measurable runtime cost</strong>. Cold start, hot start, CPU, and memory all stay within normal run-to-run variation.</li><li>Migrating existing users’ data to DataStore runs once and finishes in <strong>tens of milliseconds</strong>, on a background thread.</li><li>In the same release, the app as a whole shows its first screen <strong>0.7 seconds faster</strong> than the previous release on a Samsung Galaxy A55. DataStore did not stand in the way of that.</li></ul><h3>Why we left SharedPreferences</h3><p>SharedPreferences has been part of Android since API 1. It is simple and good enough for many cases. But in an app as large as ours, its limits started to show.</p><p><strong>1. The API is synchronous, and it is often called from the main thread.</strong><br>The first read of a SharedPreferences file blocks the calling thread until the whole file is loaded from disk. If that thread is the main thread, the UI waits too.</p><p><strong>2. </strong><strong>apply() is not fully asynchronous.</strong><br>apply() looks like a background write. But Android waits for every pending apply() to finish when an Activity stops or a Service finishes. On a device with slow storage, that wait can turn into an ANR. commit() is worse, because it blocks explicitly.</p><p><strong>3. Write failures are invisible.</strong><br>apply() never tells you whether the data was actually saved. If it fails, the app never finds out.</p><p><strong>4. There is no convenient reactive way to observe changes.</strong><br>All you get is a listener, which leaks easily and does not fit with coroutines or Flow.</p><p><strong>5. Types are not enforced.</strong><br>Storing an Int and reading it back as a Long only surfaces at runtime, as a ClassCastException.</p><p>Jetpack DataStore was designed to address all of this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LpJf0xHJNsiuFS7eHG4K5w.png" /><figcaption>SharedPreferences vs Jetpack DataStore</figcaption></figure><h3>Benchmark results</h3><p>The question we heard most before the release: <em>“If the storage moves to DataStore, won’t the app get slower?”</em></p><p>We answered it with several measurements.</p><h4>1. Isolated benchmark: what does DataStore cost on its own?</h4><p>Our release contained many changes besides DataStore. To keep DataStore’s effect from getting mixed in, we built three APKs:</p><ul><li><strong>The previous release.</strong></li><li><strong>The new release without DataStore</strong>, meaning every change in the release except the migration.</li><li><strong>The new release with DataStore</strong>, meaning the build we actually shipped.</li></ul><p>The difference between the second and third builds is the cost of DataStore and nothing else. All three were measured <strong>back to back in a single 24-minute session</strong> on a Xiaomi Redmi Note 9 Pro (Snapdragon 720G, 6 GB RAM), 10 cold starts per build, with normal thermal status on all 30 runs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*veVbMz3yUODQsrW1qEpL5w.png" /><figcaption>Isolated benchmark results</figcaption></figure><p><strong>DataStore adds no measurable runtime cost.</strong> Every startup, CPU, and memory difference is smaller than the run-to-run variation within the same build. Screens A and B are the two screens users open most. The only consistent change actually goes the right way: CPU on screen B at idle dropped 2.6 points, and that drop showed up in two separate measurement sessions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*02SiOYpbjAz0tCweBZmn6Q.png" /><figcaption>Median cold start of the three builds</figcaption></figure><h4>2. Updating from an old version on a low-end device</h4><p>The hardest scenario for the migration is an existing user updating on an inexpensive device, because that is where the data migration actually runs. We measured it with Perfetto on a <strong>Samsung Galaxy A15</strong>, 10 iterations per build, with old data already in place before the update.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MH2EEz4m0AmojlsTA5N8gw.png" /><figcaption>Update on a low-end device</figcaption></figure><p><strong>All 10 cold start iterations and all 10 warm start iterations were faster on the new APK.</strong> That includes the first iteration, the only launch that runs the data migration: 7.04 seconds, no slower than the average.</p><p>The two APKs differ by more than the storage layer, so this speed-up belongs to the release as a whole. What this measurement proves is the thing we worried about most beforehand: <strong>the data migration does not make existing users wait longer</strong>.</p><h4>3. How long does the migration itself take?</h4><p>We record the migration’s duration through telemetry. On our test device:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kshfZd4U8xGlci7N_1jUpA.png" /><figcaption>Migration duration</figcaption></figure><p>The migration runs on a background thread and finishes in milliseconds, so users never feel a wait.</p><h4>4. The release as a whole</h4><p>On a <strong>Samsung Galaxy A55</strong>, 10 runs per metric:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CAo1umXHOFsETzkNs3YWcw.png" /><figcaption>Release overview</figcaption></figure><p>Even the slowest run of the new release was faster than the fastest run of the previous one. This is the work of the whole team on that release, not DataStore alone. But it answers the original concern quite firmly: replacing the storage foundation did not stop the app from getting faster.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9_CYzGPTKtvA6v_GP0jF2Q.png" /><figcaption>Cold start per run</figcaption></figure><h3>What these numbers taught us</h3><p><strong>Measure the change on its own.</strong> Our first measurement compared the old release with the new one and found a CPU increase on one of the screens. Once we built a version without DataStore, it turned out the increase came from other changes in the same release, and DataStore actually brought it down. Without that comparison build, we would have blamed the wrong component.</p><p><strong>Measure in one session.</strong> Measurements taken hours or days apart produced differences that did not hold up when repeated. Network conditions, server-driven content, and device temperature all change in between. Measuring every build back to back in one session removes most of that noise.</p><p><strong>Report the range, not just the average.</strong> Memory on the same screen, for the same build, can move by more than 30 MB between samples. A 14 MB difference in the average between two builds means nothing against variation that large.</p><p><strong>Build in telemetry from the start.</strong> Migration duration and write failures are sent as events. The question “how long does the migration take on users’ devices?” can be answered from production data instead of guesswork.</p><h3>Closing</h3><p>A storage migration is rarely visible to users, and that is exactly how it should be. Its success is measured by what <em>doesn’t</em> happen: no lost data, no interrupted user sessions, no app that got slower.</p><p>These numbers give us the confidence to keep going: moving more data to DataStore does not have to be traded against performance.</p><p>Thank you to everyone involved: those who reviewed the plan again and again, those who tested the update scenarios one by one, and those who ran the benchmarks until the numbers could be trusted.</p><p><em>All numbers in this article come from internal measurements on physical devices. Results on other devices may differ, especially in absolute values.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e709b28719de" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/moving-local-storage-to-jetpack-datastore-without-paying-a-cent-in-performance-e709b28719de">Moving Local Storage to Jetpack DataStore Without Paying a Cent in Performance</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Fix Shipped and Nothing Changed: Hunting the Phantom Pins That Outlived It]]></title>
            <link>https://proandroiddev.com/the-fix-shipped-and-nothing-changed-hunting-the-phantom-pins-that-outlived-it-6c72232e6635?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/6c72232e6635</guid>
            <category><![CDATA[debugging]]></category>
            <category><![CDATA[android-development]]></category>
            <category><![CDATA[gradle]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Vitaliy Gribko]]></dc:creator>
            <pubDate>Thu, 08 Oct 2026 04:49:32 GMT</pubDate>
            <atom:updated>2026-10-08T04:49:30.786Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3uQl83JGu0prosKUEbaSyQ.png" /></figure><p><a href="https://proandroiddev.com/one-pom-line-42-phantom-findings-how-a-vendor-sdk-broke-our-detekt-build-f9592331e69d">The last article in this series</a> ended with a vendor fix. Its SDK had declared kotlin-stdlib 2.3.0, which AGP consistent resolution carried onto our modules’ classpaths. Detekt’s type-resolving analysis uses an embedded Kotlin compiler. In this incident, that older compiler could not handle the newer dependency metadata correctly and reported 42 phantom UnsafeCast findings. They disappeared with a compatible analysis classpath. The vendor removed the stdlib declarations, but the nightly stayed red.</p><p>Over the next month, I traced the remaining dependency path and audited our own SDKs. We had been publishing the same kind of dependency. Fixing our pipeline took several attempts, including one that passed every publication check and still broke a consumer’s build.</p><p>One warning before we start. The comfortable version of this story is a line graph: incident, root cause, fix, green. The real version is a staircase where every landing reveals another flight. The vendor’s fix was correct and incomplete. Our first fix was correct and wrong. The second was correct and half-blind. The third held, and then a review pass found a bug in the fix itself. I would edit that sequence out as implausible, if it had not happened to me.</p><h3>Verifying a fix you did not write</h3><p>The calls SDK family came back clean at the end of August. I downloaded fresh POMs and module descriptors straight from the registry and parsed them: zero stdlib declarations. I wanted to check the published files, not whatever Gradle had cached locally.</p><p>The nightly cron disagreed with my optimism. Same 21 modules, same rule, 43 findings now, one more than the original incident. So before hunting anything new, I needed one fact: are these 43 phantoms, or did something real sneak in behind the noise?</p><p>I made a fresh checkout of develop, cherry-picked the workaround from part one that changes the stdlib only inside detekt tasks, and ran all 21 failing modules without caches. All 21 passed in under four minutes. The findings disappeared with a readable analysis classpath, pointing back to the dependency graph.</p><p>Next: who was still asking for 2.3.0? The dependency insight on a failing module still showed the same two selection reasons:</p><pre>org.jetbrains.kotlin:kotlin-stdlib:2.3.0<br>  Selection reasons:<br>      - By constraint: version resolved in configuration<br>        &#39;debugRuntimeClasspath&#39; by consistent resolution<br>      - By conflict resolution: between versions 2.3.0 and 2.2.10</pre><p>Somebody in the graph was still declaring it. Finding out who involved two traps worth passing on.</p><p>Trap one: the guilty POM was not in the local cache. Gradle’s files store keeps a .pom next to most artifacts, but the local files cache contained only its AAR, and the descriptor lived in the binary metadata cache. Grepping the cache for a version string came up empty and proved nothing. Absence of a pin in your cache is not absence of a pin. The cache is a derived, prunable, format-private store, not a registry mirror.</p><p>Scanning the binary descriptor files for strings surfaced an API client, its json and http siblings, and their logging library. They came from one repository and toolchain. My first audit had stopped at the first affected SDK family; another family still requested the same version.</p><p>The second trap took more digging. The API client’s registry lastModified timestamp was about a month later than the commit that adopted that version in our app. Earlier CI logs were green; the findings started after the timestamp’s date. My best explanation was a same-version republish with a newer toolchain’s stdlib declaration. I had not kept the earlier artifact, so I could not compare the two versions’ bytes. The timing was observed; the republish mechanism was a reconstruction.</p><p>This is why release artifacts need new version numbers when their contents change. Reusing a number can leave some consumers on cached content while others download the replacement.</p><h3>The mirror</h3><p>The sibling team’s first counterargument was not about their pins. It was about ours. Their claim was simple: your published SDKs pin the stdlib too, you are just lucky your pin sits close to your hosts’ Kotlin.</p><p>They were right. Our SDK declared stdlib 2.2.10, which matched our app’s version and therefore caused no uplift there. A consumer on an older Kotlin version could see a different result. We needed to check that case too.</p><p>That argument deserved a number, so I audited the POMs in our registry. Here is a standalone Python 3 scanner for POMs you have already downloaded. Save it as scan_poms.py and run python3 scan_poms.py ./downloaded-poms; it uses only the standard library. It distinguishes direct declarations from dependency-management entries and handles XML namespaces:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/34bd3ecc645daac5d827bf93fc2ab674/href">https://medium.com/media/34bd3ecc645daac5d827bf93fc2ab674/href</a></iframe><p>This inspects declarations, not effective Maven models or resolved Gradle graphs: it prints property references verbatim, does not resolve parent POMs or imported BOMs, and does not inspect profiles, plugin dependencies, or .module files. A MANAGED entry is not itself a dependency edge. The directory contents define the sample; the script does not select the latest version of each artifact or compute a registry-wide percentage.</p><p>The original registry audit found that roughly half of several hundred published artifacts declared the stdlib, spread across toolchain generations going back years. The audit lesson from part one had been aimed at vendors. It should have been aimed at a mirror.</p><p>The useful discovery was that these artifacts shared a monorepo and publication pipeline. We could fix the metadata generation in one place instead of asking each SDK consumer to work around it.</p><p>The platform team had already tried disabling Kotlin’s default stdlib dependency. In this build, that had not been enough. I started by finding out what kept adding it.</p><h3>The fix that worked and was still wrong</h3><p>Parcelize-related runtime dependencies still introduced paths to stdlib with the default dependency flag off. Our SDK modules use @Parcelize heavily because parcelable models are part of their API.</p><p>The first fix changed the convention plugin for publishable modules. It removed stdlib from api and implementation, then added it as compileOnly and to the test configurations that needed it. In our build, a withDependencies callback let the removal run after the Kotlin and Parcelize plugins had added their dependencies. That ordering worked in the tested setup, but became a concern in review.</p><p>The first version passed our local checks:</p><ul><li>Generated POM and .module for a parcelize-heavy SDK module and a plain one: zero stdlib declarations, all other dependencies intact. Both formats, same generation run, because comparing artifacts from different generation tasks once sent me chasing a bug in a vendored generator that did not exist. The phantom hunter caught his own phantom that evening.</li><li>Compilation of the touched modules came back UP-TO-DATE versus the unfixed build. That is a useful signal, and I will come back to what it does and does not prove.</li><li>Unit tests of the SDK modules, which need the stdlib at compile and runtime of the tests, to prove the test wiring survived the strip.</li><li>A full app assemble, an APK size sanity check, and a smoke run of all three shipped apps on an emulator, zero crashes.</li><li>A detekt pass over the touched modules, because irony is a load-bearing wall in this story: the pre-commit hook of the very static analysis we kept alive through part one blocked the first commit of this fix, a cyclomatic complexity complaint against the function that saved it. The hook was right. The function got smaller.</li></ul><p>Review raised a problem with the approach: we were changing SDK build configurations to fix published metadata. The callback ordering also depended on another plugin’s behavior, which could change on upgrade. The suggested fix was to move the policy into publication generation.</p><p>I argued, then I checked the callback ordering claim, then I stopped arguing. The fix moved.</p><h3>The publication layer</h3><p>The revised implementation had three parts:</p><ul><li>A policy object: the single list of coordinates a published artifact may not declare as a dependency, the stdlib and its jdk7/jdk8 companions. One source of truth, plain data, no logic to drift.</li><li>A filter in the POM assembly: the publication utility walks the generated dependency nodes, removes the forbidden ones, and on the runtime edges that can carry the stdlib transitively adds an explicit exclusion. The Gradle API for this is the withXml hook on the Maven publication, and the operation is idempotent, which matters later.</li><li>The module metadata side. This is the delicate part. Our pipeline builds .module files through a vendored fork of Gradle’s own metadata generator, and the rule in review was: minimal diff in someone else’s clone, because that fork is already hard to update. So the builder consults the same policy while assembling its dependency lists: coordinates to drop, exclusion rules to add. A handful of lines in one file, no signatures changed.</li></ul><p>The POM half of that, extracted from the implementation and trimmed to the parts that carry the decision (Node is groovy.util.Node, the object withXml hands you). This is the final version, including the exclusions the consumer experiments below discovered; the first iteration only removed direct declarations:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/28a8b87fb672a085af28cfe35b3d780a/href">https://medium.com/media/28a8b87fb672a085af28cfe35b3d780a/href</a></iframe><p>And the hook, which is the public Gradle API for exactly this, three lines in the publication factory:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e18b25fc4b46c2667f11761b3ae4f969/href">https://medium.com/media/e18b25fc4b46c2667f11761b3ae4f969/href</a></iframe><p>The helper matters for both types and namespaces. Node.get() returns Object, so Kotlin needs the list cast. It also looks up the local element name in the namespace-aware nodes Gradle supplies: comparing node.name() directly to a string misses QName names and can silently leave the POM unchanged. A POM without a dependencies section passes through quietly instead of throwing.</p><p>The .module side has no equivalent public hook in our build: our descriptors come from a vendored fork, and the same policy object feeds a skip-list inside it. That is a fact about our pipeline, not an API recommendation. If you publish with stock Gradle, check what your consumers actually resolve before assuming either file needs surgery.</p><p>We also removed our changes to a second publication path. Its tasks were gated by CI flags, and we could not find a run that used them. The review comment referred to the whole plugin; I had initially read it as a comment about one line. Keeping that extra patch would have meant testing a path we had not shown was active.</p><p>A few files, zero module build scripts touched. The SDK modules stopped being part of the fix and went back to being part of the inventory.</p><p>The generated POMs and .module files were clean, and the tests passed. Gradle TestKit integration tests also ran a real publication in a scratch build and checked the generated descriptors.</p><p>Next I checked what a consuming app would actually resolve.</p><h3>Proving it from the outside</h3><p>A clean POM is only part of the check. I needed to know whether an app could resolve the SDK, compile against it, and run its code. So I built a small consumer.</p><p>The app used AGP 8.11.1 and one SDK artifact, our core module. I excluded the other internal SDK dependencies to isolate its effect and called one SDK entry point. That limits the result: a real app could still inherit pins from the excluded modules. I built the same app with Kotlin 2.0.21, 2.1.20, 2.2.10, and 2.3.0.</p><p>Each local test publication got a fresh throwaway version suffix. Reusing a release version can make it unclear whether a consumer reads the changed descriptor or cached content. Cache behavior depends on the repository type, with separate rules for local Maven repositories; unique versions made this experiment easier to track.</p><p>The released SDK gave this baseline:</p><pre>consumer Kotlin 2.0.21  -&gt; stdlib lifted to 2.2.10, compile FAILED<br>consumer Kotlin 2.1.20  -&gt; stdlib lifted to 2.2.10, compiles<br>consumer Kotlin 2.2.10  -&gt; no lift, compiles<br>consumer Kotlin 2.3.0   -&gt; no lift, compiles</pre><p>The 2.0 consumer failed while reading stdlib metadata. In part one, detekt’s embedded compiler had handled a two-minor gap, while a three-minor gap produced the phantom findings. Here, two minors were enough to break compilation. Those experiments used different readers and inputs; they did not establish a general compatibility rule. Our goal was to avoid raising the consumer’s stdlib version in the first place.</p><p>The first locally published version removed our direct stdlib declarations. The Kotlin 2.0.21 consumer still resolved stdlib 2.2.10 and failed to compile. The publication tests had passed, but the consumer exposed a path they did not cover.</p><p>Dependency insight showed the remaining path: our SDK depended on kotlin-parcelize-runtime, which brought in kotlin-android-extensions-runtime and stdlib. Gradle selected the higher stdlib version through that path even after we removed our direct declaration.</p><p>To isolate the cause, I forced the consumer’s stdlib down to 2.0.21 while leaving the SDK built with Kotlin 2.2.10. It compiled. There was an important condition: the SDK already used languageVersion and apiVersion 2.0:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/c16723122b1f1474a394174ff90d9c03/href">https://medium.com/media/c16723122b1f1474a394174ff90d9c03/href</a></iframe><p>This fragment belongs to the SDK modules’ Kotlin compilation tasks under compiler 2.2.10, not a standalone build.gradle.kts. These settings predated the fix. With them, removing the stdlib uplift let the tested SDK code compile and run on 2.0.21. That does not establish compatibility for arbitrary newer Kotlin libraries; <a href="https://kotlinlang.org/docs/compatibility-modes.html">JetBrains documents language and API compatibility separately</a>.</p><h3>The missing half, and the field with the wrong name</h3><p>The missing step was an exclusion on the SDK’s kotlin-parcelize-runtime dependency. In the tested artifact, kotlin-android-extensions-runtime was transitive, not directly declared. The filter also lists that coordinate so it can add the same exclusion if another publication declares it directly; this case was not established by the core experiment. The simplified graph looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*D9X_XvsOcxz4OR9tyTLrYQ.png" /><figcaption>Removing One Edge Is Not Enough.</figcaption></figure><p>The exclusion applies below our runtime dependency, not across the consumer’s whole graph. It leaves the app’s own stdlib dependency alone. In a POM, the rule looks like this:</p><pre>&lt;dependency&gt;<br>  &lt;groupId&gt;org.jetbrains.kotlin&lt;/groupId&gt;<br>  &lt;artifactId&gt;kotlin-parcelize-runtime&lt;/artifactId&gt;<br>  &lt;version&gt;2.2.10&lt;/version&gt;<br>  &lt;exclusions&gt;<br>    &lt;exclusion&gt;<br>      &lt;groupId&gt;org.jetbrains.kotlin&lt;/groupId&gt;<br>      &lt;artifactId&gt;kotlin-stdlib&lt;/artifactId&gt;<br>    &lt;/exclusion&gt;<br>  &lt;/exclusions&gt;<br>&lt;/dependency&gt;</pre><p>And in the Gradle module metadata, the .module file our Gradle consumer resolved, the equivalent is an exclude rule on that dependency. A single dependency entry inside a variant, not the whole file, looks like this, per <a href="https://github.com/gradle/gradle/blob/master/platforms/documentation/docs/src/docs/design/gradle-module-metadata-latest-specification.md#excludes-value">Gradle’s module metadata specification</a>:</p><pre>{<br>  &quot;group&quot;: &quot;org.jetbrains.kotlin&quot;,<br>  &quot;module&quot;: &quot;kotlin-parcelize-runtime&quot;,<br>  &quot;version&quot;: { &quot;requires&quot;: &quot;2.2.10&quot; },<br>  &quot;excludes&quot;: [<br>    {<br>      &quot;group&quot;: &quot;org.jetbrains.kotlin&quot;,<br>      &quot;module&quot;: &quot;kotlin-stdlib&quot;<br>    }<br>  ]<br>}</pre><p>My first attempt used exclusions instead of excludes. The JSON was valid, generation passed, and the consumer failed exactly as before. The resolver ignored the unknown field.</p><p>Review identified the field-name mismatch, and a reference descriptor generated by stock Gradle independently confirmed excludes. The consumer build caught the failure first. We then added assertions for the exact POM and .module structures so the same mistake would fail closer to publication.</p><p>With the correctly named field in place, the matrix finally flipped:</p><pre>filter + excludes, no forcing anywhere:<br>consumer Kotlin 2.0.21  -&gt; keeps own stdlib 2.0.21, compiles<br>consumer Kotlin 2.1.20  -&gt; keeps own stdlib, compiles<br>consumer Kotlin 2.2.10  -&gt; compiles<br>consumer Kotlin 2.3.0   -&gt; compiles</pre><p>No force was needed in this run: the 2.0.21 consumer kept stdlib 2.0.21.</p><p>For readers who want to apply the same check to their own project, it is one command, no scanner required:</p><pre>./gradlew :app:dependencyInsight \<br>    --dependency org.jetbrains.kotlin:kotlin-stdlib \<br>    --configuration debugRuntimeClasspath</pre><p>Use the configuration for your variant, such as releaseRuntimeClasspath. Check the selected stdlib version and the paths requesting it. In our isolated consumer without its own @Parcelize, the selected version changed from 2.2.10 to 2.0.21, and the SDK’s paths to stdlib disappeared.</p><h3>The runtime half of the contract</h3><p>I installed the 2.0.21 app without its own @Parcelize on an emulator and exercised the SDK entry point. It ran with zero observed crashes.</p><p>The 2.1 build, the oldest compiler version we tested successfully with the consumer’s own @Parcelize, went further, and this is where the verification itself needed verifying. My original smoke test put a parcelable into a Bundle and read it back, same process, same Bundle. That roundtrips a reference, not bytes: a Bundle keeps the object in memory unless something forces it through a Parcel, so the serialization code never ran. A sharp review pass caught it. The test now marshals for real:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/31605adaba139b054fbf26f2e8b4df29/href">https://medium.com/media/31605adaba139b054fbf26f2e8b4df29/href</a></iframe><p>The harness passes the activity’s classloader, then reads the restored objects with getParcelable and checks their fields. Reading forces deserialization. On the 2.1.20 build, both an SDK @Parcelize model and the app&#39;s own model passed this roundtrip on the device, with zero observed crashes.</p><p>The smoke screen also prints KotlinVersion.CURRENT, which reports the runtime stdlib rather than the compiler version. The 2.0.21 app printed 2.0.21. The 2.1.20 app with its own @Parcelize printed 2.2.10, confirming that stdlib was still being lifted in that case.</p><p>The boundary matters, because a fix sold without its boundary is a lie with a release note. A Kotlin 2.0.21 consumer with its own @Parcelize still failed. The consumer and SDK both requested parcelize-runtime; resolution selected 2.2.10, whose metadata the older compiler could not read. Our exclusion applied to the SDK’s path, not the consumer’s separate path. Kotlin 2.1.20 was the oldest version we tested successfully in this setup, including the Parcel roundtrip. This fix does not answer whether a different publication design could support older consumers too.</p><p>Here are the results together:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pJwlGVm3GVmpNLMuq4chsw.png" /><figcaption>Consumer results after the publication fix. Successful compilation does not guarantee an unchanged stdlib version: the Kotlin 2.1.20 consumer with its own @Parcelize still resolves stdlib 2.2.10. Tests cover one isolated SDK artifact; device checks were limited to the scenarios shown.</figcaption></figure><p>These results cover one isolated SDK artifact: the original entry-point check and the additional model roundtrip described above. Other SDK modules were excluded. Compiling successfully and keeping the consumer’s stdlib version are separate outcomes.</p><h3>Where this stands</h3><p>This remains a proposed stopgap. It removes direct stdlib declarations and the tested transitive paths through the SDK’s runtime dependencies. The platform team is looking for a longer-term solution, including the unresolved consumer @Parcelize case.</p><p>The checks cover generated descriptors, consumer compilation, and the runtime cases listed above. They do not show that every SDK artifact is compatible or that the policy has been rolled out.</p><h3>A publisher’s validation checklist</h3><p>Everything above, compressed into the list I will follow the next time a publish pipeline change lands on my desk:</p><ol><li>POM and .module from the same generation run. Different tasks generate them, and comparing across runs invents bugs.</li><li>Unique throwaway versions for every local test iteration, or you will verify yesterday’s cache.</li><li>Generate a reference descriptor with the stock tooling and diff your format against it before trusting your field names.</li><li>Assert on the mechanism in integration tests: the node is absent, the exclusion is present, by exact name. Absence of the old failure is not presence of the fix.</li><li>Check whether compilation inputs changed. UP-TO-DATE tasks are a useful signal, not proof of identical artifacts; compare the artifacts when you need that proof.</li><li>Build a scratch consumer across the supported toolchain versions. Isolate the artifact under test, and record which dependencies you excluded.</li><li>The stranger’s runtime, for the versions the claim is about. Install, launch, and print KotlinVersion.CURRENT so &quot;ran on old stdlib&quot; has a witness.</li><li>Run the fix twice. Idempotency bugs in code that edits XML look exactly like working fixes on the first run.</li></ol><p>Item eight came from another review finding. The exclusion code created a new container before checking for an existing one. A dependency that already had exclusions ended up with two containers, while the test inspected only the first and passed. The corrected code reuses the existing container. This shortened regression test checks the result after two runs:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/f565a52b2f44aed263043d250d81b144/href">https://medium.com/media/f565a52b2f44aed263043d250d81b144/href</a></iframe><p>The test uses JUnit 4 (org.junit.Test and org.junit.Assert), the Groovy XML parser, and the helpers above in the same Kotlin file. The deliberately matching artifactId under a different group must not suppress the Kotlin exclusion. Parsing the fixture also catches the QName bug that string-only synthetic nodes missed. Count nodes, do not inspect one. The staircase, as promised.</p><h3>Takeaways</h3><ul><li>Verify the downloaded artifacts and the resolved graph. Fixing one SDK family does not remove requests from another.</li><li>Put publication policy in the publication layer, and check both POM and .module output.</li><li>Test the consumer’s selected dependencies, compilation, and runtime separately. A green result in one does not establish the others.</li><li>Keep compatibility claims tied to the actual setup: language/API versions, included dependencies, exercised code, and known failures.</li></ul><p>The nightly is still red as I write this, waiting for the remaining vendor family to publish new versions. Our own publication fix has passed the limited consumer checks described here. Next time, I know where to start.</p><p>The scanner, pointed at the nearest mirror.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6c72232e6635" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/the-fix-shipped-and-nothing-changed-hunting-the-phantom-pins-that-outlived-it-6c72232e6635">The Fix Shipped and Nothing Changed: Hunting the Phantom Pins That Outlived It</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Modular Jetpack Compose: Don’t Leave Your Architecture to Code Review]]></title>
            <link>https://proandroiddev.com/modular-jetpack-compose-dont-leave-your-architecture-to-code-review-67b1b916097c?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/67b1b916097c</guid>
            <category><![CDATA[api-impl]]></category>
            <category><![CDATA[architecture]]></category>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[mobile-design-system]]></category>
            <category><![CDATA[monorepo]]></category>
            <dc:creator><![CDATA[Kanan Yusubov]]></dc:creator>
            <pubDate>Fri, 02 Oct 2026 04:56:58 GMT</pubDate>
            <atom:updated>2026-10-02T04:56:57.514Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yCvH3k0FBNABILyKskYtlg.png" /></figure><p>Every Android architecture diagram looks clean on day one. Features in boxes, arrows pointing down, a note in the README saying “features must not depend on each other.”</p><p>Then a deadline arrives. Someone needs the user’s avatar on the checkout screen, the profile feature already has a UserRepository, and it&#39;s one import away. The build is green, the review is busy, and the arrow now points sideways. Six months later nobody can change the profile feature without breaking checkout.</p><p>Folders and naming conventions describe an architecture. They don’t enforce it. A rule that only lives in a README is a suggestion.</p><p>This article takes six rules a modular Compose app usually lives by, and moves each one out of the README and into the build:</p><ol><li><strong>Only the app may depend on an implementation.</strong> Enforced by a Gradle check.</li><li><strong>A module’s internals stay private.</strong> Enforced by Kotlin’s internal.</li><li><strong>Only a feature may build its own routes.</strong> Enforced by internal routes and launchers.</li><li><strong>Every dependency has a definition.</strong> Enforced by Koin’s compiler plugin.</li><li><strong>Every failure is handled.</strong> Enforced by sealed types.</li><li><strong>Every feature and route is registered.</strong> Enforced by a source check that runs in CI.</li></ol><p>Where a rule can’t be enforced, the last section makes the right way the easy way instead.</p><h3>The shape: every capability is two modules</h3><p>Each capability becomes a pair of Gradle modules:</p><ul><li><strong>-api</strong> says what the capability can do. Interfaces only.</li><li><strong>-impl</strong> says how. Screens, view models, repositories, DTOs, wiring.</li></ul><pre>app/                          the only module that sees implementations<br>features/<br>  posts/posts-api             PostsApi, PostsLauncher<br>  posts/posts-impl            screens, view models, data, Koin module<br>  counter/counter-api<br>  counter/counter-impl        depends on posts-api, never posts-impl<br>core/<br>  designsystem/               tokens, theme, components<br>  statemanager/               AppStateViewModel<br>  router/router-api | impl    Navigation 3<br>  network/network-api | impl  Ktor<br>  logger/logger-api | impl    Kermit</pre><p>Dependencies only point down: app → features → core. Features reach each other only through an -api. When checkout needs the user&#39;s avatar, it depends on profile-api and asks through an interface. It never sees UserRepository, and profile is free to rewrite it.</p><h3>Rule 1: only the app may depend on an implementation</h3><p>This is the rule everything else rests on. It makes app the composition root: the one place that knows which implementation stands behind each interface. Every other module is written against contracts.</p><p>A few lines in a convention plugin check every project dependency as it’s declared:</p><pre>internal fun Project.applyArchitectureGuard() {<br>    val self = path<br>    configurations.configureEach {<br>        val configurationName = name<br>        dependencies.withType(ProjectDependency::class.java).configureEach {<br>            val target = path<br>            val violation = when {<br>                target == self -&gt; null // a module&#39;s own test classpaths<br>                target == &quot;:app&quot; -&gt; &quot;nothing may depend on :app&quot;<br>                target.endsWith(&quot;-impl&quot;) &amp;&amp; self != &quot;:app&quot; -&gt;<br>                    &quot;only :app may depend on an -impl module; depend on its -api instead&quot;<br>                else -&gt; null<br>            }<br>            if (violation != null) {<br>                throw GradleException(<br>                    &quot;Architecture rule broken in $self ($configurationName -&gt; $target): $violation.&quot;,<br>                )<br>            }<br>        }<br>    }<br>}</pre><p>Add implementation(project(&quot;:features:posts:posts-impl&quot;)) to the counter feature, and the build stops during configuration, before it compiles a single file:</p><pre>Architecture rule broken in :features:counter:counter-impl<br>(implementation -&gt; :features:posts:posts-impl):<br>only :app may depend on an -impl module; depend on its -api instead.</pre><p>The message says what went wrong and what to do instead. The review conversation never has to happen.</p><p>Because the check runs on configureEach, it covers every configuration: implementation, api, testImplementation, and any a plugin adds later. And because the rule is about module paths, there&#39;s no list to maintain.</p><h3>Every module gets the guard without asking</h3><p>A guard only helps if every module has it, and twenty modules with twenty copies of the same build setup will drift apart. So the shared setup lives in build-logic/ as convention plugins, and the Android library and application plugins both call applyArchitectureGuard().</p><p>A feature’s build file then says only what is particular to it:</p><pre>plugins {<br>    id(&quot;modular.feature.impl&quot;)<br>}<br><br>dependencies {<br>    implementation(project(&quot;:features:posts:posts-api&quot;))<br>    implementation(project(&quot;:core:network:network-api&quot;))<br>}</pre><p>modular.feature.impl brings the Android library setup (and with it the guard), Compose, Koin, serialization, detekt, the design system, the state manager, the router contract, the logger contract and the test libraries. Even the Android namespace is derived from the module path: :features:posts:posts-impl becomes com.example.modularapp.features.posts.impl.</p><h3>Rule 2: a module’s internals stay private</h3><p>Kotlin’s internal means &quot;visible inside this Gradle module only.&quot; In an -impl module, almost everything is internal: routes, view models, repositories, DTOs.</p><pre>internal class PostsRepositoryImpl(private val client: NetworkClient) : PostsRepository</pre><p>The guard stops the wrong dependency from being declared. internal makes sure that even a module allowed to depend on posts-impl, which is only app, finds nothing there except the few classes it needs to wire the feature in.</p><h3>Rule 3: only a feature may build its own routes</h3><p>Navigation 3 has a pleasantly simple model: the back stack is a plain observable list of keys, and NavDisplay shows whichever composable belongs to the last key. Going forward adds a key, going back removes one.</p><p>The modular question is: who is allowed to create a key? If any feature can build PostDetailsRoute(42), every feature depends on how posts arranges its screens. So routes are private:</p><pre>// posts-impl: no other module can see these<br>@Serializable<br>internal data object PostsRoute : AppRoute<br><br>@Serializable<br>internal data class PostDetailsRoute(val postId: Int) : AppRoute</pre><p>Other features get a route from the feature’s launcher, published in its -api:</p><pre>// posts-api<br>interface PostsApi {<br>    val launcher: PostsLauncher<br>}<br><br>interface PostsLauncher {<br>    fun posts(): AppRoute<br>    fun postDetails(postId: Int): AppRoute<br>}</pre><p>The launcher returns a route instead of navigating. The launcher knows <em>where</em>, and the caller decides <em>how</em>: push it, replace the current screen with it, or make it the new root.</p><pre>// counter-impl, handling an effect from its view model<br>CollectEffects(viewModel.effects) { effect -&gt;<br>    when (effect) {<br>        // Our own screen: use the route directly.<br>        is CounterEffect.OpenDetails -&gt; navigator.push(CounterDetailsRoute(effect.count))<br>        // Another feature: only through its launcher.<br>        CounterEffect.OpenPosts -&gt; navigator.push(postsLauncher.posts())<br>    }<br>}</pre><p>Try to write navigator.push(PostDetailsRoute(42)) in the counter feature and it doesn&#39;t compile. The route isn&#39;t visible.</p><p>Each -impl contributes one ModuleRouter that maps its routes to screens. It&#39;s also the one place the feature&#39;s view models are built:</p><pre>class PostsModuleRouter internal constructor(<br>    private val repository: PostsRepository,<br>) : ModuleRouter {<br><br>    override fun PolymorphicModuleBuilder&lt;NavKey&gt;.registerRoutes() {<br>        subclass(PostsRoute::class)<br>        subclass(PostDetailsRoute::class)<br>    }<br><br>    override fun EntryProviderScope&lt;NavKey&gt;.entries() {<br>        entry&lt;PostsRoute&gt; {<br>            PostsScreen(viewModel = viewModel { PostsViewModel(repository) })<br>        }<br>        entry&lt;PostDetailsRoute&gt; { route -&gt;<br>            PostDetailsScreen(viewModel = viewModel { PostDetailsViewModel(route.postId, repository) })<br>        }<br>    }<br>}</pre><p>Two details matter here.</p><ul><li><strong>registerRoutes() is what lets the back stack survive process death.</strong> Routes are @Serializable, and because they are private, each feature registers its own subclasses. The app merges them into one serializer.</li><li><strong>Each back-stack entry gets its own </strong><strong>ViewModelStore</strong>, through Navigation 3&#39;s view model decorator. viewModel { … } inside an entry creates a view model that lives exactly as long as that screen stays on the stack.</li></ul><h3>Navigation is an effect, never a call</h3><p>Screens follow one State / Event / Effect loop, on the plain AndroidX ViewModel:</p><pre>user taps ──▶ Event ──▶ ViewModel ──▶ new State ──▶ screen redraws<br>                            └────────▶ Effect ────▶ screen navigates</pre><p>A view model never holds a navigator or a Context. It says what should happen, as an effect, and the screen does it. Effects travel through a buffered channel, so one sent while the screen is in the background is delivered when it comes back instead of being lost.</p><h3>Rule 4: every dependency has a definition</h3><p>Classic Koin resolves everything at runtime: a missing definition is a crash the first time that code path runs. Koin’s K2 compiler plugin moves that check into the build.</p><p>Each -impl has one Koin module. The definitions are functions, so the classes themselves carry no DI annotations at all:</p><pre>@Module<br>class PostsKoinModule {<br>    @Single<br>    fun postsApi(): PostsApi = PostsApiImpl()<br><br>    @Single<br>    internal fun repository(client: NetworkClient): PostsRepository = PostsRepositoryImpl(client)<br><br>    @Single(binds = [ModuleRouter::class])<br>    internal fun moduleRouter(repository: PostsRepository): PostsModuleRouter = PostsModuleRouter(repository)<br>}</pre><p>Read each function as a sentence: <em>to provide a </em><em>PostsRepository, I need a </em><em>NetworkClient</em>. The binds on the router lets the app collect every feature&#39;s router with koin.getAll&lt;ModuleRouter&gt;(), without naming a single feature.</p><p>The app lists every module exactly once:</p><pre>@Module(<br>    includes = [<br>        LoggerKoinModule::class,<br>        NetworkKoinModule::class,<br>        CounterKoinModule::class,<br>        PostsKoinModule::class,<br>    ],<br>)<br>internal class AppKoinModule { /* … */ }<br><br>@KoinApplication(modules = [AppKoinModule::class])<br>internal object ModularKoinApplication</pre><p>Remove NetworkKoinModule from that list and the app no longer compiles:</p><pre>e: [Koin][KOIN-D001] Missing dependency: com.example.modularapp.core.network.NetworkClient</pre><p>Three decisions are worth explaining.</p><ul><li><strong>Why functions instead of annotated classes?</strong> @Single class PostsRepositoryImpl with component scanning is shorter, but then every class imports Koin and the wiring is spread across dozens of files. With functions, domain and data code stays plain Kotlin, and each capability&#39;s wiring reads in one place.</li><li><strong>Why aren’t view models resolved from Koin?</strong> The router builds them with an ordinary constructor call. A post id comes from the route, is passed to the constructor, and the compiler checks it. Resolving the view model from the container would turn that into a runtime parameter lookup.</li></ul><h3>Rule 5: every failure is handled</h3><p>Features never import Ktor. They see one interface, and every call returns a value instead of throwing:</p><pre>interface NetworkClient {<br>    suspend fun &lt;T&gt; get(<br>        path: String,<br>        response: DeserializationStrategy&lt;T&gt;,<br>        query: Map&lt;String, String&gt; = emptyMap(),<br>    ): AppResult&lt;T&gt;<br>    // post(…) likewise<br>}<br><br>sealed interface AppResult&lt;out T&gt; {<br>    data class Success&lt;out T&gt;(val value: T) : AppResult&lt;T&gt;<br>    data class Failure(val error: NetworkError) : AppResult&lt;Nothing&gt;<br>}</pre><p>NetworkError is a sealed set: no connection, timeout, HTTP status, a body that doesn&#39;t decode, unknown. The Ktor implementation catches everything and turns it into one of those, with one deliberate exception: coroutine cancellation is rethrown, so leaving a screen still cancels its request.</p><p>Each layer then speaks its own language. The repository translates network errors into the feature’s own failure type, exhaustively:</p><pre>private fun NetworkError.toFailure(): PostsFailure = when (this) {<br>    NetworkError.NoConnection -&gt; PostsFailure.NoConnection<br>    is NetworkError.Http -&gt; if (code == HTTP_NOT_FOUND) PostsFailure.NotFound else PostsFailure.Unavailable<br>    NetworkError.Timeout, is NetworkError.Unknown -&gt; PostsFailure.Unavailable<br>    is NetworkError.Serialization -&gt; PostsFailure.Malformed<br>}</pre><p>The view model keeps that failure in its state as a value. Only the screen, which has access to resources, turns it into a translated sentence. Because every step is a sealed when with no else, adding a new failure kind won&#39;t compile until every layer handles it. There&#39;s no &quot;something went wrong&quot; fallback hiding a case nobody thought about.</p><h3>Rule 6: every feature and route is registered</h3><p>Some mistakes have the right shape, compile perfectly, and still fail at runtime:</p><ul><li><strong>A feature whose Koin module was never added to the app.</strong> Nothing asks for its types, so Koin’s graph looks complete. The feature just has no screens.</li><li><strong>A route missing from </strong><strong>registerRoutes().</strong> It navigates fine, then crashes the first time Android saves the back stack.</li></ul><p>Neither Gradle nor the compiler can see these, so a Gradle task reads the sources and checks exactly them. ./gradlew doctor runs in CI on every pull request:</p><pre>doctor found problems:<br>  ✖ route-not-registered: PostDetailsRoute (posts-impl) is missing from registerRoutes().<br>  ✖ feature-not-registered: UserProfileKoinModule is not in the app&#39;s includes.</pre><p>It’s a deliberately small tool. It knows the project’s conventions, so it can check what a general-purpose linter never could.</p><h3>Where the build can’t enforce, make the right way the easy way</h3><p>Not every rule can be an error. For the rest, the goal is that doing the right thing takes less effort than doing the wrong one.</p><p><strong>New features are generated, not copied.</strong> The easiest way to avoid wiring mistakes is not to wire by hand. ./gradlew newFeature --name=user-profile generates both modules: facade, launcher, route, route table, Koin module, view model, screen and a test. It also adds the feature to the app. The generated code passes detekt and doctor as it is.</p><p><strong>The design system does the accessibility work.</strong> It’s built on Compose Foundation rather than Material: a small set of tokens (colours, spacing, radii, typography, motion) provided through composition locals and read through one object:</p><pre>AppText(text = post.title, style = AppTheme.typography.title)</pre><p>The tokens use staticCompositionLocalOf. A dynamic composition local tracks every place that reads it, so a change recomposes only those readers. A static one skips that tracking, and a change recomposes the whole subtree instead. For theme tokens that&#39;s exactly right: they&#39;re read everywhere and change only when the whole theme does.</p><h3>What it costs</h3><p>This isn’t free, and pretending otherwise would be a disservice.</p><ul><li><strong>More modules.</strong> Two per feature, plus a composition root you have to keep current. Gradle’s configuration cache and build cache keep it fast, but it’s more to navigate.</li><li><strong>Indirection.</strong> Opening another feature’s screen goes through an interface and a launcher rather than a direct call. That’s the point, but it’s one more hop when reading code.</li></ul><p>My rule of thumb: this pays off from roughly ten features, or two teams working in the same app. Below that, a single module with good discipline is probably fine. Above it, a build that enforces the boundaries is worth far more than it costs.</p><p>Everything above is in the open-source <a href="https://github.com/Yusubov-Engineering/modular-compose-template">modular-compose-template</a>, which opens on a counter screen that browses posts from JSONPlaceholder. The screens are simple on purpose; the wiring around them is the point. If you try any of these rules on a real project, I’d like to hear where they helped and where they got in the way.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=67b1b916097c" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/modular-jetpack-compose-dont-leave-your-architecture-to-code-review-67b1b916097c">Modular Jetpack Compose: Don’t Leave Your Architecture to Code Review</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Proxy your Android emulator and iOS simulator to your local Docker stack]]></title>
            <link>https://proandroiddev.com/proxy-your-android-emulator-and-ios-simulator-to-your-local-docker-stack-d251321d8017?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/d251321d8017</guid>
            <category><![CDATA[debugging]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[docker]]></category>
            <category><![CDATA[ios]]></category>
            <dc:creator><![CDATA[Jan Rabe]]></dc:creator>
            <pubDate>Wed, 30 Sep 2026 15:10:37 GMT</pubDate>
            <atom:updated>2026-09-30T15:10:36.651Z</atom:updated>
            <content:encoded><![CDATA[<p>One command handles the certificate and the routing. The interesting part is why it takes fourteen steps by hand.</p><blockquote><strong><em>TL;DR:</em></strong><em> Point the device at an HTTPS proxy running on your Mac. The proxy does the DNS lookup, so your Mac’s </em><em>/etc/hosts becomes the single source of truth for routing. The device only needs to trust one certificate, once. If you want to skip ahead: </em><em>uvx proxy-lab start android.</em></blockquote><p><a href="https://github.com/kibotu/proxy-lab.sh">GitHub - kibotu/proxy-lab.sh: Android&#39;s two HTTPS blockers - device trust and proxy routing - scripted end to end, with pre-flight checks that catch the usual traps first. Pinned, CI-tested, one YAML for both platforms.</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xzNyrmhxP1RnmfKRLaqEvg.png" /><figcaption>Image generated by Gemini.</figcaption></figure><h3>The setup you already have</h3><p>Docker Compose on your Mac. A reverse proxy (Traefik, nginx, Caddy, pick your favorite) listens on 443 and routes by hostname:</p><pre>mysub.domain.com  -&gt;  api container<br>auth.domain.com   -&gt;  keycloak container</pre><p>Your /etc/hosts has the usual line:</p><pre>127.0.0.1  mysub.domain.com auth.domain.com</pre><p>Safari opens https://mysub.domain.com and the whole stack works. Nice. So you build the debug app against the same URL, hit run, and get this:</p><pre>java.net.UnknownHostException: Unable to resolve host &quot;mysub.domain.com&quot;</pre><p>Or something more exciting: it resolves, because the domain is real and public, and your integration test just wrote a row into production.</p><p>Both are fixable with one idea. The rest of this article is why that idea needs a certificate.</p><h3>The emulator is a different computer</h3><p>That is the whole thing in one sentence. The Android emulator runs its own kernel with its own network stack and its own resolver, and it has never heard of your /etc/hosts. Neither has the iOS simulator, though for a nicer reason we will get to.</p><p>The first instinct is to give the device the same hosts file. On Android that means /system/etc/hosts, a writable system image, and -writable-system on every boot. It works, and you can feel the shape of the problem: the same mapping now lives in two places, and the second one resets itself at the worst time.</p><p>The second instinct is to change your base URL to http://10.0.2.2:8080. That address is useful, it is the host machine as seen from the emulator (<a href="https://developer.android.com/studio/run/emulator-networking">docs</a>), and for a single service it is the right answer. For a hostname-routed stack it costs more than it saves:</p><ul><li>Your reverse proxy routes on the Host header. Send it 10.0.2.2 and Traefik cannot tell which container you meant.</li><li>TLS stops matching, because the certificate is for mysub.domain.com and you asked for an IP.</li></ul><p>Cookies scoped to .domain.com stop being set, and your debug config now differs from production in exactly the places that hide bugs until release day. What you want is for the same URL to mean something different on your machine. That is a DNS question, and one computer here already knows the right answer.</p><h3>A proxy moves the DNS lookup to the right computer</h3><p>Here is the part that took me embarrassingly long to internalize. When an HTTP client talks through a proxy, it does not resolve the hostname first. It hands the name over:</p><pre>CONNECT mysub.domain.com:443 HTTP/1.1</pre><p>The <em>proxy</em> resolves it. The proxy runs on your Mac. Your Mac’s /etc/hosts says 127.0.0.1. Your Docker reverse proxy is sitting on 127.0.0.1:443 and sees a real Host header and real SNI, so it routes correctly.</p><p>Nothing on the device needs to know anything. Same URL, same cookies, same certificate name, same code path as production. Stop the proxy and the device goes straight back to the real internet. That is a lot of value from one setting:</p><pre>adb shell settings put global http_proxy 10.0.2.2:8080</pre><p>Note that the setting is device-wide, not app-scoped. Every app on that emulator now goes through the proxy, which matters in a minute.</p><h3>One certificate to install, and a good reason why</h3><p>To see and route HTTPS, the proxy terminates TLS. It presents its own certificate, signed by a CA generated on first run at ~/.mitmproxy/mitmproxy-ca-cert.pem. To your app that CA is a stranger, and your app is right to hang up on it.</p><p>The side effect is worth the trouble. <a href="https://www.mitmproxy.org/">mitmproxy</a> talks to your Docker stack with verification relaxed (--set ssl_insecure=true), so your self-signed local certificates never need to be trusted by anything. One CA on the device covers every domain in your compose file, today and next month when you add three more. You install one certificate, once, and stop thinking about local TLS.</p><p>You do have to actually install it, and that part deserves some context.</p><h3>Why Android asks for an explicit opt-in</h3><p>Since Android 7, apps targeting API 24 and above do not trust user-installed CAs by default (<a href="https://android-developers.googleblog.com/2016/07/changes-to-trusted-certificate.html">Android Developers Blog</a>). This was the right call. A CA that any app or any well-meaning user can add is a CA an attacker can add, and most users cannot reasonably evaluate that prompt.</p><p>The old workaround was the <em>system</em> store, which the <a href="https://docs.mitmproxy.org/stable/howto/install-system-trusted-ca-android/">mitmproxy guide</a> still documents: hash the cert by hand, remount /system, reboot. Android 14 then moved that store into the Conscrypt APEX (<a href="https://source.android.com/docs/core/ota/modular-system/conscrypt">AOSP</a>), which is a real improvement, because root certificates now ship as Mainline updates instead of waiting for a full OS release. APEX modules are immutable and /apex is mounted with private propagation, so editing the system store is closed even for root. Tim Perry wrote the <a href="https://httptoolkit.com/blog/android-14-breaks-system-certificate-installation/">definitive walkthrough</a>, and what remains is a Magisk module or nsenter into Zygote&#39;s mount namespace. Impressive engineering, and more than most of us need to look at our own JSON.</p><p>Google left a proper door for exactly this case, and it has been there since Nougat.</p><h3>The supported path: debug-overrides</h3><p>Apps can opt in to the user trust store per build type with a network security config. Put this in your <strong>debug</strong> source set as res/xml/network_security_config.xml:</p><pre>&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;<br>&lt;network-security-config&gt;<br>    &lt;debug-overrides&gt;<br>        &lt;trust-anchors&gt;<br>            &lt;certificates src=&quot;user&quot; /&gt;<br>        &lt;/trust-anchors&gt;<br>    &lt;/debug-overrides&gt;<br>&lt;/network-security-config&gt;</pre><p>Reference it from the &lt;application&gt; tag in your manifest. &lt;debug-overrides&gt; only applies when the build is debuggable, so it cannot weaken a release build even if it ships (<a href="https://developer.android.com/privacy-and-security/security-config">docs</a>). Keep it in the debug source set anyway, because reviewers should not have to know that rule by heart.</p><p>Supported, survives OS upgrades, no Magisk, no writable system image. Your app now trusts the user store.</p><p>The certificate still has to get in there, and that store is not the one in Settings. It lives at /data/misc/user/0/cacerts-added/ and it has opinions:</p><ol><li>The filename must be the certificate’s subject hash plus .0. It is subject_hash_old, the OpenSSL 1.0 algorithm, not subject_hash.</li><li>Mode 644, then restorecon, so the SELinux label is right.</li><li>You need adb root, which means a Google APIs system image. Play Store images decline with adbd cannot run as root in production builds, which is accurate and says nothing about images.</li><li>Reboot, then adb root again, because adbd returns to the shell user after a reboot and the shell user cannot read that directory.</li></ol><p>Each of those four can go wrong without crashing or logging anything. You get ERR_CERT_AUTHORITY_INVALID and no hint about which one it was. Once they are all right, the certificate survives reboots and you never touch it again for that AVD.</p><h3>iOS gets to skip most of this</h3><p>The simulator has no network stack of its own. It borrows the Mac’s, which is why 127.0.0.1 already works and why DNS is not a topic here.</p><p>That also means it has no proxy setting of its own. Either set the proxy for the whole Mac in <strong>System Settings &gt; Network &gt; (interface) &gt; Details &gt; Proxies</strong>, or set connectionProxyDictionary on your URLSessionConfiguration for just your app. Then, once per simulator: open mitm.it in Safari (it is served by the proxy, so start the proxy first), install the profile under <strong>VPN &amp; Device Management</strong>, and flip the switch under <strong>General &gt; About &gt; Certificate Trust Settings</strong>.</p><p>Three steps, no root, no reboot. Apple gets real credit for that one.</p><h3>Count the steps, then stop doing them by hand</h3><p>For one Android emulator, from cold:</p><p>Install mitmproxy at a known version. Generate the host CA. Boot an AVD, and make sure it is a Google APIs image. adb root. Hash the cert with the right algorithm. Create the directory with the right mode. Push the file under the right name. chmod and restorecon. Reboot. Wait for sys.boot_completed. adb root again. Check nothing else is on port 8080. Set the global proxy. Start the proxy. Clear the global proxy on exit, or the device keeps pointing at a socket that is gone.</p><p>Fourteen steps, a handful of quiet failure modes, all of it mechanical. Mechanical is good news, because that is what scripts are for.</p><p>It is also worth pricing out if you lead a team. Those steps are the difference between a new hire seeing their first intercepted request on day one or on day three, and between everyone’s emulator behaving the same or each one being quietly personal. A pinned mitmproxy version means a bug you reproduce is a bug they reproduce.</p><h3>So I put it in a script</h3><p><a href="https://github.com/kibotu/proxy-lab.sh">proxy-lab.sh</a> runs those steps and checks the preconditions before it touches anything:</p><pre>uvx proxy-lab.sh proxy-lab start android</pre><p>First run on a cold machine:</p><pre>✓ tools       adb, uv, openssl, lsof<br>  ✓ host CA     ~/.mitmproxy/mitmproxy-ca-cert.pem<br>  … emulator    booting Pixel_9_API_35<br>  ✓ emulator    Pixel_9_API_35 booted in 38s<br>  … device CA   installing c8750f0d.0, one reboot, once per AVD<br>  ✓ device CA   c8750f0d.0 trusted<br>  ✓ port 8080   free<br>  ✓ proxy       10.0.2.2:8080 set<br>  ✓ mitmdump    0.0.0.0:8080, Ctrl-C to stop</pre><pre>[local_router] <a href="https://mysub.domain.com/v1/session">https://mysub.domain.com/v1/session</a><br>[local_router] <a href="https://auth.domain.com/realms/dev/protocol/openid-connect/token">https://auth.domain.com/realms/dev/protocol/openid-connect/token</a></pre><p>The second run on the same AVD skips to the last two lines. <a href="https://docs.astral.sh/uv/">uv</a> pins mitmproxy to one version, so the whole team sees identical behavior and nobody installs Python.</p><p>Add the hostnames from your compose file to a YAML list and they get tagged in the output, which matters once your app is making two hundred requests a minute:</p><pre>domains:<br>  - &quot;.domain.com&quot;</pre><pre>uvx proxy-lab.sh proxy-lab start android my-domains.yml</pre><p>Pin the tag, put that line in your Makefile, and commit the domains file next to it. Onboarding becomes one command that behaves the same on every machine. iOS is the same command with ios, and a much thinner wrapper, for all the reasons above.</p><h3>What it touches</h3><p>Reasonable question before running a git URL that roots your emulator. The Android script is 279 lines of bash and the iOS one is 38, so you can read both in a sitting, and you should. What they do:</p><ul><li>Nothing is installed globally. uv runs mitmproxy from its own cache at a pinned version. Clear the cache and it is gone.</li><li>The CA goes into the emulator’s user store. Only builds that opted in through debug-overrides care that it exists.</li><li>The port check stops stale mitmdump processes from earlier runs and refuses to touch anything else. Your dev server on 8080 gets an error message, not a signal.</li><li>Ctrl-C clears the device proxy setting. If you kill -9 instead, the next run cleans up after you.</li><li>The emulator keeps running after the proxy stops. The script boots it, it does not own it.</li></ul><p>The failure messages got more attention than the automation did. Missing tool, Play Store image, busy port, wrong AVD name: each one prints the fix instead of a stack trace.</p><h3>The edges worth knowing</h3><p>Being upfront about these, because you will meet them anyway:</p><ul><li><strong>Not every HTTP client uses the system proxy.</strong> OkHttp and URLSession do, so stock Android and iOS work out of the box. Dart&#39;s HttpClient needs <a href="https://api.dart.dev/stable/dart-io/HttpClient/findProxy.html">findProxy</a>, and Cronet needs its own configuration. If traffic is missing, check the client before you suspect the certificate.</li><li><strong>The proxy setting is device-wide.</strong> Other apps on the emulator route through it too, and the ones that never opted into the user store will fail TLS while it runs. Same reason Android may show a “No internet connection” banner: the connectivity probe does not use user CAs. Your app is unaffected.</li><li><strong>Certificate pinning wins, as designed.</strong> A pinning app will reject the proxy CA. Turn pinning off in debug builds.</li><li><strong>Cleartext still needs opting in.</strong> Plain HTTP locally means usesCleartextTraffic on Android and an <a href="https://developer.apple.com/documentation/bundleresources/information_property_list/nsapptransportsecurity">ATS exception</a> on iOS. Or serve TLS locally, which you already are if you followed along.</li><li><strong>Emulator and simulator only.</strong> Physical devices need root for the user store, and that is a different article by someone who knows more about it than I do.</li></ul><h3>How do you handle this?</h3><p>This is not the only sane approach and I would like to hear the others. Some teams run a DNS server inside the compose stack and point the emulator at it. Some register a real domain whose A record is 127.0.0.1 and take certificates from Let&#39;s Encrypt, which sidesteps the CA question for about twelve dollars a year. Some use <a href="https://httptoolkit.com/">HTTP Toolkit</a>, which automates the hard Android 14 path properly and comes with a UI.</p><p>If you have a cleaner answer, or if the script trips on your setup, the <a href="https://github.com/kibotu/proxy-lab.sh/issues">issues</a> are open and real failure reports are the most useful thing anyone can send me. Paste the exact error line. The scripts are built to fail loudly, so that line is usually the whole diagnosis.</p><p>And if this saved you an afternoon, a <a href="https://buymeacoffee.com/kibotu">coffee</a> is a nice way to say so. Entirely optional. The bug reports are worth more.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d251321d8017" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/proxy-your-android-emulator-and-ios-simulator-to-your-local-docker-stack-d251321d8017">Proxy your Android emulator and iOS simulator to your local Docker stack</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why Kotlin’s inline and reified Keywords Exist (And How They Solve Java’s Oldest Flaw)]]></title>
            <link>https://proandroiddev.com/why-kotlins-inline-and-reified-keywords-exist-and-how-they-solve-java-s-oldest-flaw-f390508339cd?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/f390508339cd</guid>
            <category><![CDATA[android]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[android-app-development]]></category>
            <dc:creator><![CDATA[Sehaj kahlon]]></dc:creator>
            <pubDate>Sun, 27 Sep 2026 16:58:44 GMT</pubDate>
            <atom:updated>2026-09-27T16:58:42.778Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*23YGD7LtnnOHGaz62Qyd0A.png" /></figure><p>Why some of the most elegant APIs in Kotlin — first&lt;T&gt;(), Gson().fromJson&lt;T&gt;(), Jetpack’s viewModels&lt;T&gt;() — could never have existed without two keywords most developers use daily but rarely stop to understand.</p><pre>inline fun  makeInstance(): T =<br>    T::class.java.getDeclaredConstructor().newInstance()</pre><p>Generics on the JVM have a strict rule: you cannot use a type parameter T at runtime. Calling T::class, instantiating new T(), or checking if (x is T) is normally forbidden. The compiler will complain that the type information is missing.</p><p>Yet, this function compiles and works flawlessly.</p><p>The secret lies in two keywords: inline and reified. Understanding how they interact changes how you read idiomatic Kotlin, from JSON parsers to network clients.</p><h3>The Problem: The JVM’s Amnesia</h3><p>When you write generic code, you use T as a placeholder for a future type:</p><pre>class Box(val value: T)<br><br>val intBox = Box(5)          // T is Int<br>val stringBox = Box(&quot;hello&quot;) // T is String</pre><p>At compile time, Kotlin knows exactly what T is. It uses this knowledge to catch mistakes, immediately stopping you if you try to put a String into intBox.</p><p>But when compiled to run on the JVM, this knowledge vanishes. This design decision is called <strong>type erasure</strong>.</p><p>Introduced for backward compatibility in 2004, Java’s generics are largely a compile-time illusion. At runtime, the JVM simply sees “a Box holding an Object.&quot; The concrete type is erased.</p><p>Because of this, you cannot use a generic type parameter as a real class at runtime. In standard Java, the workaround is tedious: you must manually pass a Class token everywhere just to keep the type around.</p><p>Kotlin found a way to bypass this entirely.</p><h3>The First Half of the Trick: inline</h3><p>To understand the solution, we first need to look at inline—a keyword that originally has nothing to do with generics.</p><p>Normally, a function call is a detour. The program jumps to the function’s location in memory, executes it, and jumps back. Every caller shares one compiled copy of the function.</p><p>Marking a function inline changes this. Instead of jumping to the function, the compiler <strong>physically pastes the function&#39;s code directly into the caller</strong>.</p><pre>inline fun measureAndLog(label: String, block: () -&gt; Unit) {<br>    val start = System.currentTimeMillis()<br>    block()<br>    println(&quot;\(label took\){System.currentTimeMillis() - start}ms&quot;)<br>}</pre><p>When you call measureAndLog, the function boundary disappears. The compiler takes the logic and drops it exactly where you made the call.</p><p>While primarily used to speed up functions that take lambdas, this copy-paste mechanic creates a fascinating loophole for generics.</p><h3>The Second Half: reified</h3><p>Because an inlined function’s body is physically copied into the caller, the compiler already knows the concrete types being used at that exact location.</p><p>There is no longer a function boundary for the generic type to be erased across. The code is sitting right there alongside the real types.</p><p>Kotlin lets you exploit this with the reified keyword</p><pre>inline fun  makeInstance(): T =<br>    T::class.java.getDeclaredConstructor().newInstance()</pre><p>The reified keyword tells the compiler: <em>do not erase this type. Keep it real.</em></p><p>When you call makeInstance(), the compiler pastes the code and replaces every mention of T with User. It acts exactly as if you had hardcoded User::class.java yourself.</p><p>This brings us to the golden rule of Kotlin generics: <strong>reified only works on </strong><strong>inline functions.</strong> Standard functions are compiled once as a shared unit, meaning they still suffer from type erasure.</p><h3>The Hidden Cost in Tooling</h3><p>This brilliant workaround has one minor catch regarding code metrics.</p><p>Because inlined functions are duplicated rather than invoked like standard methods, code coverage tools (like JaCoCo) often fail to track them correctly.</p><p>Your tests might cover the logic flawlessly, but the coverage report will show 0% for the original generic function. It is a harmless compiler artifact, but knowing about it will save you hours of debugging your test suite.</p><p>Type erasure is a permanent JVM limitation. But by physically relocating code with inline and preserving types with reified, Kotlin turns a rigid platform constraint into an invisible implementation detail.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f390508339cd" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/why-kotlins-inline-and-reified-keywords-exist-and-how-they-solve-java-s-oldest-flaw-f390508339cd">Why Kotlin’s inline and reified Keywords Exist (And How They Solve Java’s Oldest Flaw)</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From a prompt to native Compose UI: building PocketCommunity with A2UI]]></title>
            <link>https://proandroiddev.com/from-a-prompt-to-native-compose-ui-building-pocketcommunity-with-a2ui-95a3643baed8?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/95a3643baed8</guid>
            <category><![CDATA[android-development]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[gemini]]></category>
            <category><![CDATA[android]]></category>
            <dc:creator><![CDATA[Akshay Nandwana]]></dc:creator>
            <pubDate>Sun, 27 Sep 2026 16:54:29 GMT</pubDate>
            <atom:updated>2026-09-27T16:54:28.155Z</atom:updated>
            <content:encoded><![CDATA[<p><em>An Android engineering walkthrough of agent-generated interfaces, component catalogs, stateful interactions, and the boundaries your app still needs to own.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EljBClFqAtdgYnrxaddK5w.png" /></figure><p>Ask an AI assistant to help you prepare for a developer conference. A text response can give you a checklist. An interactive response can let you tick an item, inspect the venue, and ask the assistant to update your plan using what you have already completed.</p><p>That second interaction is what we built in <strong>PocketCommunity</strong>, the latest app in the Android Engineers <strong>Android + AI Cookbook</strong>.</p><p>The app reads published developer-event listings from DevEarth. Gemini composes an interface from a restricted set of components. A companion server validates that composition, and the AndroidX A2UI renderer displays it using native Jetpack Compose components.</p><p>The interesting engineering problem starts after the first card appears: how does a tap become agent context, and how do we keep the next generated screen consistent with the user’s current state?</p><h3>What actually generates the screen?</h3><p>A2UI is a protocol, not an inference provider. Its renderer does not require a Gemini key. It can render a developer-authored message just as it can render one produced by an agent.</p><p>PocketCommunity uses Gemini as the producer, so <strong>our server needs a Gemini credential</strong>. The Android app needs the server URL. These are different responsibilities.</p><p>In this implementation, Gemini returns:</p><pre>{<br>  &quot;text&quot;: &quot;Here is a preparation checklist for your event.&quot;,<br>  &quot;components&quot;: [<br>    { &quot;id&quot;: &quot;root&quot;, &quot;component&quot;: &quot;Column&quot;, &quot;children&quot;: [&quot;heading&quot;] },<br>    { &quot;id&quot;: &quot;heading&quot;, &quot;component&quot;: &quot;Text&quot;, &quot;text&quot;: &quot;Before you go&quot; }<br>  ]<br>}</pre><p>This is a minimal illustrative composition, not a complete event response. The server validates the components and wraps them in A2UI messages. Gemini does not generate Kotlin, and the app does not compile code received from the model.</p><p>The <a href="https://developer.android.com/develop/ui/compose/agentic">official Compose A2UI guide</a> describes the separation between the rendering engine and the catalogs that map protocol components to native implementations. Our app adds the Gemini producer, event-data boundary, and application-specific validation around that renderer.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*q7IHy75S9uoyo1TD.png" /></figure><p><em>Gemini chooses the composition; the server validates it; Android renders registered native components.</em></p><p>In the shipped preview, no configured endpoint means setup — not a simulated AI response. Deterministic messages remain useful in instrumentation tests, where we explicitly inject a fake repository. They are not a runtime fallback.</p><p><strong>Source:</strong> <a href="https://github.com/AndroidEngineers/android-ai-cookbook/tree/33ff08b56b3e5f00240c99cf2848f0e35bdde998/a2ui">PocketCommunity at the article’s pinned revision</a>.</p><h3>A surface has structure and state</h3><p>Think about a single preparation checklist. It needs a component tree, but it also needs values that can change when the user interacts.</p><p>A2UI separates these concerns. In PocketCommunity, the server constructs three messages for each new response surface:</p><ol><li>createSurface identifies the surface and its catalog.</li><li>updateDataModel supplies initial values.</li><li>updateComponents supplies the component graph.</li></ol><p>Here is a compact example matching the pinned implementation’s wire format:</p><pre>[<br>  {<br>    &quot;version&quot;: &quot;v0.9&quot;,<br>    &quot;createSurface&quot;: {<br>      &quot;surfaceId&quot;: &quot;preparation-1&quot;,<br>      &quot;catalogId&quot;: &quot;https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json&quot;,<br>      &quot;sendDataModel&quot;: true<br>    }<br>  },<br>  {<br>    &quot;version&quot;: &quot;v0.9&quot;,<br>    &quot;updateDataModel&quot;: {<br>      &quot;surfaceId&quot;: &quot;preparation-1&quot;,<br>      &quot;path&quot;: &quot;/&quot;,<br>      &quot;value&quot;: { &quot;ready0&quot;: false }<br>    }<br>  },<br>  {<br>    &quot;version&quot;: &quot;v0.9&quot;,<br>    &quot;updateComponents&quot;: {<br>      &quot;surfaceId&quot;: &quot;preparation-1&quot;,<br>      &quot;components&quot;: [<br>        { &quot;id&quot;: &quot;root&quot;, &quot;component&quot;: &quot;Column&quot;, &quot;children&quot;: [&quot;pack&quot;] },<br>        {<br>          &quot;id&quot;: &quot;pack&quot;,<br>          &quot;component&quot;: &quot;CheckBox&quot;,<br>          &quot;label&quot;: &quot;Pack a charger&quot;,<br>          &quot;value&quot;: { &quot;path&quot;: &quot;/ready0&quot; }<br>        }<br>      ]<br>    }<br>  }<br>]</pre><p>pack identifies a component. /ready0 identifies a value in the surface’s data model. They are not interchangeable.</p><p>When the user checks the item, the bound value changes. The UI can reflect that change without another model call.</p><p>A version detail matters here: the current Android guide describes specification 0.9.1 support, while this pinned alpha implementation uses v0.9 and the v0_9 catalog URL in its messages. Keep the tested dependency and protocol values together rather than editing version strings to match a documentation heading.</p><p>The sample currently receives a complete HTTP response containing the message batch. Processing multiple messages does <strong>not</strong> mean we have implemented token streaming or an SSE transport.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pcrRFD2ykKtanYPKt2rh6g.png" /></figure><p><em>One response batch becomes a surface through the parser, processor, and Material catalog.</em></p><h3>The catalog is your UI contract</h3><p>The server allows eight component types:</p><pre>const allowed = new Set([<br>  &#39;Text&#39;, &#39;Column&#39;, &#39;Row&#39;, &#39;Card&#39;,<br>  &#39;Button&#39;, &#39;CheckBox&#39;, &#39;Divider&#39;, &#39;Image&#39;<br>]);</pre><p>This is our sample’s subset, not the entire A2UI component vocabulary.</p><p>On Android, communityCatalog() configures the Material basic catalog. We supply a custom image implementation that recognizes only asset://community and renders bundled artwork. Generated output cannot turn that component into an arbitrary remote image request.</p><p>The catalog’s URL opener also rejects direct navigation. Website and directions actions go through application code, which resolves the official HTTPS link from a known event record.</p><p>This keeps the agent’s freedom specific: it can choose headings, group supported components, and offer permitted actions. The app still owns its component implementations and the effects those actions can trigger.</p><p>The conversation header, composer, setup dialog, and inspector are ordinary Compose UI. The response surfaces are rendered from A2UI messages. An agent-generated experience does not require handing the whole application shell to the model.</p><h3>Keep inference outside the Composable</h3><p>We use a small repository boundary:</p><pre>interface CommunityRepository {<br>    suspend fun discover(): List&lt;CommunityEvent&gt;<br>    suspend fun ask(endpoint: String, request: AgentRequest): AgentReply<br>}<br>data class AgentReply(<br>    val text: String,<br>    val messages: List&lt;String&gt;,<br>)</pre><p>CommunityViewModel owns request coordination, conversation state, the message processor, and outgoing action handling. Compose observes state and sends user intent.</p><p>This is useful for a concrete reason: tests can replace the network repository while exercising the real native renderer and action flow. We do not need a live Gemini request to prove that a rendered button dispatches the expected event.</p><p>Request ownership also matters. A response from an old conversation must not appear after the user starts a new one. The implementation cancels its job and advances a generation counter on reset; after a repository call returns, it checks coroutine activity and whether the request still belongs to the current generation.</p><p>There is an important limit: the repository uses synchronous OkHttp calls on Dispatchers.IO. Cancelling the coroutine does not automatically establish immediate transport cancellation or stop provider computation. UI ownership and network cancellation are separate engineering concerns.</p><h3>A checkbox-aware agent needs more than chat history</h3><p>Suppose the user checks “Pack a charger” and then taps <strong>Update my plan</strong>.</p><p>If the next request contains only “Update my plan,” the agent has no reliable description of what changed. Even sending the visible conversation text is insufficient: checkbox values are not necessarily written into that text.</p><p>PocketCommunity forwards:</p><ul><li>The current request and selected event ID.</li><li>Recent conversation text.</li><li>The actual action name, originating surface/component IDs, and context.</li><li>Previous component trees.</li><li>Snapshots of bound surface values.</li></ul><p>A button’s generated action looks like this:</p><pre>{<br>  &quot;event&quot;: {<br>    &quot;name&quot;: &quot;refine&quot;,<br>    &quot;context&quot;: { &quot;eventId&quot;: &quot;published-event-id&quot; }<br>  }<br>}</pre><p>The ID above is a placeholder; real actions must reference a current published event.</p><p>The ViewModel resolves that ID before proceeding. venue, prepare, and refine become agent requests. website and directions use known listing URLs. save remembers the event for the session and explicitly does not register the user.</p><p>The request context is bounded: up to 12 text turns, three previous component trees, and six surface snapshots. These are sample limits, not a complete token-budgeting or conversation-memory system.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*bAGq-w3NDDWHJ6St.png" /></figure><p><em>A checkbox changes local state; a later agent request carries the state and the originating action.</em></p><h3>The bug that exposed the state boundary</h3><p>During a physical-device check, Gemini correctly responded that <strong>one of four items was complete</strong>. But the newly rendered checklist showed every checkbox unchecked.</p><p>The agent had received the state. The UI initialization discarded it.</p><p>Our server was initializing every checkbox in the new surface to false. The fix was to carry the relevant values into the new surface’s initial data model:</p><pre>const prior = new Map(<br>  previousChecklist.map(item =&gt; [item.label, item.checked])<br>);<br>const value = {};<br>for (const component of reply.components) {<br>  if (component.component === &#39;CheckBox&#39;) {<br>    value[component.value.path.slice(1)] =<br>      prior.get(component.label) === true;<br>  }<br>}</pre><p>On refine, the server finds the originating surface’s earlier components and values, then matches exact labels. An existing item can retain its checked value even if its binding path changes. A new or renamed item starts unchecked.</p><p>This is an application-level migration policy, not automatic A2UI persistence. Exact-label matching is also a deliberate preview limitation. Stable domain item IDs would be stronger if the agent edits wording, two items share text, or older context falls outside the retained window.</p><p>The corrected flow was verified on the phone: the generated progress text and the new checkbox state agreed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*JB8G4rJDATE9YYzh.png" /></figure><p><em>Actual PocketCommunity output after refinement: one item remains checked. Completion is self-reported, not externally verified registration.</em></p><h3>Valid JSON is only the first check</h3><p>A syntactically valid response can still reference missing components, create a cycle, invent an action target, or describe a misleading event.</p><p>The sample validator checks component names and properties, ID uniqueness, graph references, cycles, reachability, graph depth, action names, published event IDs, checkbox paths, and output limits. Android then checks the expected message envelope and surface identity before passing messages to the official processor.</p><p>These checks answer different questions from factual evaluation. A valid Text component can still contain an incorrect venue or an invented agenda. Event facts must be compared with the source feed. A checked registration item means the user marked it complete—not that our application verified a ticket purchase.</p><p>One instructive gap remains in the published baseline: two independent checklist components can bind to the same /ready0 path. Their component IDs can be unique while their values remain shared.</p><p>That is the practical exercise in our <a href="https://www.androidengineers.in/codelabs/pocketcommunity-a2ui-android?utm_source=substack&amp;utm_medium=publication&amp;utm_campaign=pocketcommunity_a2ui&amp;utm_content=validator_exercise">PocketCommunity codelab</a>. You write a failing test, add a per-surface duplicate-path policy, and rerun the suite. The policy is specific to independent checklist tasks; shared bindings can be intentional in other interfaces.</p><h3>What we verified — and what remains</h3><p>The published app checkpoint passed debug and release builds, two JVM tests, three physical-device instrumentation tests, and ten Node tests. The instrumentation tests use a fake repository with the real renderer. Separately, live Gemini generation was exercised on a OnePlus DN2101 running Android 13: event discovery, venue details, preparation, and checkbox-aware refinement.</p><p>That distinction is worth preserving in your own reports. A passing UI test is not evidence of successful inference. A successful inference is not a comprehensive usability evaluation.</p><p>This remains a learning preview. Conversations are not restored after process death; saves are session-only; embedded maps and streaming transport are not implemented. The companion service binds to localhost and does not provide production authentication, per-user quotas, or rate limiting. The A2UI dependencies are alpha and pinned together.</p><h3>Build it, then improve it</h3><p>The source includes the Android app, companion server, tests, setup instructions, and diagrams:</p><pre>git clone https://github.com/AndroidEngineers/android-ai-cookbook.git<br>cd android-ai-cookbook<br>git switch --detach 33ff08b56b3e5f00240c99cf2848f0e35bdde998<br>git switch -c learning/pocketcommunity<br>cd a2ui</pre><p>Open a2ui/ in Android Studio. The Gemini key belongs in server/.env; the phone’s Setup field takes the agent endpoint. The README covers Node setup, the pinned Java toolchains, and ADB reverse for a connected phone.</p><p>Choose the learning route that fits how you work:</p><ul><li><a href="https://www.androidengineers.in/codelabs/pocketcommunity-a2ui-android?utm_source=substack&amp;utm_medium=publication&amp;utm_campaign=pocketcommunity_a2ui&amp;utm_content=closing_codelab"><strong>Follow the codelab</strong></a><strong>:</strong> configure the app, trace real messages, implement the validator improvement, and verify it with tests.</li><li><a href="https://www.androidengineers.in/roadmap/a2ui-android?utm_source=substack&amp;utm_medium=publication&amp;utm_campaign=pocketcommunity_a2ui&amp;utm_content=closing_roadmap"><strong>Study the roadmap</strong></a><strong>:</strong> 12 units covering catalogs, protocol state, request ownership, action feedback, validation, and an independent capstone.</li><li><a href="https://github.com/AndroidEngineers/android-ai-cookbook"><strong>Explore the Android + AI Cookbook</strong></a><strong>:</strong> PocketCommunity joins PocketCards, PocketCook, PocketChat, and PocketStories in our collection of focused Android learning apps. Each sample documents its own readiness and limitations.</li></ul><p>If you build on PocketCommunity, show us the interaction after the first response: what did the user change, what did the agent receive, and how did the next native screen preserve that meaning?</p><p>That is where an impressive generated card becomes an Android engineering project you can explain and test.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=95a3643baed8" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/from-a-prompt-to-native-compose-ui-building-pocketcommunity-with-a2ui-95a3643baed8">From a prompt to native Compose UI: building PocketCommunity with A2UI</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[UI Events in UiState: Start Simple, Add a Queue Only When You Need One]]></title>
            <link>https://proandroiddev.com/ui-events-in-uistate-start-simple-add-a-queue-only-when-you-need-one-5b59dac0261e?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/5b59dac0261e</guid>
            <category><![CDATA[android]]></category>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <dc:creator><![CDATA[chanzmao]]></dc:creator>
            <pubDate>Thu, 24 Sep 2026 08:19:08 GMT</pubDate>
            <atom:updated>2026-10-09T07:41:46.164Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Three practical patterns for handling one-off UI events with a single </em></strong><strong><em>UiState.</em></strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vOSKb2TstetB8uwVHy6rbQ.jpeg" /><figcaption>Image created by AI</figcaption></figure><p>When we model a Compose screen with a single UiState, a natural question appears:</p><p><strong>Where should UI events live?</strong></p><p>A snackbar, navigation, or dialog is not really persistent screen state. It is something the UI needs to react to once.</p><p>Still, putting events directly into UiStatecan be useful. It keeps the screen driven by one observable state source and avoids introducing another mechanism such as SharedFlowor Channel.</p><p><a href="https://levelup.gitconnected.com/one-time-events-in-jetpack-compose-why-i-switched-from-sharedflow-to-channel-1107646efd8e">One-Time Events in Jetpack Compose: Why I Switched from SharedFlow to Channel</a></p><p>The interesting part is not whether we <strong>can</strong> put events into UiState.</p><p><a href="https://proandroiddev.com/android-architecture-is-moving-from-event-driven-ui-to-state-driven-ui-27f64ee09edc">Android Architecture Is Moving from Event-Driven UI to State-Driven UI</a></p><p>It is deciding <strong>how much event machinery we actually need.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/579/1*1W_M7aV5vV7SFfFp1Jlp6Q.png" /></figure><p>There are three useful levels.</p><h3>1. Start with a Single Event</h3><p>For many screens, one nullable event is enough.</p><pre>sealed interface UiEvent {<br>    data class ShowSnackbar(val message: String) : UiEvent<br>    data class NavigateToDetail(val id: String) : UiEvent<br>    data class ShowDialog(val type: DialogType) : UiEvent<br>}<br><br>data class UiState(<br>    val isLoading: Boolean = false,<br>    val items: List&lt;Item&gt; = emptyList(),<br>    val event: UiEvent? = null,<br>)</pre><p>The ViewModel produces the event:</p><pre>fun onSaveClicked() {<br>    _uiState.update {<br>        it.copy(event = UiEvent.ShowSnackbar(&quot;Saved&quot;))<br>    }<br>}<br><br>fun onEventConsumed() {<br>    _uiState.update {<br>        it.copy(event = null)<br>    }<br>}</pre><p>And the UI consumes it:</p><pre>LaunchedEffect(uiState.event) {<br>    when (val event = uiState.event) {<br>        is UiEvent.ShowSnackbar -&gt; {<br>            snackbarHostState.showSnackbar(event.message)<br>            viewModel.onEventConsumed()<br>        }<br><br>        is UiEvent.NavigateToDetail -&gt; {<br>            navController.navigate(&quot;detail/${event.id}&quot;)<br>            viewModel.onEventConsumed()<br>        }<br><br>        is UiEvent.ShowDialog -&gt; {<br>            // ...<br>            viewModel.onEventConsumed()<br>        }<br><br>        null -&gt; Unit<br>    }<br>}</pre><p>This has a nice property: <strong>everything is still StateFlow.</strong></p><p>There is no second stream to understand and no separate event lifecycle to manage.</p><p>For a screen where events normally happen one at a time, this is often all we need.</p><p>The limitation becomes obvious when two events are produced before the UI consumes the first one.</p><pre>Save<br> ├─ ShowSnackbar<br> └─ NavigateToDetail</pre><p>With a single event field, the second update can replace the first.</p><p>The latest event wins.</p><p>There is another subtle issue. LaunchedEffect uses its keys to determine whether the effect should restart. If two consecutive events are equal, changing the state may not produce a new effect execution.</p><p>So the single-event pattern is intentionally simple.</p><h3>2. Add a Queue When Events Can Accumulate</h3><p>If a screen genuinely needs to handle multiple events in sequence, we can make the event field a queue.</p><pre>data class UiState(<br>    val isLoading: Boolean = false,<br>    val items: List&lt;Item&gt; = emptyList(),<br>    val eventQueue: List&lt;UiEvent&gt; = emptyList(),<br>)</pre><p>The ViewModel appends events:</p><pre>private fun sendEvent(event: UiEvent) {<br>    _uiState.update {<br>        it.copy(eventQueue = it.eventQueue + event)<br>    }<br>}<br><br>fun onSaveClicked() {<br>    sendEvent(UiEvent.ShowSnackbar(&quot;Saved&quot;))<br>    sendEvent(UiEvent.NavigateToDetail(savedId))<br>}</pre><p>The UI handles the first event:</p><pre>LaunchedEffect(uiState.eventQueue) {<br>    val event = uiState.eventQueue.firstOrNull()<br>        ?: return@LaunchedEffect<br><br>    when (event) {<br>        is UiEvent.ShowSnackbar -&gt;<br>            snackbarHostState.showSnackbar(event.message)<br><br>        is UiEvent.NavigateToDetail -&gt;<br>            navController.navigate(&quot;detail/${event.id}&quot;)<br><br>        is UiEvent.ShowDialog -&gt; {<br>            // ...<br>        }<br>    }<br><br>    viewModel.onEventConsumed()<br>}</pre><p>Consumption removes the first item:</p><pre>fun onEventConsumed() {<br>    _uiState.update {<br>        it.copy(eventQueue = it.eventQueue.drop(1))<br>    }<br>}</pre><p>Now multiple events can survive at the same time.</p><pre>eventQueue<br>┌───────────────┐<br>│ ShowSnackbar  │ ← consume<br>├───────────────┤<br>│ Navigate      │ ← next<br>└───────────────┘</pre><p>This is a meaningful step up from the single-event approach.</p><p>But it also introduces a new failure mode:</p><p><strong>the queue depends on consumption happening correctly.</strong></p><p>If the UI fails to call onEventConsumed(), the first event remains at the head of the queue and later events cannot progress.</p><p>The queue is therefore useful when multiple events are a real requirement, not simply because queues look more robust.</p><h3>3. Add IDs When Consumption Needs to Be Explicit</h3><p>There is one more step.</p><p>Instead of treating an event as &quot;the first item in the list,&quot; we can give every queued event its own identity.</p><pre>data class QueuedEvent(<br>    val id: Long,<br>    val event: UiEvent,<br>)<br><br>data class UiState(<br>    val isLoading: Boolean = false,<br>    val items: List&lt;Item&gt; = emptyList(),<br>    val eventQueue: List&lt;QueuedEvent&gt; = emptyList(),<br>)</pre><p>The ViewModel creates a unique queue entry:</p><pre>private fun sendEvent(event: UiEvent) {<br>    _uiState.update {<br>        it.copy(<br>            eventQueue =<br>                it.eventQueue + QueuedEvent(<br>                    id = System.nanoTime(),<br>                    event = event<br>                )<br>        )<br>    }<br>}</pre><p>Consumption can now identify exactly which event was handled:</p><pre>fun onEventConsumed(id: Long) {<br>    _uiState.update { state -&gt;<br>        state.copy(<br>            eventQueue =<br>                state.eventQueue.filterNot { it.id == id }<br>        )<br>    }<br>}</pre><p>The UI uses the ID as the effect key:</p><pre>LaunchedEffect(uiState.eventQueue.firstOrNull()?.id) {<br>    val queued =<br>        uiState.eventQueue.firstOrNull()<br>            ?: return@LaunchedEffect<br><br>    when (val event = queued.event) {<br>        is UiEvent.ShowSnackbar -&gt;<br>            snackbarHostState.showSnackbar(event.message)<br><br>        is UiEvent.NavigateToDetail -&gt;<br>            navController.navigate(&quot;detail/${event.id}&quot;)<br><br>        is UiEvent.ShowDialog -&gt; {<br>            // ...<br>        }<br>    }<br><br>    viewModel.onEventConsumed(queued.id)<br>}</pre><p>The important difference is not the ID itself.</p><p>It is the <strong>identity of the queued item</strong>.</p><p>The UI is no longer saying:</p><p><em>“Remove the first event.”</em></p><p>It is saying:</p><p><em>“I consumed this particular event.”</em></p><p>That makes the contract more explicit and avoids coupling consumption to the event’s position or value.</p><h3>The Real Design Question</h3><p>The important question is not:</p><p><strong>”Which event architecture is the best?”</strong></p><p>It is:</p><p><strong>”What guarantees does this screen actually need?”</strong></p><ul><li>One UI event at a time<br>→ Single nullable event</li><li>Multiple events must survive<br>→ Queue</li><li>Events need explicit identity<br>→ Queue + ID</li><li>No event accumulation is possible<br>→ Single event is usually enough</li><li>Lost events are unacceptable<br>→ Consider stronger event semantics</li></ul><p>This also changes how we think about UiState.</p><p>A UiState does not have to contain only persistent data such as:</p><ul><li>isLoading</li><li>items</li><li>selectedItems</li></ul><p>It can also <strong>represent what the UI should do next</strong>, as long as the lifecycle and consumption semantics are clear.</p><p>The mistake is not putting an event into UiState.</p><p>The mistake is adding more machinery than the screen actually needs.</p><p>Start with the smallest state model that works.</p><p>Then make the model more explicit when the requirements force you to.</p><p>That keeps UiState simple without pretending that every UI event has the same delivery requirements.</p><p>However, in reality, I feel like the MVI style using Channels or SharedFlow for UiState is actually what’s popular.</p><p>What do you all think?</p><ul><li><a href="https://m.benigumo.com/10-flow-operators-for-building-a-declarative-uistate-eb1de6bfbc8c">10 Flow Operators for Building a Declarative UiState</a></li><li><a href="https://levelup.gitconnected.com/viewmodel-doesnt-hold-uistate-it-derives-it-22f71031236d">ViewModel Doesn’t Hold UiState. It Derives It.</a></li></ul><h4>Enjoyed this article? Here’s how you can support my work:</h4><ul><li>👏 Clap (up to 50 times!) if you found this insightful.</li><li>👤 Follow me and turn on notifications 🔔 so you never miss the next deep dive.</li><li>📧 Subscribe to get my latest stories delivered directly to your inbox.</li></ul><p>You can find all my articles on Android architecture, Jetpack Compose, and Kotlin in <a href="https://m.benigumo.com/">this curated reading list.</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5b59dac0261e" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/ui-events-in-uistate-start-simple-add-a-queue-only-when-you-need-one-5b59dac0261e">UI Events in UiState: Start Simple, Add a Queue Only When You Need One</a> was originally published in <a href="https://proandroiddev.com">ProAndroidDev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>