<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Prem Singh</title>
    <description>The latest articles on DEV Community by Prem Singh (@prem_singh_1997).</description>
    <link>https://dev.to/prem_singh_1997</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4191368%2F5f3a58fe-0bb6-4a1f-9812-183d3a1fcd36.jpeg</url>
      <title>DEV Community: Prem Singh</title>
      <link>https://dev.to/prem_singh_1997</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/prem_singh_1997"/>
    <language>en</language>
    <item>
      <title>From Pixels to Text: Google ML Kit OCR on Android, with a KMP Core</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Fri, 30 Oct 2026 07:09:14 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/from-pixels-to-text-google-ml-kit-ocr-on-android-with-a-kmp-core-25kh</link>
      <guid>https://dev.to/prem_singh_1997/from-pixels-to-text-google-ml-kit-ocr-on-android-with-a-kmp-core-25kh</guid>
      <description>&lt;p&gt;A receipt scanner looks simple in a demo: point the camera, recognize text, fill a form.&lt;/p&gt;

&lt;p&gt;The engineering work starts when the receipt is rotated, the model is not ready, or an invoice number looks convincing but is wrong. A useful OCR feature handles those cases before it asks the user to trust the result.&lt;/p&gt;

&lt;p&gt;Here is a practical architecture for Android capture and recognition, with a Kotlin Multiplatform core for the decisions that follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the right job for each component
&lt;/h2&gt;

&lt;p&gt;Google ML Kit Text Recognition v2 reads supported text from images. It is not an invoice parser or a guarantee that a recognized amount is correct.&lt;/p&gt;

&lt;p&gt;Keep the workflow explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Capture or import
      ↓
Prepare image + orientation
      ↓
Native OCR
      ↓
Shared normalization + candidate extraction
      ↓
Editable review screen
      ↓
User-confirmed save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For document capture on Android, ML Kit also has a separate Document Scanner API. Capture/cropping and text recognition are different steps; obtaining a scanned image or PDF does not mean your application already has structured OCR fields. Check that API's device and model requirements before selecting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide how the recognition model arrives
&lt;/h2&gt;

&lt;p&gt;Android Text Recognition offers bundled and Google Play services-based options. Bundling increases app size but makes the model available with the app. An unbundled model must be downloaded before use; design a real preparation state rather than showing an unexplained empty result.&lt;/p&gt;

&lt;p&gt;For Latin-script recognition, the bundled artifact is &lt;code&gt;com.google.mlkit:text-recognition&lt;/code&gt;. The unbundled artifact is &lt;code&gt;com.google.android.gms:play-services-mlkit-text-recognition&lt;/code&gt;. Choose one route and pin a compatible version from the official setup guide/release notes. Do not assume all target devices have Google Play services.&lt;/p&gt;

&lt;p&gt;Text Recognition v2 supports Latin, Chinese, Devanagari, Japanese, and Korean scripts through the relevant SDKs. A Latin recognizer is not an Arabic recognizer. For Arabic or Urdu documents, evaluate an engine that explicitly supports the required script and test representative documents; do not silently promise coverage from a generic “OCR” label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the Android recognition adapter small
&lt;/h2&gt;

&lt;p&gt;This integration sketch receives a prepared &lt;code&gt;InputImage&lt;/code&gt;, reuses one Latin recognizer, and returns plain application data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.google.mlkit.vision.common.InputImage&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.google.mlkit.vision.text.TextRecognition&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.google.mlkit.vision.text.latin.TextRecognizerOptions&lt;/span&gt;

&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;OcrDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fullText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AndroidTextReader&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;recognizer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TextRecognition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;TextRecognizerOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_OPTIONS&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;recognize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InputImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OcrDocument&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;onFailure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;recognizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addOnSuccessListener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nf"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;OcrDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;fullText&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;textBlocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                            &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                        &lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addOnFailureListener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;recognizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an adapter sketch, not a complete scanner app. Its owner must manage request lifetime, errors, and teardown. Close the recognizer when finished with it, after in-flight work has settled; keep late callbacks from updating a screen that no longer owns the request.&lt;/p&gt;

&lt;p&gt;For an imported image, &lt;code&gt;InputImage.fromFilePath(context, uri)&lt;/code&gt; is an option; handle preparation failures and URI access lifetime. For CameraX frames, use the frame's actual rotation metadata when constructing the input image.&lt;/p&gt;

&lt;p&gt;Flattened lines are enough for a simple candidate extractor. If layout matters, retain block/line bounds too and define their coordinate system. A visual document can have columns; do not assume flattening reconstructs the intended reading order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live camera OCR needs backpressure
&lt;/h2&gt;

&lt;p&gt;Recognition is asynchronous. Starting work for every frame can create a queue of stale results.&lt;/p&gt;

&lt;p&gt;With CameraX &lt;code&gt;ImageAnalysis&lt;/code&gt;, use an appropriate backpressure strategy such as &lt;code&gt;STRATEGY_KEEP_ONLY_LATEST&lt;/code&gt;. Keep the underlying image valid until recognition completes, then close the &lt;code&gt;ImageProxy&lt;/code&gt; on success &lt;strong&gt;and failure&lt;/strong&gt;. Close immediately if there is no usable image. Do not close the wrapped &lt;code&gt;Media.Image&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;If your app replaces an active scan with a new one, track request identity so an old callback cannot overwrite the new result. Dropping queued frames and ignoring obsolete UI results solve different problems.&lt;/p&gt;

&lt;p&gt;For a first portfolio implementation, a captured still image is often easier to reason about than continuous OCR. Add live overlays only after the basic capture, review, and lifecycle paths work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share interpretation through KMP, not native image objects
&lt;/h2&gt;

&lt;p&gt;Put a plain &lt;code&gt;OcrDocument&lt;/code&gt; model and pure parsing functions in &lt;code&gt;commonMain&lt;/code&gt;. The Android adapter maps ML Kit results into that model. An iOS adapter can use the native ML Kit iOS SDK or another suitable native recognizer and return the same application contract.&lt;/p&gt;

&lt;p&gt;This does not mean Google's Android SDK runs in common Kotlin. Keep &lt;code&gt;InputImage&lt;/code&gt;, Android &lt;code&gt;Bitmap&lt;/code&gt;, and iOS image types at their platform boundaries. Agree on supported scripts, line ordering, coordinate conventions, and error meanings rather than assuming both engines behave identically.&lt;/p&gt;

&lt;p&gt;For example, a deliberately narrow shared extractor could be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;invoiceIdCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Invoice ID:"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asSequence&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignoreCase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;firstOrNull&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only handles one label format. It returns a &lt;strong&gt;candidate&lt;/strong&gt;, not a verified identifier. It will miss split labels, other languages, and some recognition errors. Keep fixtures for those cases and expand the rules intentionally instead of presenting a regex as document understanding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the review screen part of the feature
&lt;/h2&gt;

&lt;p&gt;Show the source image beside editable extracted fields. Distinguish “no text found” from processing failure and model preparation. Let the user recapture a blurred image without losing their other work.&lt;/p&gt;

&lt;p&gt;Test rotations, small print, glare, multiple columns, mixed scripts, and ambiguous characters such as &lt;code&gt;O/0&lt;/code&gt; and &lt;code&gt;I/1&lt;/code&gt;. Verify numeric formatting and currency conventions separately; do not automatically accept a financial value because OCR produced digits.&lt;/p&gt;

&lt;p&gt;When recognition is poor, check focus, crop, lighting, and text size in the input before adding elaborate post-processing. A sharper capture often beats another parsing rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  On-device OCR is not the whole privacy story
&lt;/h2&gt;

