<?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: Rao Asad</title>
    <description>The latest articles on DEV Community by Rao Asad (@rao_asad_fb657aebdb761ad4).</description>
    <link>https://dev.to/rao_asad_fb657aebdb761ad4</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%2F4166165%2F42ed3b35-e2df-4ac4-92b1-cc1157048356.jpeg</url>
      <title>DEV Community: Rao Asad</title>
      <link>https://dev.to/rao_asad_fb657aebdb761ad4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rao_asad_fb657aebdb761ad4"/>
    <language>en</language>
    <item>
      <title>I wrote my own document scanner and PDF engine for Android. Here's what that involved</title>
      <dc:creator>Rao Asad</dc:creator>
      <pubDate>Mon, 19 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/rao_asad_fb657aebdb761ad4/i-wrote-my-own-document-scanner-and-pdf-engine-for-android-heres-what-that-involved-30m2</link>
      <guid>https://dev.to/rao_asad_fb657aebdb761ad4/i-wrote-my-own-document-scanner-and-pdf-engine-for-android-heres-what-that-involved-30m2</guid>
      <description>&lt;p&gt;There are already great ways to add document scanning to an Android app. Google's ML Kit has a ready-made scanner screen, OpenCV can find a page in a few lines, and plenty of libraries write PDFs.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://keepscan.truetechsol.org" rel="noopener noreferrer"&gt;Keepscan&lt;/a&gt;, I used none of them for the core. The page detection, the camera screen, the perspective correction and the PDF writer are all plain Kotlin on top of platform APIs. The only ML library in the app is on-device text recognition, for copying text out of a scan.&lt;/p&gt;

&lt;p&gt;That wasn't purity. A scanner app &lt;em&gt;is&lt;/em&gt; its engine, and I wanted to be able to change any part of it later without waiting on someone else's roadmap. Here's what building it involved, and the parts that surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the page: lines, not contours
&lt;/h2&gt;

&lt;p&gt;The classic tutorial approach is: edge detection, find the largest closed contour with four corners, done. It works beautifully on a clean page on a dark desk.&lt;/p&gt;

&lt;p&gt;Real photos aren't like that. A thumb covers a corner, a shadow falls across an edge, a white page sits on a light table. Any gap breaks the contour, and the page is lost.&lt;/p&gt;

&lt;p&gt;So Keepscan looks for &lt;strong&gt;straight lines&lt;/strong&gt; instead, and assembles the page from them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Shrink the frame to 256 px on its long side and blur it. Page edges survive easily; text strokes and paper texture mostly don't.&lt;/li&gt;
&lt;li&gt;Canny edge detection: Sobel gradients, thinned to one-pixel ridges, strong ones kept.&lt;/li&gt;
&lt;li&gt;A Hough transform turns edge pixels into line candidates. A line survives gaps that would break a contour.&lt;/li&gt;
&lt;li&gt;Try every pair of roughly horizontal lines against every pair of roughly vertical ones, and score each quadrilateral.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two details made the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rank each direction separately.&lt;/strong&gt; Rows of text produce lots of strong horizontal lines. Ranked together, they crowd out the page's vertical edges entirely. Keeping the top 10 of each direction fixed that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Score like a page, not like a line.&lt;/strong&gt; A pen lying on the desk is a long straight edge too. What a pen doesn't have is paper on one side and desk on the other. So each side of a candidate is sampled at 48 points, and a point only counts if it sits on an edge pixel &lt;em&gt;and&lt;/em&gt; there's a consistent brightness step across it: the page side brighter (or darker) than the outside, with the same polarity along the whole side. The weakest side weighs heavily in the score, because three good sides and one fake one isn't a page.&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;val&lt;/span&gt; &lt;span class="py"&gt;support&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;weakest&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.4f&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.55f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.45f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;area&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;   &lt;span class="c1"&gt;// bigger pages get a modest bonus&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more refinement: Hough lines are quantised to whole degrees, and over a page-length edge a fraction of a degree moves the ends by several pixels, enough for the scorer to sample &lt;em&gt;beside&lt;/em&gt; the real edge. So each winning line is refitted to the edge pixels within 2 px of it with a total least-squares fit (the direction of least spread in their covariance). That puts it exactly on the edge.&lt;/p&gt;

