<?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>Tue, 20 Oct 2026 09:49:36 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[The Ghost Work Left Behind by Cancelled Coroutines]]></title>
            <link>https://proandroiddev.com/the-ghost-work-left-behind-by-cancelled-coroutines-a892bd467a6b?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/a892bd467a6b</guid>
            <category><![CDATA[cancellations]]></category>
            <category><![CDATA[kotlin-coroutines]]></category>
            <category><![CDATA[coroutinescope]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[coroutine]]></category>
            <dc:creator><![CDATA[Rohan Maity | RotBolt]]></dc:creator>
            <pubDate>Sun, 18 Oct 2026 19:56:04 GMT</pubDate>
            <atom:updated>2026-10-18T19:56:02.931Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YtfIFLEXZRd0fI-96D24XQ.png" /></figure><h4><em>Lessons from a production Android codebase</em></h4><p>Cancellation in Kotlin Coroutines looks simple: cancel the Job, and the coroutine stops. In production, it is rarely that clean.</p><p>Cancellation is cooperative. Every suspend function, exception handler, callback bridge and child scope must carry the signal. Break one boundary, and work may continue after its owner is gone.</p><p>We saw the result in production: false error logs, empty results, stale Bluetooth work and a final save lost during logout. Nothing crashed. The app simply continued with the wrong state.</p><p><strong>Cancellation is cooperative</strong></p><p>Kotlin uses CancellationException to stop a coroutine. Functions such as delay and await check the Job and throw when it is cancelled.</p><pre>val job = scope.launch {<br> delay(5_000)<br> updateState()<br>}<br>job.cancel()</pre><p>Here, delay observes cancellation, so updateState is not called. Regular Kotlin code does not check automatically. It keeps running until the next cancellation point.</p><p><strong>When cancellation becomes a failure</strong></p><pre>return try {<br> repository.fetchNotifications()<br>} catch (error: Exception) {<br> logger.error(&quot;Failed to load notifications&quot;, error)<br> emptyList()<br>}</pre><p>If the user leaves the screen, viewModelScope is cancelled and fetchNotifications throws CancellationException. The broad catch records a normal lifecycle event as a failure. It also returns emptyList, so the caller cannot distinguish cancellation from a successful query with no data.</p><p>runCatching has the same problem because it also catches CancellationException. Rethrow cancellation before handling other failures.</p><pre>catch (error: CancellationException) {<br> throw error<br>} catch (error: Exception) {<br> logger.error(&quot;Failed to load notifications&quot;, error)<br> emptyList()<br>}</pre><p>In production, normal navigation becomes error telemetry. Empty values can appear as missing documents, notifications or time cards. Support sees missing data while the app reports a completed request.</p><p><strong>Callback bridges can outlive the caller</strong></p><pre>suspend fun loadMessages() = suspendCoroutine { continuation -&gt;<br>   chatClient.loadMessages { messages -&gt;<br>   continuation.resume(messages)<br> }<br>}</pre><p>The function is suspend, but the bridge is not cancellable. If the SDK never calls back, the coroutine keeps waiting after its Job is cancelled.</p><p>Use suspendCancellableCoroutine and cancel the underlying request. A cancellable continuation alone is not enough; an Rx subscription or SDK callback can still run in the background.</p><pre>suspendCancellableCoroutine { continuation -&gt;<br>   val request = chatClient.loadMessages { messages -&gt;<br>   if (continuation.isActive) {<br>     continuation.resume(messages)<br>    }<br>   }<br>   continuation.invokeOnCancellation {<br>     request.cancel()<br>   }<br>}</pre><p>In production, chat, form or document work may outlive its screen and keep memory, network or database resources. The app remains open, but one workflow can hang or show stale state.</p><p><strong>Blocking code can ignore cancellation</strong></p><pre>withContext(Dispatchers.IO) {<br> repository.loadNotifications().blockingGet()<br>}</pre><p>Dispatchers.IO moves the blocking call off the main thread. It does not make that call cancellable. The thread stays occupied until blockingGet returns.</p><p>Prefer a suspend API such as await. If unavoidable JVM code responds to thread interruption, runInterruptible can connect it to coroutine cancellation.</p><p>In production, the work continues after its result is useless and consumes IO capacity. If a blocking boundary reaches the main thread, it can become an ANR.</p><p><strong>Work with no owner</strong></p><pre>fun onPacketReceived(bytes: ByteArray) {<br>   CoroutineScope(ioDispatcher).launch {<br>     packetChannel.send(Packet(bytes))<br>   }<br>}</pre><p>Each packet creates a scope that is immediately discarded. Nothing can cancel the work on disconnect. Independent coroutines may also try to send at the same time.</p><p>Use one scope owned by the connection manager, then serialize sends with a Mutex.</p><pre>private val packetSendMutex = Mutex()<br><br>fun onPacketReceived(bytes: ByteArray) {<br>   ownerScope.launch {<br>     packetSendMutex.withLock {<br>       packetChannel.send(Packet(bytes.copyOf()))<br>     }<br>   }  <br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Nv2eAduqtQckuU0zkmHpLw.png" /></figure><p><em>The second coroutine waits while the first send holds the mutex</em></p><p>The mutex allows only one send at a time, even when send suspends. It does not preserve callback order: packet 2 may reach the lock first. If the protocol requires strict arrival order, enqueue packets before launching asynchronous work.</p><p>In production, work can continue after disconnect, and concurrent state changes can affect reconnect. The user sees an unreliable Bluetooth connection long after the callback that caused it.</p><p><strong>Timeout as normal completion</strong></p><pre>withTimeout(timeout) {<br>   startBluetoothDiscovery()<br>   delay(timeout)<br>}</pre><p>withTimeout ends by throwing TimeoutCancellationException. When timeout is expected, withTimeoutOrNull makes the result explicit. A callbackFlow should also use awaitClose to unregister the receiver when collection ends.</p><pre>val device = withTimeoutOrNull(timeout) {<br> discoverDevice()<br>}<br>if (device == null) stopDiscovery()</pre><p>In production, discovery can stop without a clear result or recovery action. An expected timeout then looks like a connection failure.</p><p><strong>Cancellation at the final step</strong></p><p>During an account switch, the old user’s tokens had to be saved before logout changed the session. The original flow launched the save, then dispatched logout.</p><pre>viewModelScope.launch {<br> tokenStore.save(currentTokens)<br>}<br>dispatchLogout()</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WQP-qfp5zlsTwzTRWTCp4w.png" /></figure><p><em>Logout cancels the scope before the token save completes</em></p><p>Keep both operations in the same coroutine. tokenStore.save suspends, so logout is dispatched only after the save completes.</p><pre>viewModelScope.launch {<br> tokenStore.save(currentTokens)<br> dispatchLogout()<br>}</pre><p>In production, a logout race can lose authentication state or unsynced data. The failure appears in the next session, far from the cancellation that caused it.</p><p><strong>A cancelled scope stays cancelled</strong></p><pre>private val scope = CoroutineScope(SupervisorJob() + dispatcher)<br>fun clear() = scope.cancel()<br>fun startAgain() = scope.launch {<br> syncData()<br>}</pre><p>After clear, `<em>startAgain()</em>` launches into a cancelled scope, so syncData never runs. If the object is reusable, cancel its children or create a new scope for the next lifecycle.</p><pre>private val parentJob = SupervisorJob()<br>private val scope = CoroutineScope(parentJob + dispatcher)<br>fun clear() = parentJob.cancelChildren()<br>fun startAgain() = scope.launch {<br> syncData()<br>}</pre><p>cancelChildren stops the current work but leaves parentJob active, so later launches can run. If clear ends the object’s lifecycle, keep scope.cancel() and create a new owner for the next lifecycle.</p><p>In production, one background subsystem can stop while the rest of the app remains healthy. Missing backend data appears later, with no crash pointing to the cause.</p><p><strong>What these bugs cost</strong></p><p>Cancellation bugs create two costs. False failures pollute reliability data. Silent cancellations stop one workflow while the rest of the app continues.</p><p>They also separate cause from symptom. A scope is cancelled during logout; the missing state appears at the next login. A packet survives disconnect; the failure appears during reconnect.</p><p>A static review cannot prove that a pattern caused an incident. It can show where the same failure is possible and where runtime tracing should begin.</p><p><strong>Reviewing cancellation in production</strong></p><ul><li>Can this catch receive CancellationException?</li><li>Can cancellation return a valid-looking value?</li><li>Who owns this scope, and when is it cancelled?</li><li>Does cancellation stop the underlying callback or subscription?</li><li>Does important work depend on a screen scope surviving?</li><li>Can this object reuse a scope after cancelling it?</li><li>Is blocking code hidden inside a coroutine?</li><li>Is timeout an error or normal completion?</li><li>Does serialization preserve the order required by the protocol?</li></ul><p><strong>References</strong></p><p><a href="https://kotlinlang.org/docs/cancellation-and-timeouts.html">Kotlin cancellation and timeouts</a>; <a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/suspend-cancellable-coroutine.html">Kotlin suspendCancellableCoroutine</a>; <a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/callback-flow.html">Kotlin callbackFlow</a>; <a href="https://kotlinlang.org/docs/channels.html">Kotlin channels</a>; <a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-main-coroutine-dispatcher/limited-parallelism.html">Kotlin limitedParallelism</a>; <a href="https://developer.android.com/kotlin/coroutines/coroutines-best-practices">Android coroutine best practices</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a892bd467a6b" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/the-ghost-work-left-behind-by-cancelled-coroutines-a892bd467a6b">The Ghost Work Left Behind by Cancelled Coroutines</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 Power of stateIn in ViewModel]]></title>
            <link>https://proandroiddev.com/the-power-of-statein-in-viewmodel-6a57864a8c5d?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/6a57864a8c5d</guid>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[android-development]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[software-architecture]]></category>
            <dc:creator><![CDATA[chanzmao]]></dc:creator>
            <pubDate>Thu, 15 Oct 2026 17:11:34 GMT</pubDate>
            <atom:updated>2026-10-17T07:23:34.306Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Building Declarative UI State with Kotlin Flow.</em></strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3vz3h5bGF28P0aonjvL92Q.jpeg" /><figcaption>Image created by AI</figcaption></figure><p>In Jetpack Compose, we often see ViewModels that manually manage UI state, call load() from init, and update several properties whenever data changes.</p><p>This approach works, but it can introduce unnecessary boilerplate. As an application grows, keeping multiple pieces of state synchronized becomes increasingly difficult.</p><p>Kotlin Flow offers a more declarative alternative. By combining Flow operators with <strong>stateIn</strong>, we can define how UI state is derived from its inputs and let the stream handle updates.</p><p><a href="https://android.benigumo.com/20261017/viewmodel-statein/">ViewModel における stateIn の実力</a></p><h3>The Hidden Cost of Manual State Updates</h3><p>Consider a typical ViewModel:</p><pre>class MyViewModel(<br>    private val repository: MyRepository<br>) : ViewModel() {<br><br>    private val _uiState = MutableStateFlow(UiState())<br>    val uiState = _uiState.asStateFlow()<br><br>    init {<br>        load()<br>    }<br><br>    private fun load() {<br>        viewModelScope.launch {<br>            val items = repository.loadItems()<br><br>            _uiState.update {<br>                it.copy(items = items)<br>            }<br>        }<br>    }<br>}</pre><p>This is a perfectly valid implementation. It is easy to understand, especially when loading data involves a one-time operation.</p><p>However, the ViewModel is responsible for several things:</p><ul><li>Starting the initial load.</li><li>Updating the state after loading.</li><li>Preserving unrelated state fields.</li><li>Keeping the UI state consistent as more inputs are added.</li></ul><p>As the screen becomes more complex, these responsibilities can lead to repetitive state-management code.</p><p>What if the repository already exposes a Flow of items? Do we still need to collect it manually and copy every update into another state holder?</p><p>Often, we do not.</p><h3>Do We Really Need to Call load()?</h3><p>A common Compose pattern is:</p><pre>LaunchedEffect(Unit) {<br>    viewModel.load()<br>}</pre><p>Another option is to call load() from the ViewModel’s init block.</p><p>Both approaches have legitimate uses. A user-triggered refresh, a one-time operation, or a request that depends on a specific event may need an explicit command.</p><p>But when the screen simply reflects data that is already represented by a Flow, manually initiating and synchronizing every update may be unnecessary.</p><p>The important distinction is between <strong>commands and state</strong>:</p><ul><li>Commands express what should happen.</li><li>State describes what is currently true.</li></ul><p>stateIn is particularly useful when UI state can be derived from an existing Flow rather than maintained through repeated imperative updates.</p><p><a href="https://proandroiddev.com/the-evolution-of-flow-collection-in-jetpack-compose-e93416a9d52d">The Evolution of Flow Collection in Jetpack Compose</a></p><h3>Let stateIn Turn a Flow into UI State</h3><p>Suppose the repository exposes a stream of items:</p><pre>interface MyRepository {<br>    val items: Flow&lt;List&lt;Item&gt;&gt;<br>}</pre><p>The ViewModel can derive its UI state directly:</p><pre>class MyViewModel(<br>    repository: MyRepository<br>) : ViewModel() {<br><br>    val uiState: StateFlow&lt;UiState&gt; = repository.items<br>        .map { items -&gt;<br>            UiState(items = items)<br>        }<br>        .stateIn(<br>            scope = viewModelScope,<br>            started = SharingStarted.WhileSubscribed(5_000),<br>            initialValue = UiState()<br>        )<br>}</pre><p>Notice what disappeared:</p><ul><li>No private MutableStateFlow for manually updating the same UI state.</li><li>No init block to start collection.</li><li>No explicit load() call just to copy repository emissions into UI state.</li><li>No manual synchronization between repository data and the UI model.</li></ul><p>Instead, the relationship is expressed directly:</p><pre>Repository Flow<br><br> ↓<br><br>map<br> <br> ↓<br><br>UiState<br><br> ↓<br><br>stateIn<br><br> ↓<br><br>StateFlow&lt;UiState&gt;</pre><p>Whenever the repository emits a new list, the Flow produces a corresponding UiState.</p><p>stateIn converts the upstream Flow into a hot StateFlow, shares its latest value, and provides an initial value. The ViewModel exposes a state stream that the UI can observe.</p><p>This is the key benefit: <strong>the code describes how state is derived, instead of manually synchronizing it after every change.</strong></p><h3>Multiple Inputs, One Consistent UI State</h3><p>The benefits become clearer when a screen depends on more than one input.</p><p>Imagine a list screen with a search query. The visible items depend on both the repository data and the query.</p><p>With manual state updates, the ViewModel must coordinate changes to both.</p><p>With Flow, the dependency is explicit:</p><pre>class MyViewModel(<br>    repository: MyRepository<br>) : ViewModel() {<br><br>    private val query = MutableStateFlow(&quot;&quot;)<br><br>    val uiState: StateFlow&lt;UiState&gt; =<br>        combine(<br>            repository.items,<br>            query<br>        ) { items, query -&gt;<br>            UiState(<br>                items = items.filter {<br>                    it.matches(query)<br>                },<br>                query = query<br>            )<br>        }.stateIn(<br>            scope = viewModelScope,<br>            started = SharingStarted.WhileSubscribed(5_000),<br>            initialValue = UiState()<br>        )<br><br>    fun onQueryChanged(query: String) {<br>        this.query.value = query<br>    }<br>}</pre><p>The UI action updates only the query input. It does not directly mutate the public uiState.</p><p>The derived state updates automatically when either input changes:</p><ul><li>The repository emits new items.</li><li>The user changes the query.</li></ul><p>There is no need to remember every place where the visible list must be recalculated.</p><p>The dependency is defined once, in code.</p><h3>Keep Commands Explicit, State Declarative</h3><p>This does not mean every operation should be represented as a Flow.</p><p>For example, submitting a form, deleting an item, or refreshing data in response to an explicit user action may require a command handled by the ViewModel.</p><p>The useful design principle is to avoid using imperative commands merely to keep derived state synchronized.</p><p>If UI state depends on several observable inputs, express that relationship with operators such as map and combine, then use stateIn when the UI needs a StateFlow.</p><p>The result is a clearer separation:</p><ul><li><strong>Inputs</strong> represent data and user intent.</li><li><strong>Flow operators</strong> describe the relationships between inputs.</li><li><strong>stateIn</strong> exposes the derived result as a state holder.</li><li><strong>Compose</strong> renders the state.</li></ul><p>This makes the ViewModel easier to reason about because dependencies are visible rather than hidden inside a sequence of state mutations.</p><h3>Choose the Right Sharing Strategy</h3><p>stateIn requires a sharing strategy. One common choice is:</p><pre>SharingStarted.WhileSubscribed(5_000)</pre><p>This starts upstream collection when the first subscriber appears and stops it five seconds after the last subscriber disappears.</p><p>The timeout can help avoid stopping and restarting the upstream stream during brief gaps in UI subscription.</p><p>However, stopping collection is not the same as guaranteeing that a network request will be repeated when the screen returns. Whether a request runs again depends on how the upstream Flow is implemented.</p><p>Also, stateIn does not fetch data by itself. It shares and retains values emitted by its upstream Flow.</p><p>Choose the sharing strategy based on the lifecycle and behavior you want, not simply because it is a common pattern.</p><h3>Mutable State Still Has Its Place</h3><p>MutableStateFlow remains useful for inputs and state that genuinely needs to be changed imperatively.</p><p>For example:</p><pre>private val query = MutableStateFlow(&quot;&quot;)</pre><p>The query is an input controlled by user interaction. Updating it is appropriate.</p><p>The derived uiState, on the other hand, does not need to be independently mutable when it can be calculated from the repository data and query.</p><p>A practical rule is:</p><ul><li>Use MutableStateFlow for mutable inputs.</li><li>Use Flow operators to derive state from those inputs.</li><li>Use stateIn to expose the resulting stream as StateFlow.</li></ul><p>This is not about eliminating mutable state. It is about putting mutability where it belongs.</p><h3>Conclusion: Let stateIn Do the State Management</h3><p>stateIn is more than a convenient way to convert a Flow into a StateFlow. Used with operators such as map and combine, it helps make the ViewModel’s state model declarative.</p><p>Instead of manually collecting data, copying values, and synchronizing related fields, we can express the dependencies between inputs and UI state directly.</p><p>The approach is especially valuable when a screen’s state is derived from multiple observable sources.</p><p>Keep commands explicit. Keep state declarative. Let Flow define the dependencies, and use stateIn to expose the result as UI state whenever that is the right fit.</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://chanzmao.benigumo.com/the-beauty-of-statein-in-viewmodel/">The Beauty of stateIn in ViewModel</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=6a57864a8c5d" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/the-power-of-statein-in-viewmodel-6a57864a8c5d">The Power of stateIn in ViewModel</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[Tracing Business Operations, Not HTTP Calls — Part 2]]></title>
            <link>https://proandroiddev.com/tracing-business-operations-not-http-calls-part-2-517ba3b6f228?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/517ba3b6f228</guid>
            <category><![CDATA[grafana]]></category>
            <category><![CDATA[kotlin-multiplatform]]></category>
            <category><![CDATA[observability]]></category>
            <category><![CDATA[mobile-development]]></category>
            <category><![CDATA[opentelemetry]]></category>
            <dc:creator><![CDATA[Damian Petla | Tilt]]></dc:creator>
            <pubDate>Thu, 15 Oct 2026 17:06:30 GMT</pubDate>
            <atom:updated>2026-10-15T17:06:28.787Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Part 2 of 2. </em><a href="https://medium.com/p/0155cfd84c4d"><em>Part 1: Everything Is a Source</em></a><em> covers the small pipeline framework this tracing is built on.</em></p><p>When a user at Tilt says “my bid didn’t go through”, what actually happened? The app read the current user, sent a GraphQL mutation, maybe retried it, maybe refreshed a token, and the backend called two more services. Before this summer I could see pieces of that in different tools, but never the whole thing as one story.</p><p>This article is about how I designed and shipped mobile distributed tracing for Tilt’s Android and iOS apps, using OpenTelemetry in a Kotlin Multiplatform module and Grafana on the other end. The short version: <strong>the trace root is the business operation, not the HTTP call</strong>, and because of the pipeline from Part 1, opting a function into tracing costs one argument:</p><pre>.buildOnce(ShopperTracedOperation.MakeBid(roomId, auctionId, bidValue))</pre><p>The longer version includes an alpha SDK, a coroutine context that will not move, and a few bugs that nobody would have found without traces.</p><h3>What I could not see</h3><p>Tilt is a live shopping app. Sellers run live auctions, and an auction often lasts only a few seconds. If placing a bid hangs, the bid does not count, and the user does not care whether it was their network, the app or the backend.</p><p>When complaints like “the auction froze” came in, diagnosing them was slow and largely guesswork. The app had a small per-request performance tracker that emitted start, end and error events. It was a flat list of timings: no parent and child, no trace ID, no link to anything the backend saw. And by then nothing was consuming it anyway.</p><p>The deeper problem was the unit of measurement. In the shared module, business operations live in repositories, and a single repository function often combines several queries and subscriptions. Tracing each GraphQL operation on its own would give me accurate numbers for the wrong thing. Tracing a whole app session would give me one giant trace that nobody can investigate.</p><p>So the goal became: <strong>one trace per business operation</strong>. Placing a bid, loading the home feed, sending a phone verification code. Each one should be a single waterfall, from the moment the app starts the work to the last backend service that touched it. And it should carry the IDs people actually ask about: the user, the room, the auction.</p><p>I also set myself one constraint early on. Other developers would add tracing to their own features, and if they had to think about where to start a span or how to nest it, the result would be a mess. They needed an abstraction that was more intuitive than spans.</p><h3>Why OpenTelemetry and Grafana</h3><p>I did not run a vendor bake-off, and I will not pretend I did. Two facts made the choice for me.</p><p>First, the backend already used Grafana, and backend teams were introducing tracing at the same time. A mobile trace is only half as useful if it stops at the app’s edge. With OpenTelemetry and the W3C traceparent header, a span started on the phone and the spans the backend creates for the same request land in <strong>one trace</strong>. No other option gave me that end-to-end view with the stack we already ran.</p><p>Second, OpenTelemetry now has a Kotlin Multiplatform SDK, opentelemetry-kotlin, which came to the project as a donation earlier this year. One implementation in commonMain covers both platforms. It is alpha, and I accepted that deliberately. As you will see, alpha has a price.</p><p>On the Grafana side, traces go to Tempo and are explored with TraceQL. A self-hosted stack is also far cheaper than sending every span to a SaaS vendor, which mattered because I wanted to store everything at first, without sampling.</p><p>For local work, one container is enough: grafana/otel-lgtm bundles a collector, Tempo, Loki, Prometheus-compatible storage and Grafana. I ran it on my laptop and pointed the Android emulator and the iOS simulator at it. Being able to tap a button and see the span in Grafana seconds later made the design work much faster.</p><h3>Architecture at a glance</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*A_ATdTmMAf00JSHB41CauA.png" /><figcaption>The root span opens where the pipeline ends (highlighted). Each HTTP attempt carries traceparent, so the backend&#39;s spans join the same trace, and both sides reach Tempo through the same collector.</figcaption></figure><h3>The trace root is the business operation</h3><p>When I started, spans and traces were new to me. I had a hunch that the pipeline framework could carry tracing, but I almost talked myself out of it. Combining the two turned out to be the best decision in the whole project.</p><p>Look at what the pipeline already gives you. Every business operation ends in build() or buildOnce(), which is exactly where the operation begins and ends. Every network call goes through source(apolloCall), which is exactly where a GraphQL operation begins and ends. The seams I needed were already there. So the API became: name the operation at the terminal, and the framework does the rest.</p><p>Step one is a typed operation in a catalogue:</p><pre>class MakeBid(roomId: String, auctionId: String, bidValue: Int) : ShopperTracedOperation(<br>    name = &quot;makeBid&quot;,<br>    attributes = mapOf(<br>        TraceAttributes.ROOM_ID to roomId,<br>        TraceAttributes.AUCTION_ID to auctionId,<br>        TraceAttributes.BID_VALUE to bidValue,<br>    ),<br>)</pre><p>Step two is passing it to the terminal:</p><pre>suspend fun makeABid(...): TiltResult&lt;AuctionBidTiming&gt; = source(currentUserStore.currentUser)<br>    .transformToSource { user -&gt; source(apolloClient.mutation(AuctionMakeABidMutation(...))) }<br>    .transform { data -&gt; /* map timing */ }<br>    .buildOnce(ShopperTracedOperation.MakeBid(roomId, auctionId, bidValue))</pre><p>That is the whole developer-facing API. I rejected the obvious alternative, buildOnce(&quot;makeBid&quot;, mapOf(&quot;room_id&quot; to ...)), because sooner or later someone writes room.id in one place and room_id in another, and every query in Grafana breaks quietly. Attribute keys live in one object of constants, and their types are kept, so TraceQL can compare numbers as numbers.</p><p>Under that one argument, a trace looks like this:</p><pre>biz op: makeBid                    root, opened by buildOnce<br>  operation: AuctionMakeABid       one span per pipeline source<br>    attempt: AuctionMakeABid       one span per HTTP request, carries traceparent<br>      hasura /v1/graphql           backend spans, same trace</pre><p>The attempt layer came from a mid-project pivot. A span above the retry logic cannot see individual retries, so I added an Apollo interceptor below retry and auth that opens one span per physical request and injects traceparent into each one. A retried bid now shows up as two attempts under one operation, and the backend sees two distinct requests.</p><p>The pipeline gave me one more thing for free: error capture. Every operator turns exceptions into a typed TiltResult.failure, so the terminal sees <em>every</em> failure, whether it came from the network, a converter or a checkNotNull in a transform. The root span records it once, with the error type and code.</p><p>Later I added opt-in spans for steps that are not network calls but still matter, like a local data source or a heavy transform. My first version added a separate traced operator. I threw it away in review, because it made the existing operators second-class the moment tracing was on. The rule I settled on: <strong>tracing must be invisible in the operators</strong>. An existing transform takes an optional name. Unnamed, it behaves exactly as before. Named, it emits a span. Nothing else in the pipeline changes.</p><h3>Streams are different</h3><p>One-shot operations map neatly onto a trace: it starts, it ends. A live room is not one-shot. It is a set of subscriptions that can run for an hour.</p><p>The tempting answer is one long span per subscription. That is the session-trace anti-pattern again, just one level down: a root that never closes, a waterfall nobody can read. I chose bounded roots instead. build(operation) opens a root when the stream is collected and <strong>closes it at the first real combined state</strong>, the moment the screen has data to show. What I trace for streams is setup: how long it takes from opening the room to seeing the auctions.</p><p>Why not also trace each emission? I tried to design it and kept hitting the same wall. A Flow’s coroutine context is fixed when collection starts, and the conversion runs upstream of the terminal. Starting a fresh trace per emission would need machinery that knows which phase the stream is in, everywhere in the chain. And the signal would be weak. Many subscriptions emit once and then stay quiet for minutes, which is perfectly normal. I did not want to flood Tempo with traces that mostly say “nothing happened”. So per-emission tracing is deferred on purpose, and I wrote down why.</p><p>The pipeline’s shape forced a few other decisions:</p><ul><li><strong>Context crosses into Apollo explicitly.</strong> My first version passed the parent span through the coroutine context with withContext inside a flow {} builder. The unit tests passed. On a device it crashed with &quot;Flow invariant is violated&quot;, because a flow may not change its context from inside. The parent span now travels in Apollo&#39;s ExecutionContext, which is designed for exactly that.</li><li><strong>Placeholders do not count.</strong> startWith(emptyList()) unblocks the UI before data arrives. If it closed the setup root, every stream would report a perfect setup time. startWith always takes a value, never a source, so I mark it as synthetic and the root waits for real data.</li><li><strong>WebSocket connections are their own traces.</strong> A socket connection has no business operation that owns it, so ws: connect is a standalone root, named after the client it belongs to. To join backend spans, traceparent rides in the connection_init payload, because the subscription protocol has no header slot per message.</li></ul><h3>Living with an alpha SDK</h3><p>The Kotlin Multiplatform OpenTelemetry SDK let me write tracing once for both platforms. It also made me a part-time SDK maintainer.</p><p>I kept a thin wrapper of my own (Tracer, Span, SpanContext) between the module and the SDK. Not to hide the platform, but to isolate churn: when an alpha API changes, one file changes, not every repository.</p><p>That wrapper earned its keep. Three SDK defects reached me in practice, and each one was worked around in the module’s own code:</p><ol><li><strong>Exporting while offline crashed the app.</strong> A transport failure escaped the exporter. A small wrapper around the HTTP engine now turns it into an ordinary failed export.</li><li><strong>A non-protobuf error body crashed the exporter.</strong> When an error response came back with a body that was not protobuf, decoding it threw. The same wrapper now empties non-2xx bodies before the SDK reads them.</li><li><strong>The batch span processor was not thread-safe.</strong> On iOS it caused a native crash under load that became one of the app’s most frequent crashes. A teammate diagnosed it and replaced the processor with one built on a coroutine Channel.</li></ol><p>Two more gaps shaped the design:</p><ul><li><strong>No metrics.</strong> The SDK has scaffolding for a Meter but no instruments and no metric exporter. Instead of faking metrics with extra spans, I left that to the backend: rate, errors and duration can be derived from spans in the OpenTelemetry Collector with the spanmetrics connector. Until that is in place, dashboards use TraceQL metrics in Tempo, which only cover a short time window.</li><li><strong>Phones have bad clocks.</strong> The first end-to-end traces showed backend spans starting visibly <em>after</em> the client span that caused them. The SDK takes timestamps from the device clock, which can drift. The app already had a server-synced clock, so I passed that to the SDK instead.</li></ul><p>Would I choose the alpha SDK again? Yes. Every one of these problems had a contained workaround, and the alternative was two separate native implementations that would drift apart. But I would plan for it from the start, wrapper first.</p><h3>Running it in production</h3><p>Shipping the code was half the work. The other half was deciding how tracing behaves once real users generate it.</p><p><strong>Gate it from the app, read it once.</strong> Tracing is switched on per user by a feature flag that lives in the apps, not in the shared module. The module only receives a tracingEnabled lambda and does not know which flag service is behind it. The flag is read lazily on the first span and cached for the process, so it never flips halfway through a trace. Without a configured endpoint, the tracer is a no-op and the SDK is never even created.</p><p><strong>Store everything, roll out by cohort.</strong> There is no sampling on the device. Sampling on the phone would make it impossible to later capture specific users on the collector side. Volume is controlled by how many users have the flag on, and I raised that gradually, Android first, then iOS.</p><p><strong>One mobile service.</strong> Both apps report as a single service.name, with the platform as an attribute. I rarely care about Android or iOS alone, because almost everything worth tracing involves the backend too. And service.name is the one attribute Grafana treats as first-class, in the service graph and the default views.</p><p><strong>Recovered is not failed.</strong> A failed attempt followed by a successful retry produces a child span with an error under a root that succeeded. If alerting looked at any error span, every recovered retry and every token refresh would fire a false alarm. Error rates key on the root span’s status only.</p><p><strong>Cancelled is not success either.</strong> If the user leaves the screen mid-bid, the operation is cancelled, not failed. Early on, those looked like successful bids in the trace list. Now the root carries an explicit cancelled marker.</p><p><strong>A digest beats a pager.</strong> My first plan was alerts on slow or hung operations. In practice a single user on a dead network can trigger them, and that is noise. What I actually wanted was to know, every day, how bids are doing. So the alerting idea turned into a daily digest.</p><p><strong>Make it self-serve.</strong> The project was done when another developer could add tracing to their query or mutation and see the trace in Tempo without asking me. I wrote a tracing guide and an interactive agent skill: it picks the right catalogue, proposes attributes, generates the TracedOperation, compiles once, and ends with ready-to-paste TraceQL queries plus a check that the backend joins the trace. When I announced it, I added one warning: do not add tracing everywhere just because you can. Add it deliberately, where you need to diagnose an operation.</p><h3>What the traces revealed</h3><p>Tracing paid for itself before it reached production.</p><p>During the spike I put the app through airplane-mode stress tests with a local Grafana, and the waterfalls showed problems I had no way to see before:</p><ul><li><strong>A bid could hang for a long time when the network dropped mid-request</strong>, and longer still with a retry, because the retry reused a dead pooled connection.</li><li><strong>A zombie WebSocket.</strong> After a network change, the socket could stay half-open: no error, no data, frozen until the next network change. That became its own fix project.</li><li><strong>Phantom bids.</strong> A cancelled bid looked like a success in the trace list, which is how the cancelled marker was born.</li></ul><p>I made a deliberate call not to fix these before shipping tracing. Only production traces could tell me whether real users actually hit them, and how often. The bid traces later kicked off a separate project to improve the bidding experience and client-side timeouts.</p><p>In production, traces changed how investigations go:</p><ul><li><strong>Complaints that were not what they seemed.</strong> When users reported that bids “did not go up”, the traces for that room showed bids completing quickly and without errors. The real problem was how fast the <em>other</em> clients saw the new price, not the bid itself. Without traces, I would have been tuning the wrong path. One caveat surfaced too: older app versions do not emit traces, so a missing trace is not a missing bid. Check the app version first.</li><li><strong>A backend bug, seen from the phone.</strong> Setup traces for the live room had a long tail. It came from one subscription that, in a specific case, completed without sending any data. The client saw no error, so nothing retried, and the room waited. I could hand the backend team trace IDs and the exact query to reproduce it.</li><li><strong>Stale state behind “late” bids.</strong> Correlating bid traces with analytics showed that some bids were rejected because the bidder’s app was one price step behind, not because they were slow. That led to a design where the bid response itself returns the updated auction, so the bidder’s screen is never behind their own bid.</li></ul><p>None of these were exotic bugs. They were simply invisible until each step of an operation had a start, an end and a parent.</p><h3>What I would tell myself in June</h3><ol><li><strong>Pick the unit before the tool.</strong> The most important decision was not OpenTelemetry or Grafana. It was that a trace is a business operation, bounded, with the IDs people ask about. Everything else followed from that.</li><li><strong>Hide spans behind your own seams.</strong> Developers should name an operation, not place spans. If your codebase has a natural boundary for business operations, hook into it. If it does not, that is worth fixing first.</li><li><strong>Tracing must be invisible where it is not asked for.</strong> Unnamed steps cost nothing and look exactly like before. Named steps emit a span. No parallel API.</li><li><strong>Design for retries and cancellation on day one.</strong> Recovered is not failed, cancelled is not success. Getting those wrong turns a dashboard into a noise generator.</li><li><strong>Budget for the alpha.</strong> A young SDK is still the right call if it removes a whole second implementation. Wrap it, and expect to patch around it.</li><li><strong>Observe first, then fix.</strong> Shipping tracing before fixing what the spike found told me which problems real users have. Some mattered a lot, and I would not have guessed which ones.</li></ol><p>And one lesson that spans both parts of this series. The pipeline framework was built to make data code safer for people who do not live in coroutines. Months later, the same structure turned tracing into one argument per function. Small, boring infrastructure that you own compounds.</p><p>If you skipped it, Part 1: Everything Is a Source tells the story of that framework.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=517ba3b6f228" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/tracing-business-operations-not-http-calls-part-2-517ba3b6f228">Tracing Business Operations, Not HTTP Calls — Part 2</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[Everything Is a Source — Part 1]]></title>
            <link>https://proandroiddev.com/everything-is-a-source-part-1-0155cfd84c4d?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/0155cfd84c4d</guid>
            <category><![CDATA[android-development]]></category>
            <category><![CDATA[kotlin-coroutines]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[kotlin-multiplatform]]></category>
            <dc:creator><![CDATA[Damian Petla | Tilt]]></dc:creator>
            <pubDate>Thu, 15 Oct 2026 16:42:03 GMT</pubDate>
            <atom:updated>2026-10-15T16:42:02.478Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Part 1 of 2: how a small, home-grown data pipeline became the backbone of Tilt’s Kotlin Multiplatform data layer. Part 2 covers what it unlocked: OpenTelemetry tracing with Grafana.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3nyeypFPdKIuZJm-a7FFng.png" /></figure><p>At <a href="https://tilt.app/">Tilt</a>, the Android and iOS apps share one Kotlin Multiplatform module that owns networking, caching and business logic. A year ago, every repository function in it was a small, hand-rolled piece of coroutine plumbing. Today almost every new one is a single expression that reads like a sentence:</p><pre>source(apolloClient.query(ProductQuery(id)))<br>    .transform { converter.toDomain(it) }<br>    .buildOnce()</pre><p>This article is about the roughly 1,600 lines of Kotlin behind that expression: why I built them instead of adopting a library, the first design I threw away, and the one idea that survived every refactor: <strong>everything is a source</strong>.</p><p>There is also a twist I did not plan for. I built the framework as guardrails for developers who did not know Flows well. It turned out to be exactly the guardrails AI coding agents needed too.</p><h3>The problem: more contributors, less Flow experience</h3><p>In late 2025, iOS developers started contributing to the shared module. That was the point of Kotlin Multiplatform, and it was great. But coroutines and Flows have plenty of sharp edges: when a flow completes, what flatMapLatest cancels, where an exception ends up, why you must never swallow a CancellationException. These are not obvious if Swift is your daily language.</p><p>The bigger issue was not knowledge of Flows. It was <strong>error handling that was quietly skipped</strong>. A typical repository function looked like this:</p><pre>return flow {<br>    emit(executeDataCatching(apolloClient.query(BundlesConfigQuery(roomId))) { /* map */ })<br>}.flatMapLatest { configResult -&gt;<br>    if (configResult.isSuccess) {<br>        val config = configResult.getOrThrow()<br>        if (config.sellerId != null &amp;&amp; config.enabled) {<br>            combineTiltResult(<br>               apolloClient.subscription(BundlesSubscription(config.sellerId)),<br>                apolloClient.subscription(AddressSubscription(roomId)),<br>            ) { bundles, address -&gt; converter.toDomain(bundles, address) }<br>        } else {<br>            flowOf(TiltResult.success(ShippingBundleState.Inactive))<br>        }<br>    } else {<br>        flowOf(TiltResult.failure(configResult.exceptionOrThrow()))<br>    }<br>}.retryUntilChanged(LIVE_MODEL)</pre><p>It works. But the isSuccess / getOrThrow / flowOf(failure) ladder is written by hand every time. Each time, someone can forget a branch, call getOrThrow() one line too early, or wrap a runCatching around something that should have stayed typed. An audit of the codebase at the time found exactly that: errors wrapped inconsistently, and at least one call that skipped the wrapper altogether.</p><p>The second problem was <strong>combining</strong>. The existing helper, combineTiltResult, only understood Apollo calls, plus a few hand-picked mixes with Flows. Every new combination of arity and input type needed a new overload. There were 11 of them, each repeating the same body. I needed to combine <em>anything</em>: a query with a subscription, a local store, a cached value, a settings flow.</p><p>One thing was not up for debate: TiltResult, Tilt&#39;s own result type. It existed before any of this, because Kotlin&#39;s Result does not bridge to Swift. Whatever I built had to speak TiltResult end to end.</p><h3>Build or adopt?</h3><p>I did not go shopping for a library. That was a deliberate choice, not an oversight.</p><p>What I needed was small: wrap a few kinds of input into one shape, combine them, transform them, and handle errors the same way everywhere. That does not justify an external SDK with its own release cycle, its own opinions about caching, and its own learning curve. I also wanted <strong>full control</strong>. This would sit under every repository in the module. When it needed to change, I wanted to change it that afternoon, not open an issue upstream.</p><p>That decision paid off later in a way I could not have predicted. More on that at the end.</p><h3>Attempt one: a DSL nobody could discover</h3><p>The first version, in November 2025, was a builder DSL. It looked tidy on a slide:</p><pre>dataPipeline {<br>    addSource&lt;UserData&gt;(observe(apolloClient.subscription(UserSubscription())))<br>    addSource&lt;RoomData&gt;(observe(apolloClient.subscription(RoomSubscription(roomId))))<br>    combine&lt;UserData, RoomData&gt; { user, room -&gt; converter.toDomain(room, user) }<br>}.build()</pre><p>In practice it was painful to use. <strong>IDE auto-completion barely helped</strong>: inside the builder lambda, you had to already know what to type. It was not clear from the code how the pieces fit together, or which calls were legal in which order. Under the hood, combine checked at <em>runtime</em> that you had registered the right number of sources, and cast them unchecked. The compiler could not help you either.</p><p>There was also a split between fetch(...) for one-shot calls and observe(...) for streams. It sounded clear. But Apollo already emits once for queries and mutations and keeps emitting for subscriptions. The second vocabulary only added a way to pick the wrong one.</p><p>In January 2026 I replaced the whole thing. That rewrite is where the real idea showed up.</p><h3>The idea that stuck: everything is a source</h3><p>The rewrite dropped the builder and kept one concept: a PipelineSource&lt;T&gt;. Under the hood it is just a Flow&lt;TiltResult&lt;T&gt;&gt;, so a failure is a <em>value</em> that travels down the chain instead of an exception that escapes it. You never touch that flow directly. You build sources, connect them, and close the pipeline with a terminal.</p><p>There are three kinds of building blocks.</p><p><strong>1. One factory for every input.</strong> source(...) has overloads for an Apollo call (query, mutation or subscription), a Flow&lt;T&gt;, a plain value, or a TiltResult you already have. Whether it emits once or many times is a property of the input, not something you declare.</p><p><strong>2. Operators, named by intent.</strong> transform, transformToSourceLatest for dependent calls, validate, distinctUntilChangedBy, timeout. Error operators follow one naming rule: with* changes the stream (withFallbackValue, withFallbackSource), on* only observes it (onSuccess, onFailure). Every operator catches exceptions and turns them into a typed failure. A checkNotNull inside a transform becomes a parsing error the caller can handle, not a crash.</p><p><strong>3. Combining, with types the compiler checks.</strong> sourceCombine(a, b, c) { a, b, c -&gt; } is a plain function with fixed arities. It short-circuits on the first failure, so the lambda only runs when every input succeeded and its parameters are never wrapped in a result. For a dynamic number of inputs there is combineSources(list).</p><p>Then you pick a terminal. build() returns a Flow&lt;TiltResult&lt;R&gt;&gt; for long-lived streams and adds retry with backoff and de-duplication. buildOnce() is a suspend function returning TiltResult&lt;R&gt; for one-shot reads.</p><p>Here is the function from the previous section, rewritten:</p><pre>val configSource = source(apolloClient.query(BundlesConfigQuery(roomId)))<br>    .transform { data -&gt; data.toConfigParams() }<br><br>val bundlesSource = configSource.transformToSourceLatest { config -&gt;<br>    if (config.sellerId != null &amp;&amp; config.enabled)<br>        source(apolloClient.subscription(BundlesSubscription(config.sellerId))).asNullable()<br>    else emptySource()<br>}<br>val countrySource = source(apolloClient.subscription(AddressSubscription(roomId)))<br>    .transform { it.address?.country }<br>return sourceCombine(configSource, bundlesSource, countrySource) { config, bundles, country -&gt;<br>    converter.toDomain(bundles, config, country)<br>}.build()</pre><p>The ladder is gone. Nobody writes isSuccess or getOrThrow, because short-circuiting on failure is the framework&#39;s job. Each source is a named value you can read in isolation. And all 11 combineTiltResult overloads were eventually deleted.</p><p>The idea grew past repositories. The in-memory stores now expose fooSource(): PipelineSource&lt;T&gt; instead of a suspend fun getFoo(). A repository that needs the store&#39;s state just composes it into its own pipeline, with no flow { emit(store.getState()) } trick in between. Before landing there, I considered letting a core operator accept suspend lambdas so the trick would not be needed. I rejected it, because it would invite network calls and delay() inside lambdas instead of modelling them as sources. If something produces data, it is a source.</p><h3>Failures propagate. Degradation is a decision.</h3><p>The most important rule in the framework is not in any operator. It is a default: <strong>a failure reaches the consumer unless someone decides otherwise.</strong></p><p>This sounds obvious until you watch it erode. When one of three combined sources can fail, the easy fix is to wrap each of them in .asNullable().withFallbackValue(null). Nothing blocks, the screen renders, and everyone moves on. But now the data can be stale, and the place that observes it and would log the problem never hears about it. I pushed back on exactly this pattern more than once.</p><p>The opposite mistake is just as real. When I migrated the live auction stream, one failed product lookup could suddenly fail the whole live room. Before, it only dropped one auction from the list. That is not acceptable, and it is precisely what fallback operators exist for. So the rule has two halves:</p><ol><li>Propagate by default. A plain source has no fallback.</li><li>Degrade on purpose, where the domain says one part must not take down the whole. Make that decision visible in the code, and log before you swallow the error.</li></ol><p>The home feed shows both halves in one function. It fetches two things in parallel: the user’s country from their settings, which only fine-tunes the results, and the feed itself. A slow or failed settings call must never block the feed. A failed feed must reach the caller. Before the migration, that took hand-rolled coroutines:</p><pre>): TiltResult&lt;ContentGroupPage&gt; = coroutineScope {<br>    val getUserCountryDeferred = async {<br>        withTimeoutOrNull(USER_SETTINGS_TIMEOUT) {<br>            userRepository.getUserSettings()<br>                .onSuccess { logger.i { &quot;User settings: $it&quot; } }<br>                .onFailure { logger.e(it) { &quot;Failed fetching user settings&quot; } }<br>                .getOrNull()?.country<br>        }<br>    }<br>    val feedDeferred = async {<br>        executeDataCatching(apolloClient.query(PersonalFeedQuery(/* … */))) { data -&gt; data }<br>    }<br>    val userCountry = getUserCountryDeferred.await().also { userCountry = it }<br>    return@coroutineScope feedDeferred.await().map { data -&gt; /* convert */ }<br>}</pre><p>The degradation is there, but it is buried in withTimeoutOrNull and getOrNull(). Nothing says which of the two fetches is allowed to fail. Here is the same function on the pipeline:</p><pre>val userCountrySource = userSettingsStore.userSettingsSource()<br>    .onFailure { logger.e(it) { &quot;Failed fetching user settings&quot; } }<br>    .transform { it.country }<br>    .asNullable()<br>    .timeout(USER_SETTINGS_TIMEOUT.inWholeMilliseconds, null)<br>    .withFallbackValue(null) // settings may never block the feed<br><br>val feedSource = source(apolloClient.query(PersonalFeedQuery(/* … */)))<br>return sourceCombine(userCountrySource, feedSource) { country, data -&gt;<br>    /* convert */<br>}.buildOnce(ShopperTracedOperation.GetFeedContentGroups(/* … */))</pre><p>Now the decision reads off the code. The settings source degrades on purpose: it logs, times out and falls back to null. The feed source has no fallback, so its failure reaches the caller. And because the settings store exposes a source, it composes like any other input.</p><p>One more distinction lives in this rule: <strong>absent is not failed</strong>. “This user has no active bundle” is a successful response with a null field. “The bundle could not be fetched” is a failure. Mixing those up is how fallbacks start hiding outages.</p><h3>A migration story: when “the stream is the cache” was too clever</h3><p>The best test of the framework was the busiest stream in the app: the auctions in a live room. Each auction references a product, so the old code subscribed to auctions, collected product IDs, and fetched missing products into a hand-maintained cache:</p><pre>private val productsCache = mutableMapOf&lt;String, Product&gt;()<br>apolloClient.subscription(AuctionsSubscription(roomId))<br>    .mapToTiltResult { auctions -&gt;<br>        val ids = auctions.productIds()<br>        cleanupUnusedProducts(ids)<br>        productsCache.putAll(fetchProductsConcurrently(ids - productsCache.keys)) // async + awaitAll<br>        AuctionsWithProducts(auctions, buildRelevantProductsMap(ids))<br>    }</pre><p>The first migration looked beautiful. A new combineSources(list) operator fanned out one source per product. distinctUntilChangedBy { ids } skipped work when the product set had not changed. The manual cache and three helper functions were deleted. The cache was no longer a data structure but a live subscription. <strong>The stream was the cache.</strong></p><p>Then code review found the catch. distinctUntilChangedBy held back the <em>whole</em> stream, so auction updates that did not change the product set never reached the screen. And any change to the set re-fetched every product, not just the new one.</p><p>The agent working with me proposed a new generic operator, combineKeyedSources, complete with tests. I stashed it. It solved the problem by adding a framework primitive for one call site. The fix I shipped was smaller: a plain cache that <em>hands out sources</em>.</p><pre>private fun productSource(id: String): PipelineSource&lt;Product?&gt; {<br>    productsCache[id]?.let { return source(it) } // hit: a value source, no network<br>    return source(apolloClient.query(ProductQuery(id)))<br>        .transform { it.toProduct() }<br>        .onSuccess { productsCache[id] = it }<br>        .onFailure { logger.w(it) { &quot;Failed to fetch product $id&quot; } }<br>        .withFallbackValue(null)<br>}</pre><p>A side effect I did not expect: the onFailure line logs the <em>cause</em> of a failed product fetch. The old imperative code used getOrNull() and threw it away silently.</p><p>The lesson for me: deleting state in favour of an elegant stream changed behaviour in ways no type caught. The right answer was not a cleverer operator. It was a boring cache that speaks the framework’s language.</p><h3>Guardrails for people turned out to be guardrails for agents</h3><p>I designed the framework for a developer who does not know every nuance of Flows and might skip error handling under deadline pressure. A few months later, the team was writing a large share of its code with AI agents. An agent fits that description surprisingly well.</p><p>Agents are fluent in coroutines in general and unreliable in the specifics. Left alone, they reach for whatever pattern they saw most often in training. With the framework, the specifics are narrowed down to a small vocabulary: make sources, combine them, end with build() or buildOnce(). I wrote that vocabulary into the agent guides as the mandatory pattern for repository methods, and a pipeline framework guide sits next to the architecture docs.</p><p>The mistakes I still catch are telling, because they are almost always a step <em>away</em> from the framework:</p><ul><li><strong>Abandoning </strong><strong>sourceCombine</strong> for hand-written async/awaitAll, convinced the pipeline broke parallelism. It did not. For one-shot sources, combine runs them concurrently and cancels them together. The real culprit was a test mock server that answered requests in order. The pipeline version was about 10 lines instead of 30.</li><li><strong>Using the wrong terminal</strong>, like build().first() instead of buildOnce(). That quietly adds retry and de-duplication meant for long-lived streams.</li><li><strong>Adding fallbacks by reflex</strong>, which is the failure-propagation problem from earlier.</li></ul><p>Each of these is easy to spot in review <em>because</em> the framework makes the correct shape so recognisable. A deviation stands out. The team took to it well, and with the guides in place, agents built new features on top of the pipeline quickly and consistently. Today more than 50 files in the module build on source(...). Older code still uses the previous helpers, and it moves over function by function, which was a design goal from day one.</p><h3>What I would tell myself in November 2025</h3><ol><li><strong>Discoverability beats elegance.</strong> The DSL looked better in a design doc. Free functions on plain values won because your IDE can complete them and a newcomer can read them top to bottom.</li><li><strong>Defaults matter more than features.</strong> The operator list grew over time. The thing that actually changed code quality was one default: failures travel to the consumer unless someone opts out, in writing.</li><li><strong>One concept scales further than many.</strong> fetch vs observe, builders, special-purpose operators: each was a second way to say the same thing. &quot;Everything is a source&quot; kept absorbing new cases, from stores to caches, without new vocabulary.</li><li><strong>The framework got simpler as it matured.</strong> Months later I seriously considered going back to a DSL-first build { } shape. An analysis showed it would change nothing at runtime, touch over 180 call sites, and leave two vocabularies to maintain. I kept the boring version.</li><li><strong>Own your small infrastructure.</strong> Because the code is mine, I can change a core operator the day I need it, with no workaround on top of a library.</li></ol><p>That last point is where Part 2 begins. This summer I set out to add distributed tracing for business operations like placing a bid, across both apps. In most codebases that means touching every call site. In this one, every business operation already ended in build() or buildOnce(), and every network call already went through source(...). So tracing a function cost one argument:</p><pre>.buildOnce(ShopperTracedOperation.MakeBid(roomId, auctionId, amount))</pre><p><a href="https://medium.com/p/517ba3b6f228"><strong>In Part 2</strong></a><strong>:</strong> how I built mobile OpenTelemetry tracing on top of the pipeline, what Grafana revealed that had been invisible before, and what it is like to run an alpha OpenTelemetry SDK for Kotlin Multiplatform in production.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0155cfd84c4d" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/everything-is-a-source-part-1-0155cfd84c4d">Everything Is a Source — Part 1</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[Swift packages in Kotlin Multiplatform - built-in SwiftPM import with Kotlin 2.4.x]]></title>
            <link>https://proandroiddev.com/swift-packages-in-kotlin-multiplatform-built-in-swiftpm-import-with-kotlin-2-4-x-f9f5380d39be?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/f9f5380d39be</guid>
            <category><![CDATA[swift]]></category>
            <category><![CDATA[ios-development]]></category>
            <category><![CDATA[android-development]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[kotlin-multiplatform]]></category>
            <dc:creator><![CDATA[Krzysztof Dąbrowski]]></dc:creator>
            <pubDate>Thu, 15 Oct 2026 16:35:24 GMT</pubDate>
            <atom:updated>2026-10-15T16:35:23.373Z</atom:updated>
            <content:encoded><![CDATA[<h3><strong>Swift packages in Kotlin Multiplatform — built-in SwiftPM import with Kotlin 2.4.x</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0V2UGH0aTveP5BY3PKHxAA.png" /></figure><p>In 2026, creating a cross-platform mobile app with shared business logic and UI between Android and iOS is fairly straightforward thanks to Kotlin Multiplatform and Compose Multiplatform. However, sooner or later we might need a platform-specific library that cannot be added to commonMain, and suddenly things aren’t as straightforward as we would expect.</p><p>Google Nearby Connections is a good example. Its iOS SDK is distributed as a Swift package, but downloading and compiling it doesn’t necessarily mean that its APIs will be accessible from Kotlin. There are a few additional pieces involved, and Kotlin 2.4.0 made the configuration easier thanks to built-in <a href="https://kotlinlang.org/docs/whatsnew24.html#swift-package-import"><em>SwiftPM import</em></a>.</p><p>In this article, I’ll describe how I migrated an existing spm4Kmp integration to this approach, what could be removed and why some Swift code still had to stay.</p><h3>Offline messaging and audio calls with Flight Chat</h3><p>Flight Chat is a peer-to-peer offline messenger built with KMP and CMP. It allows iOS and Android devices to exchange text messages and make audio calls without an Internet connection or a server in between. Google’s Nearby Connections SDK handles communication, while the application shares its business logic and UI.</p><p>You can try Flight Chat on <a href="https://play.google.com/store/apps/details?id=eu.krzdabrowski.offlinemessenger">Google Play</a> or the <a href="https://apps.apple.com/app/flight-chat-offline-messenger/id6809567938">App Store</a>.</p><p>The native SDK integration sits behind platform-specific implementations, which is quite convenient for this migration — changing how the iOS dependency is built doesn’t require changes in ViewModels or Composables.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/816/1*SgUWVVs9CXyWqz5A_g3GSA.gif" /><figcaption>Flight Chat — offline text messages and audio calls between iOS and Android</figcaption></figure><h3>Swift interop — there is still Objective-C underneath</h3><p>The original integration used the <a href="https://github.com/frankois944/spm4Kmp"><em>spm4Kmp Gradle plugin</em></a> to handle Swift package dependencies and the build configuration needed to make compatible APIs available to Kotlin. Moreover, it supported local Swift bridge code, which was crucial for integrating the Nearby SDK.</p><p>The important word here is “compatible”. Kotlin/Native’s Objective-C interoperability doesn’t expose arbitrary Swift APIs, so a Swift package can compile successfully while the generated Kotlin bindings contain almost nothing useful. For someone used to simply adding a third-party dependency and using its classes, this was a somewhat unpleasant surprise. I encountered exactly this situation and <a href="https://github.com/frankois944/spm4Kmp/issues/68">opened an issue</a>, where François (the plugin’s author) helped me understand the interoperability limitations. The discussion also became the plugin’s first FAQ entry, so hopefully it saved someone else a similar surprise.</p><p>The Nearby SDK includes a Swift-facing NearbyConnections product and a lower-level NearbyCoreAdapter module. You can see how Google’s Swift API uses that module in its <a href="https://github.com/google/nearby/blob/main/connections/swift/NearbyConnections/Sources/Advertiser.swift"><em>Advertiser implementation</em></a>. My local Swift wrappers also call NearbyCoreAdapter directly, while exposing Objective-C-compatible APIs to Kotlin. For example, my advertiser wrapper inherits from NSObject and uses @objc and @objcMembers to expose its compatible declarations. Its advertising methods then call GNCCoreAdapter.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g-ogNZ6HuKtb3h7yx_syfA.png" /><figcaption>Package dependencies: nearbyIos depends on NearbyConnections, which then brings in NearbyCoreAdapter. API calls: Kotlin calls the Objective-C-visible nearbyIos Swift wrappers, which call NearbyCoreAdapter.</figcaption></figure><p><strong>The package we depend on and the APIs we call are not quite the same thing.</strong> This distinction matters because replacing the package integration doesn’t remove the need for Objective-C-compatible APIs. The wrappers remain necessary with either build setup.</p><h3>Kotlin 2.4.x — missing module &amp; SwiftPM import</h3><p>Unfortunately, after upgrading from Kotlin 2.3.20 to 2.4.0, the Swift package still compiled, but the subsequent cinterop task failed with:</p><pre>fatal error: module &#39;NearbyCoreAdapter&#39; not found</pre><p>The generated Swift header referenced NearbyCoreAdapter, yet the cinterop configuration didn’t include the path needed to resolve that transitive module. Adding its build directory to compilerOpts worked around the problem, although maintaining a path into another tool’s build output wasn’t particularly appealing.</p><p>I <a href="https://github.com/frankois944/spm4Kmp/issues/325"><em>reported</em></a> the issue, but we couldn’t reproduce it in the plugin’s example project. Thankfully, the same Kotlin release introduced an alternative integration, so I decided to try it.</p><p>And just a side-note — <strong>SwiftPM import and Swift export are separate features</strong>:</p><ul><li>The former imports compatible APIs from Swift packages into Kotlin.</li><li>The latter exposes Kotlin APIs to Swift.</li></ul><p>The <a href="https://kotlinlang.org/docs/whatsnew24.html#swift-export-goes-alpha-with-improved-concurrency-support">Swift export alpha announcement</a> doesn’t mean that Kotlin can suddenly call every Swift API. Objective-C is still alive.</p><h3>Build configuration — a local Swift package</h3><p>With spm4kmp, the swiftPackageConfig block declared the Nearby dependency, configured the Swift bridge and prepared the package consumed by Xcode. With the new integration, the wrappers became a regular local Swift package with their own Package.swift.</p><p>The manifest declares a library product called nearbyIos, a target containing the existing Swift files and a dependency on Google’s repository. The target then depends on the NearbyConnections product from that package.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/9cc3c6a9a7d5c2e1726947fe74607069/href">https://medium.com/media/9cc3c6a9a7d5c2e1726947fe74607069/href</a></iframe><p>There are two concepts worth distinguishing here: a SwiftPM <strong>product</strong> is what another package consumes, while a <strong>target</strong> describes the source module and its dependencies. In this local package, both are named nearbyIos, which makes the configuration fairly straightforward.</p><p>On the Gradle side, we point to that package and select the module to import:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/7920e0159b7b7192713f7f01c9bb6360/href">https://medium.com/media/7920e0159b7b7192713f7f01c9bb6360/href</a></iframe><p>You may notice that products and importedClangModules contain the same name. They serve different purposes: the former selects the SwiftPM product, while the latter selects the Clang module whose APIs should be imported. Setting discoverClangModulesImplicitly to false makes that selection explicit instead of automatically discovering available modules.</p><h3>Xcode integration — Gradle is only part of the setup</h3><p>Having Kotlin bindings available doesn’t finish the integration. The iOS application also needs the corresponding native code linked into it.</p><p>Kotlin’s tooling generates an intermediary package called KotlinMultiplatformLinkedPackage. In this project, it depends on the local nearbyIos package and replaces the previous exportedNearbyIos reference in Xcode. The <a href="https://kotlinlang.org/docs/multiplatform/multiplatform-spm-import.html"><em>official integration guide</em></a> describes the :integrateLinkagePackage Gradle task, which generates the package and updates the Xcode project.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sRAvxhFfDK6od0cZPj8PfQ.png" /><figcaption>The generated linkage package connects the Swift dependencies to the iOS app</figcaption></figure><p>The generated package, updated Xcode project and the SwiftPM resolution lock can be committed to version control. The lock records the resolved dependency revisions, including transitive ones.</p><p>On the Kotlin side, the generated package name changed as well:</p><pre>-import nearbyIos.Advertiser<br>+import swiftPMImport.projectName.moduleName.Advertiser</pre><p>One additional CI adjustment was to set rootProject.name explicitly in settings.gradle.kts. It’s worth checking this setting when applying the migration to your own project.</p><h3>Was the migration worth it?</h3><p>For this particular integration, definitely. The initial migration worked on the first try, and I could remove both the plugin and the temporary cinterop workaround while retaining the existing Swift bridge.</p><p>However, keep in mind that built-in support doesn’t mean every Swift package will work without additional effort — SwiftPM import is still documented as alpha. The migration used Kotlin 2.4.0; <a href="https://github.com/JetBrains/kotlin/releases/tag/v2.4.21"><em>Kotlin 2.4.21</em></a> is available at the time of writing. Check the current integration docs for the Kotlin version you use.</p><p>If your existing integration works well, I wouldn’t migrate just for the sake of it. In my case, an unresolved integration issue and the opportunity to simplify the setup made it a worthwhile change. After all, spm4Kmp had already done the heavy lifting of making the original integration possible.</p><p>Hopefully these configuration examples save you some time when the next Swift dependency compiles successfully, but Kotlin still claims it has never heard of it!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f9f5380d39be" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/swift-packages-in-kotlin-multiplatform-built-in-swiftpm-import-with-kotlin-2-4-x-f9f5380d39be">Swift packages in Kotlin Multiplatform - built-in SwiftPM import with Kotlin 2.4.x</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[Part 1: Kotlin Flows — The Air Compressor of Android (Fundamentals & Cold Streams)]]></title>
            <link>https://proandroiddev.com/part-1-kotlin-flows-the-air-compressor-of-android-fundamentals-cold-streams-7ae94471c1d7?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/7ae94471c1d7</guid>
            <category><![CDATA[kotlin-flow]]></category>
            <dc:creator><![CDATA[Sandeep Kella]]></dc:creator>
            <pubDate>Thu, 15 Oct 2026 16:34:19 GMT</pubDate>
            <atom:updated>2026-10-15T16:34:17.849Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IKhQYar11BES_nWfmDh-9g.png" /></figure><p>Hey! So you want to learn Kotlin Flows? Grab a seat. I know the official documentation can sometimes feel like reading a microwave manual, but I promise you, once you get the mental model right, Flows are going to be your best friend.</p><p>Forget about the code for a second. Let’s talk about air.</p><h3>The Breakdown: The Air Compressor Analogy</h3><p>Imagine you are painting a car using an <strong>Air Compressor</strong> and a <strong>Spray Gun</strong>.</p><ul><li><strong>The Producer (The Compressor):</strong> This is where the air is generated. In Kotlin, this is your database, your network call, or a timer.</li><li><strong>The Flow (The Hose):</strong> This is the pipe connecting the compressor to your tool.</li><li><strong>The Operators (Valves &amp; Filters):</strong> Maybe you have a moisture filter on the hose, or a pressure regulator. It modifies the air before it reaches you.</li><li><strong>The Consumer / Collector (The Spray Gun):</strong> This is your Android UI.</li></ul><p>Here is the most important concept to understand about a standard Kotlin Flow: <strong>It is a COLD stream.</strong></p><p>What does “Cold” mean? Imagine your air compressor is completely turned off and silent. It <strong>will not</strong> generate a single ounce of air <em>until</em> you pull the trigger on the spray gun. The moment you pull the trigger (in Kotlin, we call this .collect()), the compressor kicks on, air pushes through the filters, and paint comes out. When you let go of the trigger (cancel the coroutine), the whole system shuts down instantly.</p><p><strong>Diagrammatic Mental Model:</strong></p><p>Plaintext</p><pre>[Air Compressor] ---&gt; (Pressure Valve) ---&gt; (Moisture Filter) ---&gt; [Spray Gun]<br>   (Producer)           (Operator: map)       (Operator: filter)      (Collector)<br>   <br>Status: DEAD SILENT until someone pulls the trigger on the Spray Gun.</pre><p>Let’s look at how this looks in code:</p><p>Kotlin</p><pre>// 1. The Compressor (Producer)<br>val airFlow = flow {<br>    println(&quot;Compressor turned on!&quot;)<br>    emit(&quot;Air Burst 1&quot;)<br>    delay(1000) // Takes a second to build pressure<br>    emit(&quot;Air Burst 2&quot;)<br>}<br><br>// 2. The Trigger (Collector)<br>// If we don&#39;t write this code below, the &quot;Compressor turned on!&quot; is NEVER printed.<br>coroutineScope.launch {<br>    airFlow.collect { air -&gt;<br>        println(&quot;Spray gun received: $air&quot;)<br>    }<br>}</pre><h3>Under the Hood: What happens internally?</h3><p>How does the air actually move? When you call .collect(), you are passing a &quot;Continuation&quot; (a fancy word for a callback) up the pipe to the producer.</p><p>The emit() function is a <strong>suspend function</strong>. This means if the pipe gets clogged (maybe your UI is taking too long to draw the screen), the emit() function literally pauses the air compressor. It doesn&#39;t crash, it doesn&#39;t block the main thread; it just says, <em>&quot;Whoa, the pipe is full, let&#39;s suspend and wait until the spray gun uses up some air.&quot;</em> It&#39;s a perfect pressure-regulated system.</p><h3>The “Wait, But Why?” Section</h3><p><strong>Doubt:</strong> <em>“Dude, why are we doing all this? If I just want a list of users from a database, why don’t I just return a </em><em>List&lt;User&gt;? Why do I need a Flow?&quot;</em></p><p><strong>Answer:</strong> Great question. Think of a List as a bucket of water. You have to fill the <em>entire</em> bucket before you can carry it to the UI. If you have 10,000 users, your app will freeze while it fills the bucket. A Flow is a hose. As soon as the first drop of water (or puff of air) is ready, it travels down the hose to your UI. Your UI can start displaying the first few users while the rest are still being fetched. Plus, if the database updates, the hose just pushes the new data automatically. A bucket can&#39;t do that!</p><p><strong>Doubt:</strong> <em>“Okay, but what about LiveData? We’ve used that for years!”</em></p><p><strong>Answer:</strong> LiveData is like an Android-branded bucket. It’s great for the UI, but it ONLY works on the Main Thread, and it doesn’t have good valves/filters (operators). Flow is pure Kotlin — it doesn’t care about Android. You can run it on background threads easily, filter it, combine it with other flows, and then only pass the final, polished result to the UI.</p><h3>Tiered Interview Questions (What to expect)</h3><h4>Level 1: The Junior Developer (1–3 Years Experience)</h4><ul><li><strong>Question:</strong> What is the difference between a Cold Flow and a Hot Flow?</li><li><strong>How you answer:</strong> “A standard Kotlin Flow is cold. This means the code inside the flow { } builder does not run until a consumer calls .collect() on it. If there are no collectors, no work is done. A hot flow (like StateFlow) is always active, regardless of whether someone is listening.&quot;</li><li><strong>Question:</strong> Can you call emit() outside of a Coroutine?</li><li><strong>How you answer:</strong> “No. emit() is a suspend function, meaning it needs to be called within a Coroutine or another suspend function. This is what allows Flow to be non-blocking.&quot;</li></ul><h4>Level 2: The Mid-Level Developer (4–5 Years Experience)</h4><ul><li><strong>Question:</strong> What happens to a Flow if the Coroutine collecting it is cancelled? Does it leak memory?</li><li><strong>How you answer:</strong> “No memory leaks! Flow is perfectly tied to Structured Concurrency. When the collecting Coroutine is cancelled (for example, when a ViewModel is cleared), the collector stops listening. Because the Flow is cold, this cancellation propagates upstream, and the producer (flow { ... } block) is immediately cancelled and stops emitting.&quot;</li><li><strong>Question:</strong> How does Flow differ from RxJava’s Observable?</li><li><strong>How you answer:</strong> “Flow is built entirely on Kotlin Coroutines, making it much lighter. RxJava uses its own heavy threading model and complex backpressure management. Flow handles backpressure natively because emit() is a suspend function—if the collector is slow, the emitter naturally suspends, handling backpressure implicitly without extra callbacks.&quot;</li></ul><h4>Level 3: The Senior/Architect</h4><ul><li><strong>Question:</strong> How does Flow ensure Context Preservation, and why will the compiler yell at you if you use withContext inside a Flow builder?</li><li><strong>How you answer:</strong> “Flow guarantees Context Preservation, meaning the flow builder <em>must</em> emit in the same CoroutineContext as the collector. If you try to use withContext(Dispatchers.IO) inside the flow {} builder, it violates this rule and throws an IllegalStateException. To change the dispatcher for the upstream (the producer), you <em>must</em> use the .flowOn(Dispatchers.IO) operator. This architectural choice prevents downstream collectors (like the UI) from accidentally executing on a background thread.&quot;</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7ae94471c1d7" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/part-1-kotlin-flows-the-air-compressor-of-android-fundamentals-cold-streams-7ae94471c1d7">Part 1: Kotlin Flows — The Air Compressor of Android (Fundamentals &amp; Cold Streams)</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 Evolution of Flow Collection in Jetpack Compose]]></title>
            <link>https://proandroiddev.com/the-evolution-of-flow-collection-in-jetpack-compose-e93416a9d52d?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/e93416a9d52d</guid>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[android-development]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <dc:creator><![CDATA[chanzmao]]></dc:creator>
            <pubDate>Tue, 13 Oct 2026 00:04:28 GMT</pubDate>
            <atom:updated>2026-10-17T07:33:00.787Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>From basic state collection to lifecycle-aware UI updates</em></strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Bt42Z_a-_AUre1NwZmxGoA.jpeg" /><figcaption>Image created by AI</figcaption></figure><p>When collecting a Flow in Jetpack Compose, the code can be as simple as this:</p><pre>val uiState by viewModel.uiState.collectAsState()</pre><p>But Android applications have a lifecycle. When an app moves to the background, should the UI continue collecting updates? What happens when the user returns?</p><p>Over the years, Android developers have used several approaches to handle these questions. Understanding how they evolved helps us choose the right API without adding unnecessary code.</p><p>Let’s walk through the key changes, from <strong>collectAsState()</strong>to <strong>collectAsStateWithLifecycle()</strong>.</p><p><a href="https://android.benigumo.com/20261012/collectasstatewithlifecycle/">【Jetpack Compose】もう冗長なコードは不要！ collectAsStateWithLifecycle で Flow を安全に収集する</a></p><h3>1. Compose 1.0: collectAsState()</h3><h4>July 2021</h4><p>Jetpack Compose 1.0 introduced collectAsState() as a straightforward way to convert a Flow into Compose State.</p><pre>val uiState by viewModel.uiState.collectAsState()</pre><p>Whenever the Flow emits a new value, Compose updates the state and recomposes the relevant UI.</p><p>The code is simple, but there is an important distinction: collectAsState() does not automatically stop collection when the Android lifecycle falls below STARTED.</p><p>For example, when an Activity moves to the background, collection can continue as long as the composition remains active.</p><p>This isn’t necessarily a problem for every Flow. However, if the upstream Flow performs expensive work or maintains an active connection, continuing to collect while the UI is not visible may be undesirable.</p><h3>2. Lifecycle 2.4.0: launchWhenStarted() and repeatOnLifecycle()</h3><h4>October 2021</h4><p>Before lifecycle-aware collection became a simple Compose API, developers often managed the lifecycle manually.</p><p>One approach was launchWhenStarted():</p><pre>lifecycleOwner.lifecycleScope.launch {<br>    lifecycleOwner.lifecycle.launchWhenStarted {<br>        viewModel.uiState.collect { state -&gt;<br>            // Handle state<br>        }<br>    }<br>}</pre><p>This approach tied execution to the lifecycle, but it had a significant drawback: when the lifecycle stopped, the coroutine could be suspended rather than cancelled. Upstream work could continue unnecessarily.</p><p>The Lifecycle 2.4.0 release introduced repeatOnLifecycle(), which provides a better approach:</p><pre>lifecycleOwner.lifecycleScope.launch {<br>    lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {<br>        viewModel.uiState.collect { state -&gt;<br>            // Handle state<br>        }<br>    }<br>}</pre><p>The behavior is straightforward:</p><ul><li>When the lifecycle reaches STARTED, collection begins.</li><li>When the lifecycle falls below STARTED, the collection block is cancelled.</li><li>When the lifecycle returns to STARTED, the block runs again.</li></ul><p>This is safer than suspending the coroutine while leaving its upstream work active.</p><p>However, there is still a problem: the code is verbose for something that Compose screens do frequently. We also have to manage how collected values reach Compose State.</p><h3>3. Lifecycle 2.4.0: flowWithLifecycle()</h3><p>Another option was to apply lifecycle awareness directly to the Flow.</p><pre>val lifecycleOwner = LocalLifecycleOwner.current<br><br>val uiState by viewModel.uiState<br>    .flowWithLifecycle(lifecycleOwner.lifecycle)<br>    .collectAsState(initial = UiState())</pre><p>flowWithLifecycle() allows us to keep the collection expression relatively compact.</p><p>Instead of manually calling repeatOnLifecycle(), we transform the Flow so that its collection respects the lifecycle.</p><p>But this still requires us to combine lifecycle handling with Compose’s state conversion. We must also provide an initial value when using collectAsState() with a regular Flow.</p><p>It works, but Compose can do better.</p><h3>4. Lifecycle 2.6.0: collectAsStateWithLifecycle()</h3><h4>Introduced in Lifecycle 2.6.0-alpha01 in June 2022; stable in alpha04 in January 2023.</h4><p>The lifecycle-aware Compose API brings these responsibilities together:</p><pre>val uiState by viewModel.uiState<br>    .collectAsStateWithLifecycle()</pre><p>That’s it.</p><p>The API collects the Flow when the lifecycle is active and exposes its latest value as Compose State.</p><p>For a StateFlow, the current value provides the initial state. You can also specify a minimum active lifecycle state or an explicit initial value when needed.</p><p>For most Android Compose screens, this is the clearest default.</p><p>It removes lifecycle-management boilerplate from the UI and makes the intended behavior explicit.</p><h3>Collection Is Not the Same as Refreshing</h3><p>There is one important detail worth understanding.</p><p>When collectAsStateWithLifecycle() stops collecting, it does not necessarily stop the entire upstream Flow. <strong>The actual behavior depends on how that Flow is constructed.</strong></p><p>Consider a ViewModel:</p><pre>val uiState = repository.observeItems()<br>    .map { items -&gt;<br>        UiState(items = items)<br>    }<br>    .stateIn(<br>        scope = viewModelScope,<br>        started = SharingStarted.WhileSubscribed(5_000),<br>        initialValue = UiState()<br>    )</pre><p><a href="https://proandroiddev.com/the-power-of-statein-in-viewmodel-6a57864a8c5d">The Power of stateIn in ViewModel</a></p><p>Here, WhileSubscribed(5_000) keeps the upstream Flow active for five seconds after the last subscriber disappears. If collection resumes within that window, the upstream does not need to restart.</p><p>If no subscribers remain beyond that timeout, the upstream collection stops. When a subscriber returns, the upstream collection starts again.</p><p>Whether that restart triggers a network request depends on repository.observeItems().</p><p>For example, a cold Flow that performs an HTTP request on collection may fetch again. A Room Flow generally starts observing the database again; that does not necessarily mean it makes a network request.</p><p>The distinction matters:</p><ul><li><strong>Collection resumes: </strong>The UI starts observing the Flow again.</li><li><strong>Upstream restarts: </strong>The Flow begins its upstream work again.</li><li><strong>Data refreshes: </strong>The application explicitly or implicitly fetches fresh data.</li></ul><p>These are three different events.</p><p><a href="https://m.benigumo.com/10-flow-operators-for-building-a-declarative-uistate-eb1de6bfbc8c">10 Flow Operators for Building a Declarative UiState</a></p><h3>Which API Should You Use?</h3><p>For typical Android Compose screens, the choice is simple.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aoc8KW6FJk59sg-FRRWaNA.png" /></figure><p>You don’t need to manage the lifecycle manually when all you want is to expose a ViewModel’s state to a Compose screen.</p><p>Start with:</p><pre>val uiState by viewModel.uiState<br>    .collectAsStateWithLifecycle()</pre><p>Use a lower-level API when you have a specific reason to do so.</p><h3>Conclusion</h3><p>The evolution of Flow collection in Compose has been about reducing boilerplate without sacrificing lifecycle correctness.</p><p>collectAsState() made Flow integration simple. repeatOnLifecycle() provided safer lifecycle-aware collection. flowWithLifecycle() made lifecycle handling composable. Finally, collectAsStateWithLifecycle() brought the common pattern into a single API.</p><p>The practical lesson is simple:</p><blockquote>let Compose handle lifecycle-aware state collection, and let the ViewModel and repository determine what happens upstream.</blockquote><p>That separation keeps UI code concise while making data-fetching behavior explicit where it belongs.</p><h3>References</h3><ul><li>Lifecycle | Jetpack | Android Developers<br><a href="https://developer.android.com/jetpack/androidx/releases/lifecycle">https://developer.android.com/jetpack/androidx/releases/lifecycle</a></li><li>State and Jetpack Compose | Android Developers<br><a href="https://developer.android.com/develop/ui/compose/state">https://developer.android.com/develop/ui/compose/state</a></li><li>Use Kotlin coroutines with lifecycle-aware components | App architecture | Android Developers<br><a href="https://developer.android.com/topic/libraries/architecture/coroutines">https://developer.android.com/topic/libraries/architecture/coroutines</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 this <a href="https://m.benigumo.com/">curated reading list</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e93416a9d52d" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/the-evolution-of-flow-collection-in-jetpack-compose-e93416a9d52d">The Evolution of Flow Collection in Jetpack Compose</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 Evolution of Statics: Say Goodbye to Kotlin Companion Objects?]]></title>
            <link>https://proandroiddev.com/the-evolution-of-statics-say-goodbye-to-kotlin-companion-objects-dbe625b92896?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/dbe625b92896</guid>
            <category><![CDATA[companion]]></category>
            <category><![CDATA[static]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[androiddev]]></category>
            <category><![CDATA[new-features]]></category>
            <dc:creator><![CDATA[Nav Singh]]></dc:creator>
            <pubDate>Mon, 12 Oct 2026 23:57:23 GMT</pubDate>
            <atom:updated>2026-10-12T23:57:22.523Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tsqRHo5WlhUwC3I1a2ErGA.jpeg" /><figcaption>AI generated image</figcaption></figure><p>For years, we used <strong>companion object</strong> to group class-level members <strong>(constants, factory methods)</strong>.</p><p>However, companion objects are <strong>actual object singletons</strong> under the hood. This introduces memory overhead and causes friction when interoperating with Java (requiring <strong>@JvmStatic</strong>annotations).</p><pre>data class User(val name: String, val age: Int) {<br>    companion object {<br>        <br>        @JvmStatic<br>        fun createDefault(): User = User(&quot;Guest&quot;, 18)<br>    }<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BGdLxYZ3CJ1ONVEbzhuFLA.jpeg" /><figcaption>AI generated image</figcaption></figure><h3>Kotlin 2.5 introduces <strong>companion blocks</strong> and <strong>companion extensions</strong>:</h3><ol><li><strong>Companion Blocks:</strong> Drop the <strong>object</strong> keyword. On the JVM, they compile down directly into high-performance <strong>native static members</strong>.</li><li><strong>Companion Extensions:</strong> Let us attach new <strong>static-like methods</strong> or <strong>properties</strong> to <em>any</em> <strong>class</strong> from the <strong>outside</strong>, even classes inside external dependencies or standard Java SDKs we don’t control.</li></ol><h3><strong>Companion Blocks vs. Companion Objects</strong></h3><ul><li><strong>Under the Hood:</strong> A companion object creates a nested singleton subclass instantiation. <strong>A companion block compiles directly to static bytecode members on the parent class.</strong></li></ul><blockquote><strong>Interface Limitation:</strong> Companion objects are still required if static-scoped layer needs to implement an interface. <strong>Companion blocks <em>cannot</em> implement interfaces because they are not real objects.</strong></blockquote><ul><li><strong>Java Interop:</strong> Java code can call companion block members directly via User.createDefault(). Companion objects still require <strong>User.Companion.createDefault()</strong> unless decorated with <strong>@JvmStatic</strong>.</li></ul><h3><strong>Companion Extensions vs. Regular Extension Functions</strong></h3><ul><li><strong>Call Site:</strong> Regular extensions require an existing instance (userInstance.isValid()). Companion extensions are invoked statically on the type itself (User.systemAdmin()).</li><li><strong>Use Cases:</strong> Regular extensions add instance operations (mapping, filtering, etc.). Companion extensions serve as third-party factories, cross-module configuration initializers, or domain constants.</li></ul><h3>Implementation</h3><h4>🛠️ Setting Up the project</h4><ul><li>Companion Blocks and Extensions are arriving as <strong>experimental features</strong> in <strong>Kotlin 2.5.0-Beta1</strong></li><li>To try this out today, we need to configure <strong>two</strong> things: the <strong>JetBrains Bootstrap Repository</strong> and the <strong>Experimental Compiler Flags</strong></li></ul><h3><strong>Enable the Preview Plugin Repository</strong></h3><pre>// settings.gradle.kts<br>pluginManagement {<br>    repositories {<br>        // Required for resolving preview &amp; bootstrap Kotlin compiler artifacts<br>        maven(&quot;https://jetbrains.space&quot;)<br>        // ...<br>    }<br>}</pre><h3><strong>Pass the Experimental Compiler Flags</strong></h3><pre>// build.gradle.kts<br>plugins {<br>    // Target the 2.5 preview toolchain<br>    kotlin(&quot;jvm&quot;) version &quot;2.5.0-Beta1&quot;<br>}<br><br>kotlin {<br>    jvmToolchain(21) <br><br>    compilerOptions {<br>        // 1. Opt-in to allow basic companion blocks inside classes<br>        freeCompilerArgs.add(&quot;-Xcompanion-blocks&quot;)<br>        <br>        // 2. Opt-in to allow writing static companion extensions from the outside<br>        freeCompilerArgs.add(&quot;-Xcompanion-blocks-and-extensions&quot;)<br>    }<br>}</pre><h4><strong>The Old Way (Companion Object)</strong></h4><pre>data class UserWithCompanionObj(val name: String, val age: Int) {<br><br>    companion object {<br>        val ANONYMOUS = UserWithCompanionObj(&quot;Anonymous&quot;, 12)<br>        fun getDefaultUser() = UserWithCompanionObj(&quot;Default&quot;, 19)<br>    }<br>}</pre><h4>UserWithCompanionObj.decompiled.java</h4><pre><br>public final class UserWithCompanionObj {<br>   <br>   @NotNull<br>   private static final UserWithCompanionObj ANONYMOUS = new UserWithCompanionObj(&quot;Anonymous&quot;, 12);<br>   <br>   public static final class Companion {<br>      private Companion() {<br>      }<br><br>      @NotNull<br>      public final UserWithCompanionObj getANONYMOUS() {<br>         return UserWithCompanionObj.ANONYMOUS;<br>      }<br><br>      @NotNull<br>      public final UserWithCompanionObj getDefaultUser() {<br>         return new UserWithCompanionObj(&quot;Default&quot;, 19);<br>      }<br><br>    <br>   }<br>}</pre><h4>The New Way (Companion Blocks &amp; Extensions)</h4><pre>data class User(val name: String, val age: Int) {<br>    // Compiles down directly to native Java-like statics! No inner object allocation.<br>    companion {<br>        val ANONYMOUS = User(&quot;Anonymous&quot;, 0)<br>        <br>        fun createDefault(): User = User(&quot;Guest&quot;, 18)<br>    }<br>}<br><br>// Statically extending the User class from the outside (e.g., in a separate module)<br>companion val User.SYSTEM_ADMIN: User <br>    get() = User(&quot;Admin&quot;, 99)<br><br>companion fun User.fromJson(json: String): User {<br>    val user = Json.decodeFromString&lt;User&gt;(json)<br>    return User(&quot;Parsed User&quot;, 25)<br>}<br><br>fun main() {<br>    // 1. Using Companion Block Members<br>    val anonymous = User.ANONYMOUS<br>    val guest = User.createDefault()<br>    <br>    // 2. Using Companion Extension Members (Looks completely native!)<br>    val admin = User.SYSTEM_ADMIN<br>    val parsed = User.fromJson(&quot;&quot;&quot;{&quot;name&quot;: &quot;Nav&quot;, &quot;age&quot;: 27}&quot;&quot;&quot;)<br>}</pre><h4>User-decompiled.java</h4><pre>@Serializable<br>public final class User {<br><br>   @NotNull<br>   public static final User getANONYMOUS() {<br>      return ANONYMOUS;<br>   }<br><br>   @NotNull<br>   public static final User createDefault() {<br>      return new User(&quot;Guest&quot;, 18);<br>   }<br><br>}<br><br><br>// UserKt.java<br>public final class UserKt {<br><br>   @NotNull<br>   public static final User getSYSTEM_ADMIN() {<br>      return new User(&quot;Admin&quot;, 99);<br>   }<br><br>   @NotNull<br>   public static final User fromJson(@NotNull String json) {<br>      Intrinsics.checkNotNullParameter(json, &quot;json&quot;);<br>      Json this_$iv = (Json)Json.Default;<br>      int $i$f$decodeFromString = 0;<br>      SerializersModule var5 = this_$iv.getSerializersModule();<br>      KType var10001 = Reflection.typeOf(User.class);<br>      MagicApiIntrinsics.voidMagicApiCall(&quot;kotlinx.serialization.serializer.withModule&quot;);<br>      User user = (User)this_$iv.decodeFromString((DeserializationStrategy)SerializersKt.serializer(var5, var10001), json);<br>      System.out.println(&quot;Decoded user: &quot; + user);<br>      return user;<br>   }<br>   <br>}</pre><h3>References</h3><p><a href="https://blog.jetbrains.com/kotlin/2026/09/the-companions-to-come/">https://blog.jetbrains.com/kotlin/2026/09/the-companions-to-come/</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=dbe625b92896" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/the-evolution-of-statics-say-goodbye-to-kotlin-companion-objects-dbe625b92896">The Evolution of Statics: Say Goodbye to Kotlin Companion Objects?</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[Taming Bazel Dependency Conflicts: An Android Case Study]]></title>
            <link>https://proandroiddev.com/taming-bazel-dependency-conflicts-an-android-case-study-44b7926839be?source=rss----c72404660798---4</link>
            <guid isPermaLink="false">https://medium.com/p/44b7926839be</guid>
            <category><![CDATA[gradle]]></category>
            <category><![CDATA[build-system]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[dependency-management]]></category>
            <category><![CDATA[bazel]]></category>
            <dc:creator><![CDATA[Taufik Amaryansyah]]></dc:creator>
            <pubDate>Mon, 12 Oct 2026 23:38:04 GMT</pubDate>
            <atom:updated>2026-10-12T23:38:03.013Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vQJUolgtfxv52dg2COK82w.png" /><figcaption>Illustration Generated by Gemini</figcaption></figure><p>For many developers, “dependency conflict” is a phrase that instantly causes a headache. This is especially true when moving from a familiar ecosystem like Gradle to a powerful but complex tool like Bazel.</p><p>A while back, I wrote about my experience building an Android app with Bazel (You can read about it here: <a href="https://medium.com/@taufik.amary/building-a-modern-android-music-player-app-with-bazel-jetpack-compose-and-exoplayer-4a70039655f2">Building a Modern Android Music Player App with Bazel…</a>).</p><p>Recently, I decided to update some libraries in that project, most notably by moving to the Compose BOM (androidx.compose:compose-bom:2024.12.01).</p><h3>🧨 The Problem Begins: A Simple Library Update</h3><p>I updated my MODULE.bazel file with the new dependencies. The initial attempt looked something like this:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/ab6ac611fb4ff73c1120f2dba1a8df3e/href">https://medium.com/media/ab6ac611fb4ff73c1120f2dba1a8df3e/href</a></iframe><p>I ran the build, and… it failed. I was greeted by a long, cryptic error log, which boiled down to a classic dependency conflict.</p><p>Here’s just a small snippet of the dependency graph hell:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8Wna5GjBr3RBP9sCXswiAA.png" /></figure><p>At its core, the problem was familiar. For example, looking at the log:</p><ul><li>androidx.activity:activity-compose:1.7.0 (which I was using) explicitly wants androidx.compose.ui:ui:1.0.1.</li><li>But the new Compose BOM (2024.12.01) was pulling in androidx.compose.ui:ui:1.7.6 (a much newer version).</li></ul><p>This threw me for a loop. In my time with Gradle, I rarely faced conflicts this severe. Gradle usually seems “smart” enough to just pick the newest version and move on.</p><h3>🧠 Understanding the “Why”: Gradle vs. Bazel</h3><p>After some digging, I realized the fundamental difference lies in how these build tools manage versions.</p><h4>1. Gradle: “Newest Version Wins” (By Default)</h4><p>Gradle, by default, uses a “Newest Version Selection” strategy. If it encounters a conflict, it simply picks the <strong>newest</strong> version of the dependency available in the graph.</p><ul><li>A → C v1.0</li><li>B → C v2.0</li><li><strong>Gradle’s Result:</strong> C v2.0 (Chosen automatically)</li></ul><h4>2. Bazel (Bzlmod): “Minimal Version Selection” (MVS)</h4><p>Bazel, with the introduction of Bzlmod, adopts the “Minimal Version Selection” (MVS) algorithm, which is also used by Go modules (you can read the official documentation on Bzlmod <a href="https://bazel.build/external/module">here</a>).</p><p><strong>MVS Principle:</strong> Bazel selects the <strong>minimum</strong> version that satisfies <strong>all</strong> constraints in the dependency graph. This algorithm is highly deterministic and reproducible.</p><p>Let’s look at the classic example from the Bazel documentation:</p><pre>       A 1.0<br>      /     \<br>   B 1.0    C 1.1<br>     |        |<br>   D 1.0    D 1.1</pre><p>In this case, Bazel will select D 1.1. Why? Because it&#39;s the <em>highest</em> version <em>explicitly required</em> by a module (C 1.1 needs it). It&#39;s &quot;minimal&quot; because D 1.1 is the earliest version that satisfies all requirements. Even if D 1.2 existed, MVS would <em>not</em> pick it unless another module explicitly asked for it.</p><p><strong>Quick Comparison</strong></p><pre>| Aspect                     | Gradle (Default)         | Bazel (MVS)                                |<br>|----------------------------|--------------------------|--------------------------------------------|<br>| Algorithm                  | Newest Version Selection | Minimal Version Selection                  |<br>| Version Choice             | Newest version found     | Minimum version that meets all constraints |<br>| Example (D 1.0 vs 1.1).    | D 1.1 selected           | D 1.1 selected                             |<br>| If D 1.2 is available      | D 1.2 selected           | D 1.1 is still selected                    |<br>| Reproducibility            | Medium                   | Very High                                  |</pre><p>My conflict was happening because MVS couldn’t find a <em>single</em> version that satisfied all the different version ranges being requested.</p><h3>🧩 The Solution: Finding and Fixing the Conflicts</h3><p>So, how do we fix this?</p><p>A lot of advice online (even from our new AI overlords like ChatGPT, Claude, Gemini, etc.) will tell you to just add version_conflict_policy = &quot;pinned&quot; to your maven.install.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/7fdc2761fcc350867ff26bbbcfdd9b50/href">https://medium.com/media/7fdc2761fcc350867ff26bbbcfdd9b50/href</a></iframe><p>However, <strong>this is not enough.</strong> This policy only tells Bazel to “pin” the versions it <em>successfully</em> resolves; it doesn’t actually <em>solve</em> the conflict itself. You will still get the same error.</p><p>After digging into the rules_jvm_external repository, I found the real solution: using maven.artifact to manually &quot;force&quot; or &quot;override&quot; the version of a specific library.</p><p>But… how do we know <em>which</em> versions to pick when the error log is so noisy?</p><h4>Step 1: Debug and Pin Dependencies</h4><p>The key is to run this command (from rules_jvm_external) to perform the pinning process and dump the <em>full</em> log:</p><pre>bazel clean --expunge<br>bazel run @maven//:pin &amp;&gt; maven_pin.txt</pre><p>This command runs the dependency resolution process and saves <em>all</em> output (including the detailed errors) to a file named maven_pin.txt.</p><h4>Step 2: Analyze the Log</h4><p>Now, open maven_pin.txt. Scroll up from the bottom (or search for &quot;Conflicting dependencies&quot;) until you find the root of the problem. In my case, the log looked like this:</p><pre>...<br>Resolution error: Conflicting dependencies:<br>androidx.collection:collection:1.0.0 or 1.1.0 or 1.2.0 or 1.4.0 or 1.4.2 or [1.2.0] wanted by<br><br> androidx.activity:activity:1.7.0 wants 1.0.0<br> ├─ androidx.activity:activity-ktx:1.7.0 wants androidx.activity:activity:[1.7.0]<br>...</pre><p>The culprit was clear: androidx.collection:collection. Many different dependencies were requesting many different versions.</p><h4>Step 3: Define a Version and Apply the Override</h4><p>Now that we know the conflicting library, we can pick one version to rule them all.</p><ol><li><strong>The Strategy:</strong> Try picking the <strong>newest</strong> version listed in the conflict log (in this case, 1.4.2). The assumption is that this version is backward-compatible and will satisfy all modules.</li><li>If the newest version still causes errors, try the next-newest version and repeat.</li></ol><p>Add this override to your MODULE.bazel file, <strong>before</strong> the maven.install block:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/f8acb092725a9b03da50a68e93bc2bbe/href">https://medium.com/media/f8acb092725a9b03da50a68e93bc2bbe/href</a></iframe><h4>Step 4: Iterate and Repeat</h4><p>Run the bazel run @maven//:pin &amp;&gt; maven_pin.txt command again. Check the maven_pin.txt file. If another conflict appears for a <em>different</em> library, repeat Steps 2 and 3 for that library.</p><p>For my project, I ended up needing to override several libraries:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/228e70c19e2f006f69e25dea8dce9f19/href">https://medium.com/media/228e70c19e2f006f69e25dea8dce9f19/href</a></iframe><p>After adding all the necessary overrides, the bazel run @maven//:pin command should finally run successfully with no errors.</p><h3>✅ The Final Result</h3><p>Now, I could run my project’s build commands as usual:</p><pre>bazel build //app/src/main:app<br>adb install -r bazel-bin/app/src/main/app.apk<br>adb shell am start -n com.amary.my.music/.MainActivity</pre><p>And… voilà!<br> The app built and ran perfectly with all updated dependencies 🎉</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/996/1*I3AFxIqINAVY4Va55IMMUQ.png" /></figure><p>📦 Source code available here:<br> 👉 <a href="https://github.com/amary21/my-music-bazel">github.com/amary21/my-music-bazel</a></p><h3>💡Final Thoughts</h3><p>Bazel’s MVS dependency resolution can feel “noisy” and rigid at first, especially when compared to Gradle’s “it-just-works” approach. However, its deterministic and reproducible nature is invaluable for build consistency in the long run. The solution is to be explicit and provide manual overrides when MVS can’t find a path.</p><p>One thing I’m still figuring out is the best way to manage updates to the Bazel <em>rules</em> themselves (like rules_android, rules_kotlin). Updating these often introduces significant configuration changes, sometimes even requiring a Bazel version bump. That&#39;s why I&#39;m still using the 2024 version of the Compose BOM in this project; the 2025 version requires at least Kotlin 2, and its compiler has moved to JetBrains, which would necessitate a tricky update to rules_kotlin.</p><p>If you have any experience or tips on managing Bazel rule updates, I’d love to hear them in the comments below!</p><h3><strong>📫 Let’s Connect</strong></h3><ul><li>📧 Email: <a href="http://taufik.amary@gmail.com/">taufik.amary@gmail.com</a></li><li>💻 GitHub: <a href="https://github.com/amary21">github.com/amary21</a></li><li>💼 LinkedIn: <a href="http://www.linkedin.com/in/t-amar">www.linkedin.com/in/t-amar</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=44b7926839be" width="1" height="1" alt=""><hr><p><a href="https://proandroiddev.com/taming-bazel-dependency-conflicts-an-android-case-study-44b7926839be">Taming Bazel Dependency Conflicts: An Android Case Study</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[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-14T18:45:06.002Z</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><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Bbo8f9d6yXspLncWbDjF-Q.png" /></figure><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>
    </channel>
</rss>