&lt;p&gt;Google states that ML Kit processes input and recognition output on device rather than sending them to Google servers. Its SDK may still contact servers for models/updates and send performance/utilization metrics.&lt;/p&gt;

&lt;p&gt;Your own app can still leak a receipt through logs, analytics, image backups, or an AI summarization request. Retain only what the feature needs, protect stored documents, and make any upload a separate deliberate action. On-device recognition does not authorize forwarding the extracted text elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  A portfolio demo worth showing
&lt;/h2&gt;

&lt;p&gt;Record three short paths: a clean receipt, an unreadable capture that recovers gracefully, and a corrected field saved only after review. Add shared parser fixtures and platform integration checks. Explain model availability and supported scripts in the README.&lt;/p&gt;

&lt;p&gt;That demonstrates a useful mobile product flow, not just a call to a recognition API.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/ml-kit/vision/text-recognition/v2/android" rel="noopener noreferrer"&gt;Google: Text Recognition v2 on Android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/ml-kit/vision/text-recognition/v2" rel="noopener noreferrer"&gt;Google: Text Recognition v2 and result structure&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/ml-kit/vision/text-recognition/v2/languages" rel="noopener noreferrer"&gt;Google: Supported OCR languages and scripts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/ml-kit/tips/installation-paths" rel="noopener noreferrer"&gt;Google: ML Kit model installation paths&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/ml-kit/vision/text-recognition/v2/ios" rel="noopener noreferrer"&gt;Google: Text Recognition v2 on iOS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/media/camera/camerax/analyze" rel="noopener noreferrer"&gt;Android Developers: CameraX image analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/ml-kit/vision/doc-scanner" rel="noopener noreferrer"&gt;Google: ML Kit Document Scanner&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/ml-kit/terms" rel="noopener noreferrer"&gt;Google: ML Kit terms and privacy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>android</category>
    </item>
    <item>
      <title>Android Keystore and iOS Keychain: Protecting Mobile Data in the AI Era</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Thu, 29 Oct 2026 16:08:03 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/android-keystore-and-ios-keychain-protecting-mobile-data-in-the-ai-era-3di1</link>
      <guid>https://dev.to/prem_singh_1997/android-keystore-and-ios-keychain-protecting-mobile-data-in-the-ai-era-3di1</guid>
      <description>&lt;p&gt;A token can be encrypted on disk and still leak through a debug log, a support screenshot, or an AI prompt.&lt;/p&gt;

&lt;p&gt;Mobile security needs two boundaries: how data is protected while stored, and where plaintext is allowed to travel after the app reads it. Android Keystore and iOS Keychain help with the first. The second is an application design decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, separate keys, secrets, and bulk data
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you have&lt;/th&gt;
&lt;th&gt;A useful storage direction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android encryption key&lt;/td&gt;
&lt;td&gt;Generate and use it through Android Keystore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS token or small secret&lt;/td&gt;
&lt;td&gt;Keychain item with explicit accessibility policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android token&lt;/td&gt;
&lt;td&gt;Authenticated ciphertext in private storage, protected by an appropriate Keystore-managed key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documents, attachments, large caches&lt;/td&gt;
&lt;td&gt;Encrypted files or a suitable encrypted database, with separately protected key material&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared AI-provider credential&lt;/td&gt;
&lt;td&gt;Backend secret manager; do not ship it in the mobile app&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keystore is primarily a key-management facility. Keychain is also a store for small secrets. Neither should be treated as a place to dump an entire application database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Android: protect the key, then encrypt the value
&lt;/h2&gt;

&lt;p&gt;Android Keystore can keep key material non-exportable and constrain how a key is used. Hardware-backed protection depends on the device, algorithm, and configuration; do not assume every generated key lives in StrongBox.&lt;/p&gt;

&lt;p&gt;This Kotlin snippet creates a &lt;strong&gt;new&lt;/strong&gt; AES key for a new alias on API 23+:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;android.security.keystore.KeyGenParameterSpec&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;android.security.keystore.KeyProperties&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;javax.crypto.KeyGenerator&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;javax.crypto.SecretKey&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;createNewStorageKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;SecretKey&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;generator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KeyGenerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;KEY_ALGORITHM_AES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"AndroidKeyStore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;KeyGenParameterSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PURPOSE_ENCRYPT&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt;
                &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PURPOSE_DECRYPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBlockModes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BLOCK_MODE_GCM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEncryptionPaddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ENCRYPTION_PADDING_NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setKeySize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is key creation, not a complete storage wrapper. Do not call it on every read or silently replace an existing alias: old ciphertext still depends on its original key. Serialize first-use initialization and load the existing key for subsequent operations.&lt;/p&gt;

&lt;p&gt;Use a platform-supported authenticated encryption construction, such as &lt;code&gt;AES/GCM/NoPadding&lt;/code&gt;. On encryption, let the provider generate a fresh IV and persist the IV with the ciphertext and authentication tag. Never reuse a GCM nonce with the same key. Authenticate on decryption and reject modified data.&lt;/p&gt;

&lt;p&gt;An ordinary Keystore key is not automatically gated by a new biometric prompt. Configure authentication requirements deliberately when the use case needs them, and design for lock state, cancellation, and key invalidation. A compromised process may still invoke allowed key operations even when it cannot extract the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  iOS: store a small secret with an explicit access policy
&lt;/h2&gt;

&lt;p&gt;For a foreground-only credential that should not migrate to another device, a Keychain add operation can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Foundation&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Security&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;addNewSecret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;kSecClass&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kSecClassGenericPassword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;kSecAttrService&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;kSecAttrAccount&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;kSecAttrAccessible&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;kSecAttrAccessibleWhenUnlockedThisDeviceOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;kSecValueData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SecItemAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFDictionary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;errSecSuccess&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;NSError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSOSStatusErrorDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This intentionally adds only a new item. Handle &lt;code&gt;errSecDuplicateItem&lt;/code&gt; using a narrowly matched &lt;code&gt;SecItemUpdate&lt;/code&gt; when updating is intended; do not delete and recreate a credential on every save. Define service/account namespaces and access groups deliberately.&lt;/p&gt;

&lt;p&gt;Choose accessibility around actual behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;WhenUnlockedThisDeviceOnly&lt;/code&gt;: available while unlocked; does not migrate to a different device through backup restoration.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AfterFirstUnlockThisDeviceOnly&lt;/code&gt;: can support access while locked after the first unlock since restart, with a broader availability window.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WhenPasscodeSetThisDeviceOnly&lt;/code&gt;: requires a device passcode; removing it deletes items in this class.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;ThisDeviceOnly&lt;/code&gt; is a migration restriction, not a claim that the item can never appear in a same-device backup. Explicitly define synchronization and recovery requirements.&lt;/p&gt;

&lt;p&gt;If user presence is required at retrieval, use Keychain access controls tied to the operation. A separate Face ID screen does not automatically protect every later read. Also, Keychain is not synonymous with Secure Enclave: placing an arbitrary AES key in Keychain does not make it a non-exportable Secure Enclave AES key.&lt;/p&gt;

&lt;p&gt;Treat &lt;code&gt;errSecItemNotFound&lt;/code&gt;, temporary interaction restrictions, and authentication cancellation differently. Deleting a credential because a locked-device read failed can turn a recoverable condition into data loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the app has much more data?
&lt;/h2&gt;

&lt;p&gt;For larger datasets, protect the data where it belongs—files or a database—and protect the keys separately.&lt;/p&gt;

&lt;p&gt;A possible envelope-encryption design is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Platform-protected key material
          |
          v
protects a random data-encryption key
          |
          v