&lt;p&gt;All of this is pure functions on plain &lt;code&gt;IntArray&lt;/code&gt;s, so it runs identically on a camera frame, a gallery photo and a unit test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it feel steady
&lt;/h2&gt;

&lt;p&gt;Per-frame detection flickers. A tracker sits between the detector and the screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new outline only replaces the shown one after it's been seen &lt;strong&gt;twice in a row&lt;/strong&gt;, so one odd frame doesn't make the overlay jump. Small movements are eased rather than snapped.&lt;/li&gt;
&lt;li&gt;A few missed frames keep the last outline, so a passing shadow doesn't blink it away.&lt;/li&gt;
&lt;li&gt;It reports how long the page has held still. &lt;strong&gt;Auto-capture fires after 1.1 seconds of stillness.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Flattening the page
&lt;/h2&gt;

&lt;p&gt;Once you have four corners, the platform already has what you need: &lt;code&gt;Matrix.setPolyToPoly&lt;/code&gt; with four point pairs is a full perspective transform. The output keeps the page's real proportions (width from the longer of the top and bottom edges, height from the longer side), so a receipt stays receipt-shaped instead of being stretched to A4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing PDFs by hand
&lt;/h2&gt;

&lt;p&gt;Android has &lt;code&gt;PdfDocument&lt;/code&gt;, and I started with it. The problem: it re-encodes bitmaps losslessly. A phone scan came out at several megabytes per page, and no quality setting could shrink it.&lt;/p&gt;

&lt;p&gt;A PDF page that's just an image is a small amount of structure around the image bytes, and PDF can embed a JPEG as-is with the &lt;code&gt;/DCTDecode&lt;/code&gt; filter. So Keepscan writes the file itself: catalog, page tree, and for each page a page object, a one-line content stream that draws the image, and the JPEG bytes untouched.&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;val&lt;/span&gt; &lt;span class="py"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q\n"&lt;/span&gt;   &lt;span class="c1"&gt;// scale the image to the page, draw it&lt;/span&gt;
&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;&amp;lt; /Type /XObject /Subtype /Image /Width ${page.widthPx} /Height ${page.heightPx} "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
       &lt;span class="s"&gt;"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.size} &amp;gt;&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file ends up the size of its images, which is what the size presets promise. Each page is sized so its long side matches A4's (842 pt), keeping the scan's own aspect ratio.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Exactly under 2 MB"
&lt;/h2&gt;

&lt;p&gt;Upload forms love hard limits: "max 2 MB". So Keepscan can save a PDF that's the best quality that still fits under a size you pick.&lt;/p&gt;

&lt;p&gt;There's no formula from settings to file size; it depends on what's on the pages. So there's a ladder of resolution and JPEG-quality pairs, and the exporter &lt;strong&gt;binary-searches&lt;/strong&gt; it: render at the middle rung, check the size, go up or down. A long document is rendered a few times instead of once per rung. Each attempt is written to a file rather than held in memory, because a 30-page document at a high rung is large. If even the smallest rung is too big, you get that file plus an honest "didn't fit".&lt;/p&gt;

&lt;h2&gt;
  
  
  Password-protected PDFs without a PDF library
&lt;/h2&gt;

&lt;p&gt;I added PDFBox for encryption, then removed it the same day. It was a big dependency for one feature. Keepscan only ever needs to &lt;em&gt;encrypt&lt;/em&gt; its own files, never open someone else's, and that turns out to be a well-specified, contained job.&lt;/p&gt;

&lt;p&gt;It's the PDF Standard security handler, revision 6 (AES-256), from ISO 32000-2, written on &lt;code&gt;javax.crypto&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One random 256-bit file key encrypts every stream with AES-256-CBC and a random IV in front.&lt;/li&gt;
&lt;li&gt;The user password unlocks that key.&lt;/li&gt;
&lt;li&gt;The owner password also unlocks it, so it's generated randomly and &lt;strong&gt;thrown away&lt;/strong&gt;. There's no second, hidden way into the file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bugs a real phone found
&lt;/h2&gt;