authenticated encrypted files / database content
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android, a non-exportable Keystore key can wrap a random data key. On iOS, a symmetric data key can be stored as a protected Keychain item. These mechanisms have different properties; an iOS symmetric key retrieved into the process is still present in application memory.&lt;/p&gt;

&lt;p&gt;Record a versioned format, algorithm, key identifier, nonce, ciphertext, and tag as appropriate. Associated data can bind ciphertext to an expected account/record context, but is authenticated rather than encrypted. Use trusted context during decryption; merely trusting metadata supplied alongside an attacker's ciphertext does not enforce account isolation.&lt;/p&gt;

&lt;p&gt;For large files, use a vetted streaming authenticated-encryption implementation rather than inventing chunking, nonce allocation, or tag rules. Google's Tink Streaming AEAD is one option for supported environments; its Objective-C implementation does not offer the same streaming primitive. Verify platform support and the file format before promising Android/iOS interoperability.&lt;/p&gt;

&lt;p&gt;For a database, evaluate a maintained encryption implementation, including its journals, write-ahead logs, temporary files, and key-handling behavior. Room and DataStore do not automatically provide application-level encryption simply because the app uses them. Platform file protection is useful, but does not replace your chosen key and backup policy.&lt;/p&gt;

&lt;p&gt;Encryption at rest does not authorize a server request, hide plaintext from your own running code, or stop every attack on a compromised device.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI-era failure happens after decryption
&lt;/h2&gt;

&lt;p&gt;Imagine an assistant that summarizes a customer's document. The app decrypts it correctly, then uploads the entire text and a diagnostic token to a model endpoint. Local encryption worked; the data-disclosure boundary failed.&lt;/p&gt;

&lt;p&gt;Make that boundary explicit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep shared provider credentials on your backend. A key embedded in an APK or IPA is distributable to the person running the app, even if obfuscated or encrypted with another bundled secret.&lt;/li&gt;
&lt;li&gt;Authenticate users, enforce authorization, limit spending/rate, and constrain operations server-side. For direct provider access, use purpose-built, short-lived, narrowly scoped credentials where supported.&lt;/li&gt;
&lt;li&gt;Send only the content required for the requested feature. Prefer synthetic/redacted examples when debugging with AI tools; exclude tokens, private keys, customer records, and production dumps.&lt;/li&gt;
&lt;li&gt;Check provider retention, training use, access, and deletion controls for the actual account and service. Do not assume every endpoint has the same policy.&lt;/li&gt;
&lt;li&gt;Treat retrieved text and model output as untrusted. Enforce permissions in application/tool code, not in a prompt saying “never reveal secrets.”&lt;/li&gt;
&lt;li&gt;Review prompt traces, crash reports, analytics, clipboard actions, and support exports. They can bypass otherwise careful storage design.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For local models, the network disclosure risk may change, but prompt history, caches, embeddings, logs, and tool permissions still need protection. “On device” is a location, not a complete security policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan the moments when keys disappear
&lt;/h2&gt;

&lt;p&gt;Decide what happens after uninstall, restore to a new device, account removal, or key invalidation. Device-bound keys and restored ciphertext may not remain usable together. Test backup exclusions and recovery instead of discovering the mismatch after release.&lt;/p&gt;

&lt;p&gt;On logout, revoke server sessions where appropriate and remove account-scoped secrets and cached plaintext. Key deletion can render ciphertext unusable, but is not proof that every backup, exported copy, or flash-storage remnant vanished. Recovery, retention, and deletion are separate requirements.&lt;/p&gt;

&lt;p&gt;Before shipping, verify tampered ciphertext is rejected, nonce handling survives repeated writes, locked-device reads fail safely, accounts cannot read each other's records, and diagnostic/AI flows never receive unintended plaintext.&lt;/p&gt;

&lt;p&gt;Protect the key, minimize the plaintext lifetime, and make every boundary after decryption intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/privacy-and-security/keystore" rel="noopener noreferrer"&gt;Android Developers: Android Keystore system&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/privacy-and-security/cryptography" rel="noopener noreferrer"&gt;Android Developers: Cryptography&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/security/keychain-services" rel="noopener noreferrer"&gt;Apple: Keychain services&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/security/restricting-keychain-item-accessibility" rel="noopener noreferrer"&gt;Apple: Restricting Keychain item accessibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/724013" rel="noopener noreferrer"&gt;Apple: SecItem pitfalls and best practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/security/protecting-keys-with-the-secure-enclave" rel="noopener noreferrer"&gt;Apple: Protecting keys with the Secure Enclave&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/cryptokit/aes/gcm/nonce" rel="noopener noreferrer"&gt;Apple: AES.GCM nonce requirements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/tink/encrypt-large-files-or-data-streams" rel="noopener noreferrer"&gt;Google Tink: Encrypt large files or data streams&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/tink/supported-key-types" rel="noopener noreferrer"&gt;Google Tink: Supported key types by language&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://genai.owasp.org/llmrisk/llm022025-sensitive-information-disclosure/" rel="noopener noreferrer"&gt;OWASP: Sensitive information disclosure&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://genai.owasp.org/llmrisk/llm072025-system-prompt-leakage/" rel="noopener noreferrer"&gt;OWASP: System prompt leakage&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Offline-First Mobile: A Refresh Error Should Not Erase Good Data</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:53:40 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/offline-first-mobile-a-refresh-error-should-not-erase-good-data-44ed</link>
      <guid>https://dev.to/prem_singh_1997/offline-first-mobile-a-refresh-error-should-not-erase-good-data-44ed</guid>
      <description>&lt;p&gt;Open a mobile app on a weak connection. A list appears from yesterday's cache, refresh starts, the request fails—and the entire screen becomes an error page.&lt;/p&gt;

&lt;p&gt;The app had useful data. Its state model simply had nowhere to put both that data and a refresh failure.&lt;/p&gt;

&lt;p&gt;This is a practical design problem for Android and Kotlin Multiplatform: content availability and refresh status are different facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model two dimensions
&lt;/h2&gt;

&lt;p&gt;An exclusive &lt;code&gt;Loading / Success / Error&lt;/code&gt; model is convenient for an initial request. It becomes restrictive when a screen can show cached content while refreshing.&lt;/p&gt;

&lt;p&gt;Consider this shared presentation model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;RefreshStatus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Idle&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RefreshStatus&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Running&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RefreshStatus&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Failed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RefreshStatus&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;ArticleListState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ArticleSummary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;hasLocalSnapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RefreshStatus&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RefreshStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Idle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hasLocalSnapshot&lt;/code&gt; distinguishes “we have never loaded data” from “we loaded an empty list.” Without that distinction, a legitimate empty response can look like unfinished loading forever.&lt;/p&gt;

&lt;p&gt;The refresh state answers whether synchronization is happening. It does not replace the content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give reads one route
&lt;/h2&gt;

&lt;p&gt;For a read-only feed, a useful repository contract is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.Flow&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ArticleRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;observeLocal&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ArticleSummary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;null&lt;/code&gt; means no stored snapshot yet, while an empty list is a stored empty result. This is one possible contract, not a universal database convention.&lt;/p&gt;

&lt;p&gt;The UI observes the local stream. A refresh fetches remote data and commits the accepted result to local storage. The local observation then supplies the updated content. Avoid making the screen race a network result against a separate database result.&lt;/p&gt;

&lt;p&gt;Persistence, networking, and scheduling still need implementations appropriate to the supported platforms. A shared repository interface does not make Android background execution APIs portable to iOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep failure from becoming deletion
&lt;/h2&gt;

&lt;p&gt;This conceptual refresh sequence makes the ordering visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mark refresh as running
fetch the remote snapshot
validate and map the response
commit the accepted snapshot atomically
mark refresh as idle

on expected refresh failure:
    retain the local snapshot
    mark refresh as failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not clear storage at the start of refresh. A failed request should not destroy data merely to make room for a result that never arrives.&lt;/p&gt;

&lt;p&gt;However, retaining everything forever is not a synchronization strategy. A successful authoritative empty snapshot may need to remove old rows. A paginated response normally cannot replace the entire dataset. Define whether the response is a complete snapshot, a page, or a delta before choosing a database operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn combinations into UI decisions
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Local state&lt;/th&gt;
&lt;th&gt;Refresh state&lt;/th&gt;
&lt;th&gt;Useful presentation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No snapshot&lt;/td&gt;
&lt;td&gt;Running&lt;/td&gt;
&lt;td&gt;Initial loading view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No snapshot&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;Full error with retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cached items&lt;/td&gt;
&lt;td&gt;Running&lt;/td&gt;
&lt;td&gt;Existing list with refresh indicator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cached items&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;Existing list with a retry message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stored empty result&lt;/td&gt;
&lt;td&gt;Idle&lt;/td&gt;
&lt;td&gt;Intentional empty state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are product choices, but making them explicit prevents accidental behavior. Show a “last updated” value when freshness matters, using persisted metadata rather than the time the composable happened to render.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle the next race before it ships
&lt;/h2&gt;

&lt;p&gt;If two refreshes overlap, an older response can arrive after a newer one. Pick a policy: serialize refreshes, cancel obsolete work where appropriate, or reject responses using a suitable version check. A database transaction prevents a partially written snapshot; it does not automatically decide which response is newest.&lt;/p&gt;

&lt;p&gt;Account changes also need ownership rules. Partition or clear account-specific data deliberately and cancel old work so a previous user's response cannot populate the next user's screen.&lt;/p&gt;

&lt;p&gt;For edits and queued writes, go further: define durable operations, server deduplication, conflict handling, and how pending state is shown. A cached read feed alone is not a complete offline synchronization engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the behavior users notice
&lt;/h2&gt;

&lt;p&gt;Test a stored non-empty list followed by a refresh failure. The list should remain. Then test an accepted empty snapshot, restart the process, and confirm the app still knows the result was empty.&lt;/p&gt;

&lt;p&gt;That pair of cases often reveals more than a successful network demo. Offline-first behavior starts with preserving a coherent local view and making uncertainty visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/topic/architecture/data-layer/offline-first" rel="noopener noreferrer"&gt;Android Developers: Build an offline-first app&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/topic/architecture/data-layer" rel="noopener noreferrer"&gt;Android Developers: Data layer and source of truth&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/topic/architecture" rel="noopener noreferrer"&gt;Android Developers: Guide to app architecture&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>android</category>
    </item>
    <item>
      <title>KMP Coroutine Tests: Test Time Without Waiting for It</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:50:34 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/kmp-coroutine-tests-test-time-without-waiting-for-it-1lg</link>
      <guid>https://dev.to/prem_singh_1997/kmp-coroutine-tests-test-time-without-waiting-for-it-1lg</guid>
      <description>&lt;p&gt;A test that sleeps for a second to verify a one-second delay is slow by design. A test that sleeps a little longer to avoid failing on CI is also difficult to trust.&lt;/p&gt;

&lt;p&gt;For shared coroutine logic, virtual time gives you a better question: what should have happened before this deadline, and what should happen at it?&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the delayed behavior small
&lt;/h2&gt;

&lt;p&gt;Here is a deliberately tiny piece of shared code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.delay&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DelayedRefresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The delay is only an example. In a real feature, a product rule should justify it. The important design choice is that the class suspends in its caller's coroutine instead of creating an unrelated scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assert the boundary explicitly
&lt;/h2&gt;

&lt;p&gt;Put this test in &lt;code&gt;commonTest&lt;/code&gt; with &lt;code&gt;kotlin.test&lt;/code&gt; and &lt;code&gt;kotlinx-coroutines-test&lt;/code&gt; dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.ExperimentalCoroutinesApi&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.launch&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.test.advanceTimeBy&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.test.runCurrent&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.test.runTest&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlin.test.Test&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlin.test.assertEquals&lt;/span&gt;

&lt;span class="nd"&gt;@OptIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExperimentalCoroutinesApi&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DelayedRefreshTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;refreshRunsAtTheDeadline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;refreshes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DelayedRefresh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;refreshes&lt;/span&gt;&lt;span class="p"&gt;++&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;runCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="nf"&gt;advanceTimeBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refreshes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;advanceTimeBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;runCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refreshes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first &lt;code&gt;runCurrent()&lt;/code&gt; starts the launched coroutine and reaches its delay. Advancing by 999 ms proves the work has not fired early. Advancing to 1,000 ms moves the clock to the deadline; &lt;code&gt;runCurrent()&lt;/code&gt; executes work scheduled at that instant.&lt;/p&gt;

&lt;p&gt;This distinction matters because &lt;code&gt;advanceTimeBy&lt;/code&gt; does not itself run tasks scheduled exactly at the destination time. Treating clock movement and task execution as separate steps makes boundary assertions easier to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test cancellation as a feature
&lt;/h2&gt;

&lt;p&gt;Add a second test to the same class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;cancellationPreventsRefresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;refreshes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DelayedRefresh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;refreshes&lt;/span&gt;&lt;span class="p"&gt;++&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;runCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;advanceTimeBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nf"&gt;advanceTimeBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;runCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refreshes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A completed success test cannot prove that obsolete work stops. This cancellation test expresses a separate requirement: cancelling the owner before the deadline must prevent the refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Virtual time has a boundary too
&lt;/h2&gt;

&lt;p&gt;The test scheduler controls work using its test dispatchers. It does not accelerate a real server, blocking sleep, or arbitrary work moved to a production dispatcher.&lt;/p&gt;

&lt;p&gt;When a class must switch dispatcher, inject that dispatcher and use a &lt;code&gt;StandardTestDispatcher(testScheduler)&lt;/code&gt; in the test. Keep related test dispatchers on the same scheduler. Otherwise, advancing one clock may not advance the work you intended to observe.&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;runTest&lt;/code&gt; as the returned result of the test function, as shown above, for cross-platform compatibility. Avoid placing unrelated work after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this still does not prove
&lt;/h2&gt;

&lt;p&gt;A passing common test validates the shared behavior on the targets where you actually run it. Merely placing a test in &lt;code&gt;commonTest&lt;/code&gt; is not evidence that CI executed every target.&lt;/p&gt;

&lt;p&gt;Discover the test tasks exposed by your module, run the relevant Android/JVM and native targets, and respect host requirements for Apple targets. Native integration and UI behavior deserve their own tests.&lt;/p&gt;

&lt;p&gt;For timing-sensitive code, a useful test tells a short story: start work, advance to just before the boundary, assert, cross the boundary, assert again. That is more informative than waiting and hoping the final state appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/" rel="noopener noreferrer"&gt;Kotlin: kotlinx-coroutines-test&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/advance-time-by.html" rel="noopener noreferrer"&gt;Kotlin: advanceTimeBy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kotlinlang.org/docs/multiplatform/multiplatform-run-tests.html" rel="noopener noreferrer"&gt;Kotlin: Test your multiplatform app&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kotlin</category>
    </item>
    <item>
      <title>KMP Architecture: Share the Contract, Keep the Platform Details</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:49:00 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/kmp-architecture-share-the-contract-keep-the-platform-details-4b83</link>
      <guid>https://dev.to/prem_singh_1997/kmp-architecture-share-the-contract-keep-the-platform-details-4b83</guid>
      <description>&lt;p&gt;The first difficult Kotlin Multiplatform decision is often not how much code to share. It is where platform behavior should enter the shared code.&lt;/p&gt;