&lt;p&gt;Two came only from testing on an actual device:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Camera callbacks after close.&lt;/strong&gt; Leaving the scanner the moment it opened crashed with "CameraDevice was already closed". The screen closes the camera on the main thread while Camera2 callbacks are still arriving on the camera thread. The fix is a generation counter: every &lt;code&gt;open()&lt;/code&gt; increments it, and every callback checks it still belongs to the current opening before touching anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The crop handles you couldn't drag.&lt;/strong&gt; On my Xiaomi, dragging a corner near the screen edge triggered the system back gesture instead. &lt;code&gt;setSystemGestureExclusionRects&lt;/code&gt; is the documented fix, and HyperOS ignores it. I moved the handles 40 dp inward instead. It's less elegant, and it works on every phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;It took longer than wiring up libraries would have. But every piece of the pipeline is now code I can read, test and change, and the APK doesn't carry an image-processing library for one screen.&lt;/p&gt;

&lt;p&gt;Keepscan is on &lt;a href="https://play.google.com/store/apps/details?id=org.truetechsol.keepscan" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;: free to scan, edit and share with no watermark, and Pro is a one-time payment. If you've built something similar and solved detection differently, I'd like to hear how.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>computervision</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Turning bank notifications into expenses on Android, without a bank login</title>
      <dc:creator>Rao Asad</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/rao_asad_fb657aebdb761ad4/turning-bank-notifications-into-expenses-on-android-without-a-bank-login-5af2</link>
      <guid>https://dev.to/rao_asad_fb657aebdb761ad4/turning-bank-notifications-into-expenses-on-android-without-a-bank-login-5af2</guid>
      <description>&lt;p&gt;Every time I pay for something, my bank sends a notification: "You spent Rs. 1,250 at ‚Ä¶". The expense tracker on the same phone has no idea it happened, and later I'm expected to type it in by hand.&lt;/p&gt;

&lt;p&gt;That gap is the reason &lt;a href="https://onetap.truetechsol.org" rel="noopener noreferrer"&gt;Onetap&lt;/a&gt; exists. You pick which banking or wallet apps it should watch, and when one of them posts a payment notification, it becomes an expense entry. There's no bank login and no bank API. It only reads notifications you already get, from apps you chose.&lt;/p&gt;

&lt;p&gt;It sounds like a weekend feature. It took a lot longer, mostly because of what has to happen &lt;em&gt;before&lt;/em&gt; any text leaves the phone. Here is how it works and what I got wrong along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Listen to everything, read almost nothing
&lt;/h2&gt;

&lt;p&gt;The entry point is a &lt;code&gt;NotificationListenerService&lt;/code&gt;. Once the user grants notification access, Android hands it &lt;strong&gt;every&lt;/strong&gt; notification on the phone: chats, emails, OTPs, all of it.&lt;/p&gt;

&lt;p&gt;So the very first thing the handler does is look at the package name, and nothing else:&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;onPosted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sbn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StatusBarNotification&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;packageName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sbn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&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;packageName&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageName&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;scope&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="c1"&gt;// Only apps the user explicitly added. Nothing else about any&lt;/span&gt;
        &lt;span class="c1"&gt;// other notification is ever read.&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;bankApps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@launch&lt;/span&gt;
        &lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sbn&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;A WhatsApp message is dropped before its text is ever touched. I considered auto-detecting finance apps instead, but an explicit list is easier to explain and easier to trust.&lt;/p&gt;

&lt;p&gt;Then a few cheap filters: group summaries repeat their children, and ongoing, foreground-service and progress notifications are status, not events. All of those are skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One-time codes are never stored or sent
&lt;/h2&gt;

&lt;p&gt;Banking apps send OTPs through the same channel as payment alerts, and an OTP is the most sensitive thing a phone receives. Those are dropped on the device, before anything is saved.&lt;/p&gt;