&lt;p&gt;Take an article reader that opens an external link. Android needs an appropriate Context and intent. iOS needs its own application APIs. Shared presentation logic only needs to request an action and handle its outcome.&lt;/p&gt;

&lt;p&gt;That is a useful place for a small contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the caller's needs
&lt;/h2&gt;

&lt;p&gt;Put the following in &lt;code&gt;commonMain&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OpenLinkResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Opened&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Unavailable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ExternalLinkOpener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;OpenLinkResult&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;linkOpener&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ExternalLinkOpener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;openSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;OpenLinkResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OpenLinkResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Unavailable&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;linkOpener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This deliberately small example rejects non-HTTPS input. A real application still needs URI parsing and any product-specific host rules; checking a prefix is not a security validation strategy.&lt;/p&gt;

&lt;p&gt;The dependency describes a capability. It does not expose an Android Context, an Intent, or an iOS application object to every caller.&lt;/p&gt;

&lt;p&gt;Also define what &lt;code&gt;Opened&lt;/code&gt; means: the platform accepted the handoff, not that the page loaded or the user read it. Clear semantics make the contract useful across platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inject an implementation at the edge
&lt;/h2&gt;

&lt;p&gt;Each platform creates its implementation and passes it to &lt;code&gt;ArticleActions&lt;/code&gt;. Constructor injection is enough; a dependency injection framework is optional.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Android entry point -&amp;gt; AndroidLinkOpener -&amp;gt; ArticleActions
iOS entry point     -&amp;gt; IOSLinkOpener     -&amp;gt; ArticleActions
commonTest          -&amp;gt; FakeLinkOpener    -&amp;gt; ArticleActions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep platform thread requirements, lifecycle handling, and native error translation in the adapters. A &lt;code&gt;suspend&lt;/code&gt; declaration does not automatically switch threads or make a platform call cancellable.&lt;/p&gt;

&lt;p&gt;If the adapter performs suspending work, preserve coroutine cancellation instead of turning it into an ordinary &lt;code&gt;Unavailable&lt;/code&gt; result. Convert expected platform failures deliberately; do not blanket-catch every programming error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where expect/actual fits
&lt;/h2&gt;

&lt;p&gt;When the shared module should provide its own construction entry point, an expected factory can return the interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;createExternalLinkOpener&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;ExternalLinkOpener&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The corresponding &lt;code&gt;actual&lt;/code&gt; functions belong to the relevant platform source sets and package. Their implementations are omitted here because construction may require platform dependencies. A no-argument factory is only suitable when those dependencies can be obtained appropriately; otherwise pass them at a platform entry point.&lt;/p&gt;

&lt;p&gt;The interface and expect/actual serve different purposes. The interface is the behavior contract. The factory selects a construction path. You do not need an expected class for every platform service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test behavior without opening a browser
&lt;/h2&gt;

&lt;p&gt;This common test verifies that an unsupported scheme never reaches the platform adapter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.test.runTest&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlin.test.Test&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlin.test.assertEquals&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleActionsTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;unsupportedSchemeDoesNotReachAdapter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;calls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fake&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;ExternalLinkOpener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;OpenLinkResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;++&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OpenLinkResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Opened&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ArticleActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;openSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"file:///notes"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OpenLinkResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Unavailable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;kotlin.test&lt;/code&gt; and &lt;code&gt;kotlinx-coroutines-test&lt;/code&gt; in &lt;code&gt;commonTest&lt;/code&gt;, aligned with your project versions. The snippets describe a module design, not a complete app setup.&lt;/p&gt;

&lt;p&gt;This test checks the shared decision. Platform tests still need to verify the actual handoff, valid URLs, unavailable handlers, and lifecycle behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  A useful boundary review
&lt;/h2&gt;

&lt;p&gt;Before introducing a platform abstraction, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the caller need a capability, or the native object itself?&lt;/li&gt;
&lt;li&gt;Can the outcome mean the same thing on every supported platform?&lt;/li&gt;
&lt;li&gt;Who supplies platform dependencies and owns their lifetime?&lt;/li&gt;
&lt;li&gt;Can a fake verify shared behavior without pretending to test native integration?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Share policy and intent where they are genuinely common. Keep native mechanics at a boundary you can explain and test.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kotlinlang.org/docs/multiplatform/multiplatform-connect-to-apis.html" rel="noopener noreferrer"&gt;Kotlin: Use platform-specific APIs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kotlinlang.org/docs/multiplatform/multiplatform-expect-actual.html" rel="noopener noreferrer"&gt;Kotlin: Expected and actual declarations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kotlinlang.org/docs/multiplatform/multiplatform-run-tests.html" rel="noopener noreferrer"&gt;Kotlin: Test your multiplatform app&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kotlin</category>
    </item>
    <item>
      <title>Your AI Feature Deserves Better Than a Spinner: A Jetpack Compose Response Card</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Sun, 18 Oct 2026 07:38:22 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/your-ai-feature-deserves-better-than-a-spinner-a-jetpack-compose-response-card-4n83</link>
      <guid>https://dev.to/prem_singh_1997/your-ai-feature-deserves-better-than-a-spinner-a-jetpack-compose-response-card-4n83</guid>
      <description>&lt;p&gt;An AI response can take a moment. During that pause, the interface has a job: explain what is happening, preserve context, and give the user a way forward.&lt;/p&gt;

&lt;p&gt;Let's design a compact response card for an Android writing assistant. The visual direction is simple: a rounded Material surface, a clear heading, generous spacing, and one primary action. The interesting part is how the card behaves when the answer isn't ready.&lt;/p&gt;

&lt;p&gt;This is a UI implementation example, not an AI integration. The response text is supplied by the caller. No model, API key, or network request is involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with four explicit states
&lt;/h2&gt;

&lt;p&gt;Avoid a collection of independent booleans such as &lt;code&gt;isLoading&lt;/code&gt;, &lt;code&gt;hasError&lt;/code&gt;, and &lt;code&gt;hasResult&lt;/code&gt;. Those can accidentally describe contradictory combinations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Idle&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Loading&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each state should answer a user question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;What the interface explains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Idle&lt;/td&gt;
&lt;td&gt;What can I do here?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loading&lt;/td&gt;
&lt;td&gt;Did my action start?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer&lt;/td&gt;
&lt;td&gt;What did I get back?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error&lt;/td&gt;
&lt;td&gt;How can I recover?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Build the card around state and events
&lt;/h2&gt;