&lt;p&gt;The naive check ("does it contain 'OTP'?") is wrong in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lots of real &lt;strong&gt;payment&lt;/strong&gt; alerts end with "Never share your OTP with anyone." A keyword check throws those away.&lt;/li&gt;
&lt;li&gt;"Rs 5000" is a 4-digit number, but it's an amount, not a code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a message counts as a one-time code only when both are true: it names a code (OTP, verification code, MPIN‚Ä¶) &lt;strong&gt;outside&lt;/strong&gt; a safety reminder, and it contains a bare 4‚Äì8 digit number that isn't preceded by a currency mark.&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;isOneTimeCode&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="nc"&gt;String&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lower&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="nf"&gt;lowercase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ROOT&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;withoutReminders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;REMINDER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// "never share your otp‚Ä¶"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nc"&gt;CODE_WORD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;containsMatchIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;withoutReminders&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;BARE_NUMBER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;before&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lower&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="nf"&gt;maxOf&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;number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;CURRENCY_BEFORE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;containsMatchIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// "rs 5000" is money&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;It's all pure functions, so every real notification that ever confused it became a unit test.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Mask account numbers before anything leaves
&lt;/h2&gt;

&lt;p&gt;The text of a payment alert does need to be read by a language model: bank formats vary far too much for regexes alone. But the model doesn't need your card number. Card, account, IBAN and phone numbers are masked on the phone first, keeping the last four digits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before: Rs 2,000 sent from a/c 01234567890123 to 0300 1234567
After:  Rs 2,000 sent from a/c ****0123 to 0300 1234567
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait, the phone number survived. That's the interesting part: the rules have to mask long identifiers &lt;em&gt;without&lt;/em&gt; masking amounts, dates or times. What I settled on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;9 or more digits written together ‚Üí masked&lt;/li&gt;
&lt;li&gt;12 or more digits in groups of 3+ separated by spaces or dashes (&lt;code&gt;4111 1111 1111 1234&lt;/code&gt;) ‚Üí masked&lt;/li&gt;
&lt;li&gt;IBAN-shaped strings ‚Üí masked&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1,250,000&lt;/code&gt; (commas) and &lt;code&gt;15-09-2026 14:32&lt;/code&gt; (short groups) ‚Üí left alone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A local 11-digit phone number written as &lt;code&gt;0300 1234567&lt;/code&gt; is two groups, so it isn't caught by the grouped rule. Written as &lt;code&gt;03001234567&lt;/code&gt; it is. That's a known gap I'd rather tell you about than hide. Every rule is a test case, and each new bank format adds more.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Don't let the model guess what it can't know
&lt;/h2&gt;

&lt;p&gt;The model gets the masked text plus the user's categories, and returns structured JSON: amount, merchant, category, date. That part works well.&lt;/p&gt;

&lt;p&gt;What went wrong was currency. A real alert read "You spent Rs. 5,930.18" and was recorded as &lt;strong&gt;$62.00&lt;/strong&gt; in a USD list. The model had been asked which currency "Rs" was, and it said INR. The correct answer was Pakistani rupees: $21.40.&lt;/p&gt;

&lt;p&gt;"Rs" is written by Pakistan, India, Sri Lanka and Nepal. The model was reading the same two characters I was, with no extra information. Its answer &lt;em&gt;looked&lt;/em&gt; like knowledge and was really a coin flip. I'd even written a function that refused to trust it, and then the very next line fell back to the model's answer anyway.&lt;/p&gt;

&lt;p&gt;The lesson I took: &lt;strong&gt;a model's answer to an ambiguous question isn't evidence.&lt;/strong&gt; Tie-breakers have to come from something that actually knows. For a rupee, that's the list's own currency, then the SIM's country (not the phone's language: my phone runs English on a Pakistani SIM). And if nothing knows, ask the user rather than guess.&lt;/p&gt;

&lt;p&gt;For now I've gone further and switched conversion off: Onetap records the amount the alert states, in the list's currency, and you keep a rupee bank on a rupee list. The cleaner fix, which I haven't built yet, is binding each bank app to its own list, so conversion is never needed in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Never lose a payment
&lt;/h2&gt;

&lt;p&gt;A notification shows up once. If the app drops it, that payment is gone from the record and the user won't know. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write to disk first.&lt;/strong&gt; Each kept notification goes into an encrypted inbox before any network call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry with WorkManager.&lt;/strong&gt; No network in the metro? The entry waits, and a worker tries again when connectivity returns, however long that takes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;De-duplicate with a fingerprint.&lt;/strong&gt; Banks sometimes post the same alert twice, and retries can overlap. A SHA-256 of the package, the normalised words and the post time means the same payment is never recorded twice, even if the process dies halfway through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch up on reconnect.&lt;/strong&gt; While the listener isn't bound, nothing is heard. When it reconnects, it reads the bank notifications still sitting in the shade from the last three days, skipping ones it has already handled.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. The phone that kills your listener
&lt;/h2&gt;

&lt;p&gt;On my Xiaomi (HyperOS), the first real test failed silently. From logcat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Swiping the app away from recents &lt;strong&gt;kills the process&lt;/strong&gt;, listener included.&lt;/li&gt;
&lt;li&gt;With "Autostart" off, the system &lt;strong&gt;refuses to rebind&lt;/strong&gt; the notification listener, so nothing is heard until the user opens the app again.&lt;/li&gt;
&lt;li&gt;After an app &lt;strong&gt;update&lt;/strong&gt;, the listener can stay unbound even though access is still granted. Calling &lt;code&gt;requestRebind()&lt;/code&gt; and toggling the listener component's enabled state brings it back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Onetap now asks for Autostart only on Xiaomi, Redmi and POCO phones, inside the bank-automation setup, with an explanation of why. With it on, a real wallet payment of Rs 1 was recorded 1.2 seconds after its notification arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it without spending money
&lt;/h2&gt;

&lt;p&gt;You don't want to make a real payment for every test. Debug builds also accept notifications posted from the shell:&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 cmd notification post &lt;span class="nt"&gt;-S&lt;/span&gt; bigtext &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"HBL"&lt;/span&gt; test-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"PKR 1,250.00 debited from a/c 01234567890123 at FOODPANDA on 06-10-2026"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These come from &lt;code&gt;com.android.shell&lt;/code&gt;, which the debug build treats as a bank app (never in release). That gave me a repeatable way to run real-looking bank text, OTPs and odd formats through the whole pipeline on a real phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Filter by package &lt;strong&gt;before&lt;/strong&gt; reading content. It's the difference between "reads my bank alerts" and "reads my messages".&lt;/li&gt;
&lt;li&gt;Decide what must never leave the device, and enforce it in pure, tested functions.&lt;/li&gt;
&lt;li&gt;Treat model output as a suggestion, not a fact, wherever the input is ambiguous.&lt;/li&gt;
&lt;li&gt;Assume Android will kill you, and write things down before doing anything slow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Onetap is on &lt;a href="https://play.google.com/store/apps/details?id=org.truetechsol.onetap" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; with a 3-day free trial if you want to try it with your own bank. If yours sends a format it misreads, I'd really like to see it (with the numbers removed).&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>5 Android bugs my tests didn't catch while shipping an expense tracker</title>
      <dc:creator>Rao Asad</dc:creator>
      <pubDate>Tue, 06 Oct 2026 10:58:27 +0000</pubDate>
      <link>https://dev.to/rao_asad_fb657aebdb761ad4/5-android-bugs-my-tests-didnt-catch-while-shipping-an-expense-tracker-131h</link>
      <guid>https://dev.to/rao_asad_fb657aebdb761ad4/5-android-bugs-my-tests-didnt-catch-while-shipping-an-expense-tracker-131h</guid>
      <description>&lt;p&gt;I've spent the last few months building and shipping &lt;a href="https://coinly.truetechsol.org" rel="noopener noreferrer"&gt;Coinly&lt;/a&gt;, an expense tracker for Android. It's a normal Kotlin app: MVVM, coroutines and Flow, Room, Gson, R8 in release.&lt;/p&gt;

&lt;p&gt;It also had a reasonable test suite. None of these five bugs failed a single test. Every one of them reached a real build, and a couple of them reached real users.&lt;/p&gt;

&lt;p&gt;Here they are, with the fix for each, in case they save you an evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Gson + R8: fields that silently come back as defaults
&lt;/h2&gt;