&lt;p&gt;The following snippet uses Compose Foundation, Animation, and Material 3 inside an existing Compose app. Include the standard imports from those libraries. It is an illustrative snippet; it has not been compiled or device-tested as a standalone project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;AssistantCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;onGenerate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;colors&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;colorScheme&lt;/span&gt;

    &lt;span class="nc"&gt;Surface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RoundedCornerShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;surfaceContainer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tonalElevation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;verticalArrangement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Arrangement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spacedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Writing assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;titleLarge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onSurface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Turn a rough note into a clearer draft."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodyMedium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onSurfaceVariant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="nc"&gt;AnimatedContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;targetState&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"assistant_content"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Idle&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="s"&gt;"Ready when you are."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onSurface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Loading&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;horizontalArrangement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Arrangement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spacedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="n"&gt;verticalAlignment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Alignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CenterVertically&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nc"&gt;CircularProgressIndicator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                            &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                            &lt;span class="n"&gt;strokeWidth&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Preparing your draft…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onSurface&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Answer&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodyLarge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onSurface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Couldn't prepare a draft. Please try again."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onGenerate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Loading&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Preparing…"&lt;/span&gt;
                        &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Try again"&lt;/span&gt;
                        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Answer&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Generate another draft"&lt;/span&gt;
                        &lt;span class="nc"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Idle&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Generate draft"&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The card receives immutable state and emits an event. It doesn't own the request. This makes it easier to preview each state and connect the same interface to a fake response or a real service. Choose the state owner based on the logic involved; business logic typically belongs in a screen-level state holder such as a ViewModel. See the &lt;a href="https://developer.android.com/develop/ui/compose/state-hoisting" rel="noopener noreferrer"&gt;Android state-hoisting guidance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AnimatedContent&lt;/code&gt; uses its lambda parameter (&lt;code&gt;current&lt;/code&gt;) to render the transitioning state. Keep that detail: reading the outer &lt;code&gt;state&lt;/code&gt; instead can break the intended transition behavior. The &lt;a href="https://developer.android.com/develop/ui/compose/animation/composables-modifiers" rel="noopener noreferrer"&gt;official animation documentation&lt;/a&gt; explains the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The details that make it feel finished
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use motion to explain change.&lt;/strong&gt; A gentle content transition is enough here. Constant shimmer or a dramatic glow can compete with the answer. For streamed responses, keep the answer container stable rather than restarting a transition for every token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be honest about progress.&lt;/strong&gt; This example shows indeterminate progress. Display a percentage only when the underlying work can actually measure it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design the recovery path.&lt;/strong&gt; An error message without an action is a dead end. Preserve the original input outside this card so retry doesn't require typing it again. A real request owner should also reject duplicate requests and handle cancellation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check more than the screenshot.&lt;/strong&gt; Test large font sizes, dark mode, long answers, and TalkBack. This sample leaves announcements to the surrounding screen; consider a concise polite live-region announcement when a result becomes available. Avoid announcing every streamed token. Read the &lt;a href="https://developer.android.com/develop/ui/compose/accessibility/semantics" rel="noopener noreferrer"&gt;Compose semantics guidance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the result identifiable.&lt;/strong&gt; Label AI-generated output and let the user review it before using it elsewhere. A polished surface should never imply the answer has been verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it in your next Compose experiment
&lt;/h2&gt;

&lt;p&gt;Render the card in all four states before connecting a model. If the loading and error views feel like afterthoughts, the feature isn't finished yet.&lt;/p&gt;

&lt;p&gt;What would you add next: cancellation, editable output, or a copy action?&lt;/p&gt;




&lt;p&gt;Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose, and practical Android engineering&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/premsinghsodha7" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/premsinghsodha7/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>design</category>
      <category>kotlin</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Beyond the Run Button: Android Debugging with ADB and Gradle</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Sun, 18 Oct 2026 03:38:16 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/beyond-the-run-button-android-debugging-with-adb-and-gradle-5gnf</link>
      <guid>https://dev.to/prem_singh_1997/beyond-the-run-button-android-debugging-with-adb-and-gradle-5gnf</guid>
      <description>&lt;p&gt;“It works when I press Run” is a starting point. A useful debugging workflow also lets another developer reproduce the same build, launch the same route, and collect evidence of the failure.&lt;/p&gt;

&lt;p&gt;Here is a compact terminal workflow for a debug app. Each step answers a question rather than adding another command to memorize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;p&gt;Examples use macOS/Linux shell syntax, an &lt;code&gt;app&lt;/code&gt; module, and a &lt;code&gt;debug&lt;/code&gt; variant. On Windows, use &lt;code&gt;gradlew.bat&lt;/code&gt; and adapt shell variables and quoting. Run Gradle commands from the project root. Your configured JDK must be compatible with the project's Android Gradle Plugin.&lt;/p&gt;

&lt;p&gt;Have SDK Platform Tools on your PATH and an authorized test device or emulator connected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb devices &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANDROID_SERIAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;emulator-5554
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the serial with one from the output. &lt;code&gt;ANDROID_SERIAL&lt;/code&gt; selects the target for subsequent ADB commands. An &lt;code&gt;unauthorized&lt;/code&gt; device needs approval on the unlocked device.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Can someone else build this exact variant?
&lt;/h2&gt;

&lt;p&gt;Use the project's Gradle Wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew :app:assembleDebug
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a conventional project, the APK is at &lt;code&gt;app/build/outputs/apk/debug/app-debug.apk&lt;/code&gt;. Flavors and output customization change tasks and paths; inspect the actual project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew :app:tasks &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a flavor named &lt;code&gt;demo&lt;/code&gt;, look for &lt;code&gt;assembleDemoDebug&lt;/code&gt;. A successful build produces an artifact; it doesn't mean the app was installed or launched. These task conventions are described in the &lt;a href="https://developer.android.com/build/building-cmdline" rel="noopener noreferrer"&gt;Android command-line build guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Is the build on the intended device?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; app/build/outputs/apk/debug/app-debug.apk
adb shell am start &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; com.example.debugcli/.MainActivity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the application ID and activity with your debug variant's actual values. &lt;code&gt;-r&lt;/code&gt; replaces an installed compatible package while retaining its data. A signature mismatch needs investigation; deleting an app is not a neutral fix because it removes local data.&lt;/p&gt;

&lt;p&gt;Alternatively, &lt;code&gt;./gradlew :app:installDebug&lt;/code&gt; builds and installs. Check its output to confirm the selected device or devices when several are connected. See the &lt;a href="https://developer.android.com/tools/adb" rel="noopener noreferrer"&gt;ADB guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Which logs belong to the running process?
&lt;/h2&gt;

&lt;p&gt;After launching the app, get its current PID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell pidof &lt;span class="nt"&gt;-s&lt;/span&gt; com.example.debugcli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suppose it prints &lt;code&gt;12345&lt;/code&gt;. Use that value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb logcat &lt;span class="nt"&gt;--pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;12345 &lt;span class="nt"&gt;-v&lt;/span&gt; threadtime
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stop streaming with Ctrl+C. An empty PID usually means the process isn't running or the application ID is wrong. Recheck it after a restart: a new process can have a new PID. Separate app processes need separate consideration.&lt;/p&gt;

&lt;p&gt;A PID filter is focused, but can hide useful system-side evidence. For crash investigation, also inspect the crash buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb logcat &lt;span class="nt"&gt;-b&lt;/span&gt; crash &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; threadtime &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; crash-log.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This dumps available crash-buffer entries, which can include other apps and older failures. Match timestamps and package/process details. It is not a complete ANR diagnosis. Inspect supported options with &lt;code&gt;adb logcat --help&lt;/code&gt;; see the &lt;a href="https://developer.android.com/tools/logcat" rel="noopener noreferrer"&gt;Logcat command-line reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Does the deep link actually reach the right screen?
&lt;/h2&gt;

&lt;p&gt;Assume your manifest declares &lt;code&gt;debugcli://product/42&lt;/code&gt; as a supported route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell am start &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; android.intent.action.VIEW &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'debugcli://product/42'&lt;/span&gt; com.example.debugcli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package restriction checks routing inside your app. Also test without that restriction to observe normal system resolution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell am start &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; android.intent.action.VIEW &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'debugcli://product/42'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Success means more than an activity opening: verify the destination, arguments, sign-in behavior, and Back navigation. A custom scheme doesn't prove HTTPS App Links verification. For HTTPS links, test the real domain and its verification separately. See &lt;a href="https://developer.android.com/training/app-links/create-deeplinks" rel="noopener noreferrer"&gt;Create deep links&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Does the feature handle a denied permission?
&lt;/h2&gt;

&lt;p&gt;For an app that declares CAMERA and supports runtime permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell pm revoke com.example.debugcli android.permission.CAMERA
adb shell pm grant com.example.debugcli android.permission.CAMERA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run these separately: revoke, exercise the denied path, then grant and exercise the granted path. Permission changes can affect the app process, so recheck its PID.&lt;/p&gt;

&lt;p&gt;Shell grants don't test the permission dialog or every denial state. Also test the real request flow and the feature's fallback behavior. See &lt;a href="https://developer.android.com/training/permissions/requesting" rel="noopener noreferrer"&gt;Request runtime permissions&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Can you attach evidence to the issue?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb exec-out screencap &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; debug-screen.png
adb shell screenrecord &lt;span class="nt"&gt;--time-limit&lt;/span&gt; 20 /sdcard/debug-demo.mp4
adb pull /sdcard/debug-demo.mp4 ./debug-demo.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recording command waits while recording. Pull the file after it finishes. Standard &lt;code&gt;screenrecord&lt;/code&gt; doesn't record audio; protected content may be unavailable. See &lt;a href="https://developer.android.com/tools/adb#screencap" rel="noopener noreferrer"&gt;ADB screen capture and recording&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn the workflow into a useful bug report
&lt;/h2&gt;

&lt;p&gt;Attach a short reproduction recipe with the variant, application ID, device/Android version, triggering command, expected result, and observed result. Add a timestamped log excerpt and a short recording when they explain the failure. Use test data and review logs and media before sharing them.&lt;/p&gt;

&lt;p&gt;For your portfolio, include a &lt;code&gt;DEBUGGING.md&lt;/code&gt; beside a sample app. Document one reproducible bug, the evidence that isolated it, and the fix. That shows the reasoning behind your tooling skills.&lt;/p&gt;

&lt;p&gt;Which command saves you the most time when a bug refuses to reproduce from Android Studio?&lt;/p&gt;




&lt;p&gt;Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose &amp;amp; Android architecture&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/premsinghsodha7" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/premsinghsodha7/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
    </item>
    <item>
      <title>The Compose Search Bug That Debounce Doesn't Fix</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Sun, 18 Oct 2026 03:23:04 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/the-compose-search-bug-that-debounce-doesnt-fix-1kbf</link>
      <guid>https://dev.to/prem_singh_1997/the-compose-search-bug-that-debounce-doesnt-fix-1kbf</guid>
      <description>&lt;p&gt;A user searches for &lt;code&gt;cat&lt;/code&gt;, then changes the query to &lt;code&gt;car&lt;/code&gt;. For a moment, results for cats appear under the new query.&lt;/p&gt;

&lt;p&gt;You already added &lt;code&gt;debounce(300)&lt;/code&gt; and &lt;code&gt;flatMapLatest&lt;/code&gt;. So why can the old request still finish?&lt;/p&gt;

&lt;p&gt;The answer is operator placement. Debouncing reduces request frequency. It does not automatically cancel an in-flight search the instant the user edits the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  The familiar pipeline has a timing window
&lt;/h2&gt;

&lt;p&gt;Consider this simplified chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;queries&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMapLatest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;searchFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the user types &lt;code&gt;car&lt;/code&gt;, the new value waits inside &lt;code&gt;debounce&lt;/code&gt;. Until that value reaches &lt;code&gt;flatMapLatest&lt;/code&gt;, the previous inner flow can continue. If the &lt;code&gt;cat&lt;/code&gt; response arrives during that gap, it can still be emitted.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/debounce.html" rel="noopener noreferrer"&gt;Kotlin debounce reference&lt;/a&gt; describes suppression of rapidly followed values. The &lt;a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/flat-map-latest.html" rel="noopener noreferrer"&gt;flatMapLatest reference&lt;/a&gt; describes cancellation when a new upstream value reaches that operator. Those are different moments.&lt;/p&gt;

&lt;p&gt;This behavior can be acceptable when the UI deliberately keeps old results visible and labels them correctly. If you want a new query to cancel old work promptly, move the waiting period inside the switched flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switch first, wait second
&lt;/h2&gt;

&lt;p&gt;The following integration sketch accepts a Flow of query strings and a cancellable suspend search function. It separates waiting, loading, empty results, and failure so the UI can communicate each state clearly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.CancellationException&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.ExperimentalCoroutinesApi&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.delay&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.Flow&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.buffer&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.distinctUntilChanged&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.flatMapLatest&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.flow&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.map&lt;/span&gt;

&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;SearchHit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;SearchState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Idle&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SearchState&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Waiting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SearchState&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SearchState&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchHit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SearchState&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SearchState&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@OptIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExperimentalCoroutinesApi&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;searchStates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchHit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;distinctUntilChanged&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMapLatest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Idle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Waiting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CancellationException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;cancelled&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a Flow-layer example, not a complete Compose app. Connect your repository and UI using dependencies compatible with your project. The experimental opt-in reflects the linked API reference; check the status for your coroutines version.&lt;/p&gt;

&lt;p&gt;Now a new distinct, trimmed query reaches &lt;code&gt;flatMapLatest&lt;/code&gt; before the delay. It cancels the previous inner flow, including its waiting period or cooperative in-flight request. The new flow then starts its own waiting period.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;buffer(0)&lt;/code&gt; avoids an extra queued output buffer after the switching operator. It cannot retract a value the UI has already received, so keep the query attached to every result and render it honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep text input responsive
&lt;/h2&gt;

&lt;p&gt;Debounce the search operation, not the text field's displayed value. Typing and cursor movement should update immediately.&lt;/p&gt;

&lt;p&gt;For state-based Compose text fields, &lt;code&gt;TextFieldState&lt;/code&gt; owns editable text, selection, and composition. You can observe its text with &lt;code&gt;snapshotFlow&lt;/code&gt; and feed that into the search pipeline. Preserve the text field state rather than replacing it with a fresh instance on every recomposition. The &lt;a href="https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/input/TextFieldState" rel="noopener noreferrer"&gt;Android TextFieldState reference&lt;/a&gt; includes examples.&lt;/p&gt;

&lt;p&gt;Choose the lifetime of the collector deliberately. For screen state on Android, lifecycle-aware collection is generally appropriate; a search operation should not accidentally gain a lifetime longer than its owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three details worth getting right
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A blank query must reach the switch.&lt;/strong&gt; Filtering out short queries before &lt;code&gt;flatMapLatest&lt;/code&gt; can leave an older search active when the user clears the field. This example emits &lt;code&gt;Idle&lt;/code&gt; inside the switched flow instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch errors per request.&lt;/strong&gt; The failure handler sits inside the inner flow. A failed request becomes a state while the outer query stream remains available for later input. A single &lt;code&gt;catch&lt;/code&gt; after the whole pipeline can instead complete that collection after an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancellation is cooperative.&lt;/strong&gt; A suspend function is not automatically well behaved. The repository must support cancellation and avoid blocking the calling thread. Cancelling a client request also doesn't guarantee the server stopped processing it. Never swallow &lt;code&gt;CancellationException&lt;/code&gt; as an ordinary search error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about retrying the same query?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;distinctUntilChanged&lt;/code&gt; intentionally suppresses equal adjacent queries. Assigning the same string again won't implement retry.&lt;/p&gt;

&lt;p&gt;Model retry as a separate action. For example, include an attempt counter in a request model and increment it on retry, so the request stream sees a new value even when the query is unchanged. Decide whether explicit retries should bypass the typing delay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the timing, not just the final list
&lt;/h2&gt;