&lt;p&gt;Exchange rates stopped working in the first release build. No crash, no error. Rates just came back empty, and Drive backup quietly decided there was no existing backup to restore.&lt;/p&gt;

&lt;p&gt;The cause: when a field has no &lt;code&gt;@SerializedName&lt;/code&gt;, Gson matches JSON keys &lt;strong&gt;by field name&lt;/strong&gt;. R8 renames fields in release builds. So the JSON key &lt;code&gt;rates&lt;/code&gt; no longer matched a field called &lt;code&gt;rates&lt;/code&gt;, because that field was now called &lt;code&gt;a&lt;/code&gt;. Gson didn't complain. It left the field at its default.&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;// Works in debug, silently broken in release&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;RatesResponse&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;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&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;rates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&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;,&lt;/span&gt; &lt;span class="nc"&gt;Double&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;emptyMap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Works in both&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;RatesResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@SerializedName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"result"&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;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@SerializedName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rates"&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;rates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&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;,&lt;/span&gt; &lt;span class="nc"&gt;Double&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;emptyMap&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;Two things made this nasty:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debug builds aren't minified&lt;/strong&gt;, so every test on my phone passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default values turn a crash into a wrong answer.&lt;/strong&gt; &lt;code&gt;emptyList()&lt;/code&gt; for "files in Drive" reads as "no backup exists", so a release build would have happily written a second backup next to the one it couldn't see.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;-keep&lt;/code&gt; rule alone isn't enough either: it stops the class from being removed, but its fields can still be renamed. I now put &lt;code&gt;@SerializedName&lt;/code&gt; on every field of every Gson class, including the ones whose Kotlin name already matches the key. Those are exactly the ones people leave bare.&lt;/p&gt;

&lt;p&gt;The general lesson: a release smoke test that checks "the app opens and the screens render" proves very little. R8 breaks data paths, not layouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;StateFlow.value&lt;/code&gt; that is forever the initial value
&lt;/h2&gt;

&lt;p&gt;This one showed up three separate times.&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;val&lt;/span&gt; &lt;span class="py"&gt;history&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Period&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="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observeHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;viewModelScope&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="mi"&gt;5_000&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="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onDeleteClicked&lt;/span&gt;&lt;span class="p"&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;history&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="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nf"&gt;showTwoWayDialog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;showSimpleDialog&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;WhileSubscribed&lt;/code&gt; only starts the upstream flow when something collects it. Nothing collected &lt;code&gt;history&lt;/code&gt;. It was only ever read as &lt;code&gt;.value&lt;/code&gt; from a click handler. So &lt;code&gt;.value&lt;/code&gt; was always &lt;code&gt;emptyList()&lt;/code&gt;, and a budget with three months of history reported "no history".&lt;/p&gt;

&lt;p&gt;The worst instance was on the Delete Account dialog: a user who was connected to Google Drive was told "you're not connected, so nothing is stored outside this device". The deletion itself was correct; the sentence was false.&lt;/p&gt;

&lt;p&gt;The rule I use now: &lt;strong&gt;if a StateFlow is only read through &lt;code&gt;.value&lt;/code&gt; and never collected, it must be &lt;code&gt;SharingStarted.Eagerly&lt;/code&gt;.&lt;/strong&gt; To audit, grep every &lt;code&gt;stateIn(... WhileSubscribed ...)&lt;/code&gt; for &lt;code&gt;.collect&lt;/code&gt;. If there are zero collectors and any &lt;code&gt;.value&lt;/code&gt; reads, that's the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The splash screen that hung forever
&lt;/h2&gt;

&lt;p&gt;For my first five releases, Coinly could get stuck on its splash screen until you force-stopped it. I never saw it in testing.&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;// ViewModel&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;_destination&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MutableSharedFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// replay = 0&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;destination&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asSharedFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Activity&lt;/span&gt;
&lt;span class="n"&gt;lifecycleScope&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="nf"&gt;repeatOnLifecycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STARTED&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;navigate&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="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;&lt;code&gt;repeatOnLifecycle(STARTED)&lt;/code&gt; cancels the collector whenever the activity is stopped. A &lt;code&gt;SharedFlow&lt;/code&gt; with &lt;code&gt;replay = 0&lt;/code&gt; drops anything it can't deliver right now. So if the ViewModel decided where to go while the app wasn't in the foreground, the event was gone, and the splash waited for a second event that never came.&lt;/p&gt;

&lt;p&gt;That sounds rare until you notice how often it happens: tapping a notification on a locked phone, opening the app during a call, switching away during the splash animation. Coinly's daily reminder opens the splash screen, so this was on a real path.&lt;/p&gt;

&lt;p&gt;You can reproduce it deterministically with adb:&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 input keyevent 26        &lt;span class="c"&gt;# screen off&lt;/span&gt;
adb shell am start &lt;span class="nt"&gt;-n&lt;/span&gt; com.example/.SplashActivity
&lt;span class="nb"&gt;sleep &lt;/span&gt;8                            &lt;span class="c"&gt;# the ViewModel emits with no collector&lt;/span&gt;
adb shell input keyevent 224       &lt;span class="c"&gt;# wake, then unlock and look&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is one argument: &lt;code&gt;MutableSharedFlow&amp;lt;Destination&amp;gt;(replay = 1)&lt;/code&gt;. A late collector still gets the value, and since the splash finishes itself after navigating, there's nothing to re-navigate. Leave a comment next to it, because &lt;code&gt;replay = 1&lt;/code&gt; on a "one-shot event" looks wrong and someone will revert it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The in-app language picker that worked everywhere except Google Play
&lt;/h2&gt;

&lt;p&gt;Coinly ships in English, Spanish, Portuguese and Indonesian, with a language picker inside the app. On my test phone it worked perfectly. For people who installed from Play and picked a language other than their phone's, it showed English text with, say, Spanish number formatting.&lt;/p&gt;

&lt;p&gt;The reason: with an Android App Bundle, &lt;strong&gt;Play only installs the language resources that match the phone's system languages&lt;/strong&gt;. My test installs were APKs, which carry every language, so the bug couldn't happen on my device.&lt;/p&gt;

&lt;p&gt;The fix is in &lt;code&gt;build.gradle.kts&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="nf"&gt;android&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;bundle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;language&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// The app has its own language picker, so every language must be installed.&lt;/span&gt;
            &lt;span class="n"&gt;enableSplit&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="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 alternative is downloading languages on demand with Play Core. I didn't find this bug by testing. Lint found it (&lt;code&gt;AppBundleLocaleChanges&lt;/code&gt;). If you have an in-app language picker, check your bundle config today.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A crash on a device nobody owns
&lt;/h2&gt;

&lt;p&gt;Crashlytics reported an &lt;code&gt;InflateException&lt;/code&gt; on the onboarding screen, caused by &lt;code&gt;NoSuchMethodError&lt;/code&gt; in Material's &lt;code&gt;FocusRingDrawable&lt;/code&gt;. The device was &lt;code&gt;sdk_gphone_arm64&lt;/code&gt; on Android 11.&lt;/p&gt;

&lt;p&gt;That's not a user. It's Google Play's pre-launch test robot, which crawls every new release. It loads its own un-shrunk copy of Material inside your app's process, and R8 had renamed a method that the robot's copy expected by its original name. Real users weren't affected, but a crash in pre-launch reports is noise you don't want, and it can hide a real one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-keep class com.google.android.material.focus.** { *; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I got the same crash in my second app a few days later, from a slider this time, and widened the rule for that app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in how I work
&lt;/h2&gt;

&lt;p&gt;None of these were exotic. They were all "the build I test isn't the build people install":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test a minified release build&lt;/strong&gt;, not just debug, and exercise the network and storage paths, not just the screens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install from Play&lt;/strong&gt; (internal testing track) at least once per release. APKs hide bundle problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with the app not in the foreground&lt;/strong&gt;: locked screen, notification taps, switching away mid-launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read Crashlytics device names&lt;/strong&gt; before panicking. &lt;code&gt;sdk_gphone&lt;/code&gt; is a robot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coinly is free on &lt;a href="https://play.google.com/store/apps/details?id=org.truetechsol.coinly" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; if you want to see the app these came out of. I'd like to hear the bug your tests missed.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>debugging</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