&lt;p&gt;Use a fake search implementation and virtual time to verify these scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Expected behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rapid typing&lt;/td&gt;
&lt;td&gt;Only the settled query starts a search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit during a request&lt;/td&gt;
&lt;td&gt;Previous cooperative request is cancelled when the new query reaches the switch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear the field&lt;/td&gt;
&lt;td&gt;State becomes Idle; previous work is cancelled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request fails, then query changes&lt;/td&gt;
&lt;td&gt;A new search can still run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry unchanged text&lt;/td&gt;
&lt;td&gt;Explicit retry creates another attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A small demo showing the query, current state, and cancellation logs makes this a useful portfolio feature. It demonstrates an engineering decision that a polished screenshot alone cannot show.&lt;/p&gt;

&lt;p&gt;The next time you review a search pipeline, ask: does cancellation happen when the user changes intent, or only after the debounce window?&lt;/p&gt;




&lt;p&gt;Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose &amp;amp; Android architecture&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/premsinghsodha7" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/premsinghsodha7/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
    </item>
    <item>
      <title>Your Compose Screen Is Gone. Why Is Your Flow Still Running?</title>
      <dc:creator>Prem Singh</dc:creator>
      <pubDate>Sun, 18 Oct 2026 03:10:19 +0000</pubDate>
      <link>https://dev.to/prem_singh_1997/your-compose-screen-is-gone-why-is-your-flow-still-running-4e2m</link>
      <guid>https://dev.to/prem_singh_1997/your-compose-screen-is-gone-why-is-your-flow-still-running-4e2m</guid>
      <description>&lt;p&gt;Press Home on an Android app. The screen disappears, but that doesn't automatically mean every coroutine feeding it has stopped.&lt;/p&gt;

&lt;p&gt;For a search screen, live dashboard, or AI response view, this distinction matters. A UI collector and the producer behind it have separate lifetimes. Reviewing only one can leave unnecessary work running—or stop work that the product actually needs to finish.&lt;/p&gt;

&lt;p&gt;Here's a small pattern worth understanding before adding more architecture to a Compose screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Collect screen state with the Android lifecycle
&lt;/h2&gt;

&lt;p&gt;For an Android Compose route backed by a ViewModel, use lifecycle-aware collection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.runtime.Composable&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.runtime.getValue&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.lifecycle.compose.collectAsStateWithLifecycle&lt;/span&gt;

&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;FeedRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FeedViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;state&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectAsStateWithLifecycle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nc"&gt;FeedScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FeedScreen&lt;/code&gt; is your rendering composable; &lt;code&gt;FeedViewModel&lt;/code&gt; is supplied by your app's normal ViewModel creation mechanism. Add &lt;code&gt;androidx.lifecycle:lifecycle-runtime-compose&lt;/code&gt; using a version aligned with your project's Lifecycle dependencies.&lt;/p&gt;

&lt;p&gt;By default, collection runs when the lifecycle is at least &lt;code&gt;STARTED&lt;/code&gt;. It stops below that state and starts again when the lifecycle becomes active. This is different from ordinary &lt;code&gt;collectAsState()&lt;/code&gt;, whose collection follows the composition rather than applying this lifecycle threshold. See the &lt;a href="https://developer.android.com/develop/ui/compose/state" rel="noopener noreferrer"&gt;Android Compose state guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That fixes the consumer side. Now look upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Give the upstream an explicit sharing policy
&lt;/h2&gt;

&lt;p&gt;Here is an integration sketch for a repository that observes feed items. It assumes a cold Flow whose observation work is cancelled when its collector is cancelled. Adapt the model and repository to your app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.lifecycle.ViewModel&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.lifecycle.viewModelScope&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.CancellationException&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.Flow&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.SharingStarted&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.StateFlow&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.catch&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.map&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.stateIn&lt;/span&gt;

&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Loading&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;FeedRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;observeItems&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FeedViewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FeedRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observeItems&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cause&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cause&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;CancellationException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;cause&lt;/span&gt;
            &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stateIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;viewModelScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SharingStarted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WhileSubscribed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;stopTimeoutMillis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;initialValue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The explicit &lt;code&gt;map&lt;/code&gt; result type allows both &lt;code&gt;Content&lt;/code&gt; and &lt;code&gt;Error&lt;/code&gt; to flow through the same pipeline. The cancellation guard makes the intended behavior clear: cancelling observation should not become a visible failure.&lt;/p&gt;

&lt;p&gt;These are focused integration snippets, not a complete sample app. The repository, UI rendering, and dependency setup remain application-specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Read the timeline, not just the API name
&lt;/h2&gt;

&lt;p&gt;With the policy above, the expected sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UI starts collecting       -&amp;gt; upstream observation starts
last subscriber disappears -&amp;gt; five-second grace period begins
subscriber returns early   -&amp;gt; existing observation continues
no subscriber after 5s     -&amp;gt; sharing cancels upstream collection
subscriber returns later   -&amp;gt; upstream collection starts again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grace period can bridge short subscriber gaps such as configuration changes. Five seconds is a choice, not a universal performance rule.&lt;/p&gt;

&lt;p&gt;The default replay expiration retains the last value: stopping the upstream doesn't automatically put &lt;code&gt;uiState&lt;/code&gt; back into &lt;code&gt;Loading&lt;/code&gt;. Returning users can see cached content while observation restarts. Read the &lt;a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-sharing-started/-companion/-while-subscribed.html" rel="noopener noreferrer"&gt;Kotlin WhileSubscribed reference&lt;/a&gt; before changing cache behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Know what this pattern does not stop
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WhileSubscribed&lt;/code&gt; controls this sharing coroutine. It doesn't cancel every job in the ViewModel or shut down an independently running hot producer.&lt;/p&gt;

&lt;p&gt;For example, this job has a different owner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;viewModelScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refreshOnce&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;refreshOnce()&lt;/code&gt; represents an application-specific suspend function. Its job can continue after the UI stops collecting because it was launched separately in &lt;code&gt;viewModelScope&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This can be intentional. An explicit user action and an observational feed don't always need the same lifetime. Decide whether an operation should stop when the screen becomes inactive, survive a navigation change, or require durable execution. Then choose its owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Handle failures and retry deliberately
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;catch&lt;/code&gt; above turns a failure into an error state, but it does not continuously retry. Once that upstream attempt completes, a retry action needs an explicit strategy, such as a refresh trigger that starts a new observation, or a bounded &lt;code&gt;retryWhen&lt;/code&gt; policy before &lt;code&gt;catch&lt;/code&gt; for suitable transient failures.&lt;/p&gt;

&lt;p&gt;Don't rely on backgrounding and reopening the app as the user's retry mechanism. Also consider retaining existing content alongside a refresh error when that serves the screen better.&lt;/p&gt;

&lt;h2&gt;
  
  
  A useful code review exercise
&lt;/h2&gt;

&lt;p&gt;For the next Flow-backed screen you review, trace four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which lifecycle controls the UI collector?&lt;/li&gt;
&lt;li&gt;Which scope owns the producer?&lt;/li&gt;
&lt;li&gt;Does cancellation actually reach the underlying work?&lt;/li&gt;
&lt;li&gt;What happens after failure, and who triggers another attempt?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a portfolio demo, instrument a fake cold Flow with start and cancellation logs, then record navigating away and returning before and after the timeout. Show the logs beside the screen recording. That demonstrates the behavior far more clearly than a screenshot of an API call.&lt;/p&gt;

&lt;p&gt;The key design decision is ownership: the screen's visibility, the observer's lifetime, and the operation's lifetime should each be intentional.&lt;/p&gt;

&lt;p&gt;What do you usually find in reviews: missing lifecycle-aware collection, or upstream work with an unexpected lifetime?&lt;/p&gt;




&lt;p&gt;Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose &amp;amp; Android architecture&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/premsinghsodha7" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/premsinghsodha7/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
