<?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: Codnet Software Company</title>
    <description>The latest articles on DEV Community by Codnet Software Company (@codnet_softwarecompany_7).</description>
    <link>https://dev.to/codnet_softwarecompany_7</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%2F4092661%2Fd519997b-0214-4744-967b-31dd840f36c0.png</url>
      <title>DEV Community: Codnet Software Company</title>
      <link>https://dev.to/codnet_softwarecompany_7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/codnet_softwarecompany_7"/>
    <language>en</language>
    <item>
      <title>Why Android's scrolling screenshot is greyed out in the app you actually need it in</title>
      <dc:creator>Codnet Software Company</dc:creator>
      <pubDate>Sat, 05 Sep 2026 00:51:25 +0000</pubDate>
      <link>https://dev.to/codnet_softwarecompany_7/why-androids-scrolling-screenshot-is-greyed-out-in-the-app-you-actually-need-it-in-1n9p</link>
      <guid>https://dev.to/codnet_softwarecompany_7/why-androids-scrolling-screenshot-is-greyed-out-in-the-app-you-actually-need-it-in-1n9p</guid>
      <description>&lt;p&gt;If you have tried to take a scrolling screenshot on Android, you have probably had this happen: it works in Chrome, it works in Settings, and then you open the one app where you actually need it and the &lt;strong&gt;Capture more&lt;/strong&gt; button is not there.&lt;/p&gt;

&lt;p&gt;That is not a bug in your phone. It is a consequence of how the feature is implemented, and it is worth understanding because it tells you exactly when to expect it to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the built-in one first
&lt;/h2&gt;

&lt;p&gt;Genuinely - if this works for your case, stop here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Samsung&lt;/strong&gt;: take a normal screenshot, then tap &lt;strong&gt;Capture more&lt;/strong&gt; in the toolbar that appears. It has to be enabled in Settings &amp;gt; Advanced features &amp;gt; Screenshots and screen recorder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Xiaomi / MIUI&lt;/strong&gt;: screenshot, tap the thumbnail, then &lt;strong&gt;Scroll&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OnePlus / OxygenOS&lt;/strong&gt;: screenshot, then the expand arrow on the preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome&lt;/strong&gt;: screenshot, then &lt;strong&gt;Capture full page&lt;/strong&gt; in the preview - this one is Chrome's own and works on any Android.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android 12+ stock&lt;/strong&gt;: screenshot, then &lt;strong&gt;Capture more&lt;/strong&gt; if it appears.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a web page in Chrome, the built-in path is usually the better answer. It captures the page as the browser knows it, not as pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it disappears
&lt;/h2&gt;

&lt;p&gt;The platform feature is not a screen recorder. It asks the app's own view hierarchy to scroll and render more of itself, which means the app has to implement the right hooks - a &lt;code&gt;ScrollCaptureCallback&lt;/code&gt; on Android 12+, or on Samsung the view has to be one MIUI/One UI knows how to drive.&lt;/p&gt;

&lt;p&gt;Which apps do not? Anything drawing its own surface. Feeds built on custom rendering, most chat apps, anything backed by a &lt;code&gt;SurfaceView&lt;/code&gt; or &lt;code&gt;TextureView&lt;/code&gt;, most Flutter and React Native screens, WebViews inside apps, and anything the manufacturer has not specifically wired up.&lt;/p&gt;

&lt;p&gt;So the rule of thumb is: the more custom the UI, the less likely you get the button. Which is exactly backwards from where people want it - nobody needs a scrolling screenshot of the Settings app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the alternative approach costs
&lt;/h2&gt;

&lt;p&gt;The other way is to record the screen while you scroll and stitch the frames back into one image. That works in any app, because it operates on pixels and never asks the app for anything.&lt;/p&gt;

&lt;p&gt;It also inherits every problem of doing it from the outside:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You have to scroll yourself.&lt;/strong&gt; An app could scroll for you, but only by holding an AccessibilityService - and that same permission lets it read the content of every screen you open. That is a large thing to hand over for a screenshot tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human thumb is not a machine.&lt;/strong&gt; It accelerates, overshoots, and drifts a pixel or two while it is supposed to be still. Every one of those breaks a different assumption in the stitching, and getting them right is most of the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moving content is genuinely hard.&lt;/strong&gt; A playing video keeps moving while you scroll past it, so its area gets read a few rows at a time from different moments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshot-blocked apps still come out black.&lt;/strong&gt; Banking apps and DRM video are blocked by Android at the compositor. No approach on either side gets around that, and you should be suspicious of one that claims to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I build one of these: &lt;a href="https://play.google.com/store/apps/details?id=com.codnet.longshot" rel="noopener noreferrer"&gt;LongShot&lt;/a&gt;, free on Google Play.&lt;/p&gt;

&lt;p&gt;The specific trade-off it makes is the permission one. It uses MediaProjection - the ordinary screen-share API - and never asks for accessibility access, so it cannot scroll for you. In exchange it has &lt;strong&gt;no INTERNET permission at all&lt;/strong&gt;, and that is checkable rather than promised: it is stripped from the merged manifest, so the process cannot open a socket. Run &lt;code&gt;aapt dump permissions&lt;/code&gt; on the APK and you will see four permissions and no INTERNET.&lt;/p&gt;

&lt;p&gt;That constraint decides the rest of the design. Text recognition (copy text out of a capture, or auto-cover emails, phone numbers, card numbers and IBANs before sharing) runs on a model bundled in the app, because there is no service it could call.&lt;/p&gt;

&lt;p&gt;Where it falls short, from the app's own About screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pause any playing video before you capture.&lt;/strong&gt; Moving content can come out stitched from several moments. Measured and located; not yet fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text recognition is Latin script only.&lt;/strong&gt; Arabic and other non-Latin text is not detected, so redaction there has to be done by hand with the blur tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard fling can outrun the capture.&lt;/strong&gt; Steady scrolling is more reliable than fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshot-blocked apps come out black.&lt;/strong&gt; As above - that is Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the built-in button is there and works, use it. It is faster and it is lossless. The stitching approach is for the case where the button is missing, which - if you have read this far - is probably why you are here.&lt;/p&gt;

</description>
      <category>android</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Your test harness can silently not be testing the thing you think it is</title>
      <dc:creator>Codnet Software Company</dc:creator>
      <pubDate>Sat, 05 Sep 2026 00:34:27 +0000</pubDate>
      <link>https://dev.to/codnet_softwarecompany_7/your-test-harness-can-silently-not-be-testing-the-thing-you-think-it-is-26en</link>
      <guid>https://dev.to/codnet_softwarecompany_7/your-test-harness-can-silently-not-be-testing-the-thing-you-think-it-is-26en</guid>
      <description>&lt;p&gt;I spent a week fixing a stitching bug in an Android app, and the single most expensive thing was not the algorithm. It was that my offline test harness was quietly not exercising the code I was changing.&lt;/p&gt;

&lt;p&gt;Here is the shape of it, because I doubt it is rare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The app records a scroll and joins the frames into one tall image. To iterate on that without re-shooting captures by hand, debug builds write a journal of every frame, and a test replays the journal through the same engine offline. Same input, same code, repeatable.&lt;/p&gt;

&lt;p&gt;That harness is the whole basis for every claim I make about the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways it lied
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. It never called the guard it was grading.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The engine has a pass that rewrites a video area from one frame so it does not come out as a collage of moments. The call existed in one test function and not in the one I was using. So every measurement of video tearing I had taken was of the engine with that guard switched off.&lt;/p&gt;

&lt;p&gt;That is not a small discrepancy. It meant a change to that guard could not move the number in either direction, which is exactly what happened: I made a fix, measured no effect, and concluded the fix was wrong. It was not tested at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. One missing callback made recovery impossible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Production wires three callbacks into the engine. The replay wired two. The missing one reports how far down the page the canvas currently reaches - and a guard deep in the relock path reads it. With it null, that guard can never be true, so a chain that loses its place in the replay can never recover.&lt;/p&gt;

&lt;p&gt;Same 908-frame journal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 before   after   real device
output rows       2464    13305      13720
writes               7       99          -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. The harness had been reproducing a crippled engine and I had been reading its output as production behaviour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. I pasted a call one nesting level too deep.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After fixing the above I added the guard call by line number and it landed inside &lt;code&gt;if (repairs &amp;lt;= 40) { ... }&lt;/code&gt;, itself inside another conditional. It ran only when an unrelated repair had just succeeded - which never happens on the journal I was testing. The counter read zero, and I published the conclusion 'this guard never fires'. It fires four times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why none of these looked like failures
&lt;/h2&gt;

&lt;p&gt;A broken measurement does not raise an error. It returns a number, and the number is usually plausible.&lt;/p&gt;

&lt;p&gt;Worse, the failure mode is biased. An empty output reads as 'nothing found'. A zero counter reads as 'no effect'. A skipped task reads as 'no change since last time'. All three are indistinguishable from the most common real result, which is that your change did nothing.&lt;/p&gt;

&lt;p&gt;In one week I hit six of these. A grep pattern that missed indented output. A build task skipped as up-to-date, so I compared against a run that never happened. A template calibrated on one recording and applied to another, matching 77% of the page and reporting no difference. A &lt;code&gt;-D&lt;/code&gt; flag the build never forwarded to the test JVM. Launching the wrong package on the phone, because the debug build installs alongside the store build under a different application id. And the nesting mistake above.&lt;/p&gt;

&lt;p&gt;Three of them produced a diagnosis I acted on and had to undo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prove the instrument can move before you trust it.&lt;/strong&gt; Feed it a case where it MUST report non-zero. If it cannot produce a positive, its negative is worthless.&lt;/p&gt;

&lt;p&gt;This is what finally caught the harness problem. My tearing metric said all three recordings were clean. Instead of accepting it I checked whether it could report a tear at all - and the check failed, which is how I found the guard was never being called.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the wiring matches production, not just that the test passes.&lt;/strong&gt; A green suite proves the code compiles and the assertions hold. It says nothing about whether the code path you care about was reached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty output is a question, not an answer.&lt;/strong&gt; Every time this bit me, the tell was output that was emptier than it should have been - and I read it as a result instead of asking whether the tool had run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half
&lt;/h2&gt;

&lt;p&gt;The same week taught me a related one: an optimisation measured only where the system already works tells you nothing.&lt;/p&gt;

&lt;p&gt;I sampled every second pixel in a hot loop. On the reference recording the worst frame halved and detection was unaffected, so I shipped it. Re-run on the recording where detection was already weak, it fired twice as many repairs while landing fewer rows, and the worst frame nearly tripled. The gain was specific to one recording; the cost was general.&lt;/p&gt;

&lt;p&gt;Reverted, with the numbers written into the source above the function so the next person to have that idea finds the measurement instead of repeating the week.&lt;/p&gt;




&lt;p&gt;The app is &lt;a href="https://play.google.com/store/apps/details?id=com.codnet.longshot" rel="noopener noreferrer"&gt;LongShot&lt;/a&gt; - free, no ads, and no INTERNET permission, which is checkable rather than promised: it is stripped from the merged manifest, so the process cannot open a socket.&lt;/p&gt;

&lt;p&gt;Known limitation, stated in the app itself: pause any playing video before capturing. Moving content is read a few rows at a time as you scroll past it, so it can come out stitched from several moments. That one is measured and located, and not yet fixed.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>android</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why capturing a long screenshot on Android is harder than it looks</title>
      <dc:creator>Codnet Software Company</dc:creator>
      <pubDate>Sat, 29 Aug 2026 13:55:21 +0000</pubDate>
      <link>https://dev.to/codnet_softwarecompany_7/why-capturing-a-long-screenshot-on-android-is-harder-than-it-looks-575d</link>
      <guid>https://dev.to/codnet_softwarecompany_7/why-capturing-a-long-screenshot-on-android-is-harder-than-it-looks-575d</guid>
      <description>&lt;p&gt;Every phone can screenshot what is on the screen. None of them, out of the box, can screenshot what is &lt;em&gt;below&lt;/em&gt; it — the rest of the chat, the rest of the article, the rest of the settings page you are trying to send to someone. You take five overlapping screenshots and apologise for the seams.&lt;/p&gt;

&lt;p&gt;I spent a while building an app that does the whole scroll in one image — &lt;strong&gt;LongShot&lt;/strong&gt; — and most of that time went not into the feature people ask for but into the failure modes nobody sees. Here is what actually makes this hard, and how the app handles each part. No framework does this for you; you assemble it out of parts that were built for other jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no "capture the scroll" API
&lt;/h2&gt;

&lt;p&gt;The first instinct is to look for a system call that returns a tall bitmap. There isn't one. What Android gives a normal app is &lt;code&gt;MediaProjection&lt;/code&gt; — the same API a screen recorder uses. You get frames of &lt;em&gt;what is currently visible&lt;/em&gt;, at the moment it is visible, and nothing else.&lt;/p&gt;

&lt;p&gt;So a scrolling screenshot is not a capture. It is a &lt;strong&gt;reconstruction&lt;/strong&gt;: record the screen while the user scrolls, then stitch the frames back into the single tall image they would have formed if the screen were long enough to hold them.&lt;/p&gt;

&lt;p&gt;The deliberate cost of using MediaProjection instead of an AccessibilityService — which &lt;em&gt;can&lt;/em&gt; read and drive other apps — is that the app cannot scroll for you. You scroll; it records. That trade is the whole reason the app can honestly say it never reads the content of your other apps: it has no mechanism to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stitch is the entire problem
&lt;/h2&gt;

&lt;p&gt;Say the user scrolls through a chat. You have thirty frames, each 1080×2340, overlapping by some unknown amount because a human thumb does not move at a constant speed. Stitching them means, for every new frame, answering one question: &lt;strong&gt;how far down has the content moved since the last frame I kept?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Get it right and the frames weld into one seamless column. Get it wrong by four pixels and you either duplicate a band of content or drop one, and the result has a visible tear or a line of text that appears twice.&lt;/p&gt;

&lt;p&gt;The naive approach — template-match the whole frame against the previous one — falls apart the moment anything on screen moves independently of the scroll: a playing video, an animated sticker, a "typing…" indicator. Those regions match at the wrong offset and drag the whole estimate with them. The capture that looks hardest (a feed full of autoplaying video) is exactly the one a whole-frame match handles worst.&lt;/p&gt;

&lt;p&gt;What works is &lt;strong&gt;consensus over rigid slices&lt;/strong&gt;: cut each frame into horizontal strips, ask each strip independently how far it moved, and trust the offset the majority of strips agree on rather than any single region. A video playing in the middle of the screen becomes one dissenting slice outvoted by the static ones around it. The text keeps its place; the video's private motion is ignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory is the quiet killer
&lt;/h2&gt;

&lt;p&gt;A full capture of a long chat is routinely 1080×18660 pixels. At 4 bytes a pixel that is ~80 MB for one bitmap — and while you are building it you are also holding the frame buffer, the display copy, and whatever the editor needs. Decode one thing carelessly and a 2 GB device is out of memory before it finishes.&lt;/p&gt;

&lt;p&gt;Two things keep it alive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The finished image is never held whole. It is streamed to a PNG on disk row by row, so an arbitrarily long capture saves at full resolution without ever existing as a single in-memory bitmap.&lt;/li&gt;
&lt;li&gt;Anything that decodes a saved capture back — for a thumbnail, for text recognition — does it a &lt;strong&gt;region at a time&lt;/strong&gt; with subsampling, so the cost is bounded by the tile, not by how tall the capture happens to be.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I learned the byte-vs-pixel distinction the hard way: a memory ceiling written in pixels while the actual cost is bytes will pass every test on short captures and OOM on the long ones users actually take.&lt;/p&gt;

&lt;h2&gt;
  
  
  The on-device extras, and one honest limit
&lt;/h2&gt;

&lt;p&gt;Once the pixels are on the device and staying there, a few things follow for free — because "no network" is not a slogan here, it is enforced: the app declares no &lt;code&gt;INTERNET&lt;/code&gt; permission at all, and both &lt;code&gt;INTERNET&lt;/code&gt; and &lt;code&gt;ACCESS_NETWORK_STATE&lt;/code&gt; are stripped from the merged manifest, including the copies the bundled ML Kit recogniser would otherwise pull in. The operating system refuses any socket, so nothing recognised from your screenshot can leave the device even if a library tried. You do not have to trust me on that — run &lt;code&gt;aapt dump permissions&lt;/code&gt; on the APK.&lt;/p&gt;

&lt;p&gt;On top of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copy text&lt;/strong&gt; lifts the words out of a capture with on-device recognition, in reading order — banded into lines, sorted within each band, paragraph breaks inferred from the gaps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hide personal data&lt;/strong&gt; finds card numbers (Luhn-verified), IBANs (mod-97-verified), emails and phone numbers, and paints them out — genuinely out, not blurred, so the covered pixels are gone from the exported file rather than merely scrambled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text, in 13 typefaces&lt;/strong&gt; — 7 Arabic and 6 Latin — with free rotation and resizing, for annotating over the shot.&lt;/li&gt;
&lt;li&gt;Crop, PDF/PNG export, share.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest limit: the text recognition is &lt;strong&gt;Latin script only&lt;/strong&gt;. ML Kit ships no Arabic model — the artifact literally does not exist — so Arabic and other non-Latin scripts are not read. The app says so on screen rather than returning a confident empty result. If you build on ML Kit and expected Arabic, that is the thing to know going in.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't look for a scroll-capture API; there isn't one. Reconstruct from &lt;code&gt;MediaProjection&lt;/code&gt; frames.&lt;/li&gt;
&lt;li&gt;Estimate scroll offset by &lt;strong&gt;consensus across rigid slices&lt;/strong&gt;, not whole-frame matching, or independently-moving content will wreck the alignment.&lt;/li&gt;
&lt;li&gt;Budget memory in &lt;strong&gt;bytes, not pixels&lt;/strong&gt;, and never hold the finished image whole.&lt;/li&gt;
&lt;li&gt;If your privacy story is "no network," enforce it by removing the permission, not by promising — and check the &lt;em&gt;merged&lt;/em&gt; manifest, because your dependencies vote on it too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LongShot is free on Google Play, no ads, no accounts, no analytics: &lt;strong&gt;&lt;a href="https://play.google.com/store/apps/details?id=com.codnet.longshot" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.codnet.longshot&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on any part of the stitch in the comments — the slice-consensus stage especially has more corners than fit here.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>Two features were dead in every release build for a month — and worked in every debug build</title>
      <dc:creator>Codnet Software Company</dc:creator>
      <pubDate>Thu, 27 Aug 2026 10:48:42 +0000</pubDate>
      <link>https://dev.to/codnet_softwarecompany_7/two-features-were-dead-in-every-release-build-for-a-month-and-worked-in-every-debug-build-9po</link>
      <guid>https://dev.to/codnet_softwarecompany_7/two-features-were-dead-in-every-release-build-for-a-month-and-worked-in-every-debug-build-9po</guid>
      <description>&lt;p&gt;LongShot is an Android app that stitches a scrolling screen into one long screenshot. Two of its tools run on-device text recognition: &lt;strong&gt;Copy text&lt;/strong&gt; pulls the text out of a capture, and &lt;strong&gt;Hide personal data&lt;/strong&gt; finds card numbers, IBANs and phone numbers and covers them.&lt;/p&gt;

&lt;p&gt;For a month, both were dead in every published build.&lt;/p&gt;

&lt;p&gt;Not crashed. Not slow. Dead in the quietest way available: you tapped the button, waited, and got&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Couldn't read this image.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No crash dialog. Nothing in Android Vitals — the app never crashed, so there was nothing to report. The crash rate for the entire period reads &lt;code&gt;–&lt;/code&gt;. Every user who tried the feature concluded the app couldn't handle &lt;em&gt;their&lt;/em&gt; screenshot.&lt;/p&gt;

&lt;p&gt;They were wrong. It could not handle any screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it took five rounds to find
&lt;/h2&gt;

&lt;p&gt;Because it worked. Every single time I tested it.&lt;/p&gt;

&lt;p&gt;I test on a device over ADB with &lt;code&gt;installDebug&lt;/code&gt;. The debug variant is not minified — that is the Android default, and I had never questioned it. So five rounds of &lt;em&gt;"fixed it, please try again"&lt;/em&gt; were five rounds of verifying a code path R8 had never touched.&lt;/p&gt;

&lt;p&gt;The false trails I burned first, in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory.&lt;/strong&gt; The captures are tall — 1080 × 18660 is ordinary. I assumed the recogniser was being handed a bitmap it could not allocate, and rewrote the scanner to read the image one tile at a time through &lt;code&gt;BitmapRegionDecoder&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A budget in the wrong unit.&lt;/strong&gt; While doing that I found a genuine bug: the scan budget was expressed in pixels while the cost is bytes. The code's own comment described the danger, then set the ceiling at exactly the dangerous value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fix that was a no-op.&lt;/strong&gt; I set &lt;code&gt;inPreferredConfig = RGB_565&lt;/code&gt; to halve the memory. Logging showed &lt;code&gt;decoded 1080x3404 ARGB_8888&lt;/code&gt;. &lt;code&gt;BitmapFactory&lt;/code&gt; ignores the preference for images with an alpha channel. My fix changed nothing — and I only knew because I had added the log line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bug I introduced myself.&lt;/strong&gt; &lt;code&gt;BitmapRegionDecoder&lt;/code&gt; reads from its &lt;code&gt;InputStream&lt;/code&gt; on &lt;em&gt;every&lt;/em&gt; &lt;code&gt;decodeRegion&lt;/code&gt; call, not only at construction. I wrapped the stream in &lt;code&gt;.use { }&lt;/code&gt;; every tile then failed. I verified the fix on a 2340-pixel capture — the one input short enough never to be tiled, and therefore the exact input that cannot exhibit the bug.&lt;/p&gt;

&lt;p&gt;All of that was real work. None of it was the cause.&lt;/p&gt;

&lt;p&gt;What cracked it was a question from the person reporting the bug:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this about the library not being included in the app when you export it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What R8 actually did
&lt;/h2&gt;

&lt;p&gt;ML Kit does not find its components through any call R8 can see. The merged manifest names them in a &lt;code&gt;&amp;lt;meta-data&amp;gt;&lt;/code&gt; tag, and a component runtime instantiates each one by reflection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta-data&lt;/span&gt;
    &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.google.mlkit.vision.text.internal.TextRegistrar"&lt;/span&gt;
    &lt;span class="na"&gt;android:value=&lt;/span&gt;&lt;span class="s"&gt;"com.google.firebase.components.ComponentRegistrar"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The library ships a consumer rule for exactly this case. Here it is, copied out of &lt;code&gt;configuration.txt&lt;/code&gt; in my own release build:&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 * implements com.google.firebase.components.ComponentRegistrar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rule keeps the class. It says nothing about the class's &lt;strong&gt;members&lt;/strong&gt; — and in ProGuard/R8 that is the entire difference. &lt;code&gt;-keep class X&lt;/code&gt; guarantees X survives shrinking. It guarantees nothing about what is inside X.&lt;/p&gt;

&lt;p&gt;So R8 kept &lt;code&gt;TextRegistrar&lt;/code&gt;, observed that nothing in the program ever calls its no-argument constructor — nothing does, in bytecode — and removed it. &lt;code&gt;usage.txt&lt;/code&gt;, R8's record of what it deleted, named it in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mlkit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;vision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TextRegistrar&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At runtime the component runtime asks for an instance, finds no constructor to invoke, initialisation throws, and &lt;code&gt;TextRecognition.getClient()&lt;/code&gt; fails. My code catches it:&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;recognizer&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="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="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;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TAG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"scan: text recogniser unavailable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&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;Result&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="mi"&gt;0&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— and the UI, having examined zero pixels, reports that it couldn't read the image. Which is true, and completely useless as a diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One line. It is the stock rule with a member specification added:&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 * implements com.google.firebase.components.ComponentRegistrar { &amp;lt;init&amp;gt;(); }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You do not have to take that on faith, and neither did I. Comment the line out,&lt;br&gt;
leaving the library's own rule in force, and rebuild:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;init&amp;gt;'&lt;/span&gt; app/build/outputs/mapping/debug/usage.txt   &lt;span class="c"&gt;# what R8 DELETED&lt;/span&gt;
com.google.mlkit.vision.text.internal.TextRegistrar:
    public void &amp;lt;init&amp;gt;&lt;span class="o"&gt;()&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;TextRegistrar app/build/outputs/mapping/debug/seeds.txt   &lt;span class="c"&gt;# what R8 KEPT&lt;/span&gt;
com.google.mlkit.vision.text.internal.TextRegistrar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line in &lt;code&gt;seeds.txt&lt;/code&gt;: the class, and nothing else. Put the rule back and&lt;br&gt;
build again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;TextRegistrar app/build/outputs/mapping/release/seeds.txt
com.google.mlkit.vision.text.internal.TextRegistrar
com.google.mlkit.vision.text.internal.TextRegistrar: TextRegistrar&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines: the class &lt;strong&gt;and its constructor&lt;/strong&gt;. Same source, same library, same&lt;br&gt;
stock rule present in both — the only variable is the member specification.&lt;/p&gt;

&lt;p&gt;In my build that single rule covers &lt;strong&gt;seven&lt;/strong&gt; registrars.&lt;/p&gt;
&lt;h3&gt;
  
  
  About the advice you will find instead
&lt;/h3&gt;

&lt;p&gt;Search for this problem and you will be told to write:&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.mlkit.** { *; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works. It also switches off shrinking and obfuscation across the entire ML Kit surface in order to save one constructor. The narrow rule is the same fix at a fraction of the cost — and it is not ML Kit-specific. It covers any &lt;code&gt;ComponentRegistrar&lt;/code&gt;, which is how the whole Firebase component system is discovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change that actually matters
&lt;/h2&gt;

&lt;p&gt;Fixing the rule fixes today's bug. It does nothing about the reason the bug lived for a month.&lt;/p&gt;

&lt;p&gt;That defect was in the build configuration:&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;debug&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Minified like release, on purpose. The text features were broken in every&lt;/span&gt;
    &lt;span class="c1"&gt;// published build and worked in every debug build, because R8 runs only in&lt;/span&gt;
    &lt;span class="c1"&gt;// release and strips a constructor ML Kit needs by reflection. A debug variant&lt;/span&gt;
    &lt;span class="c1"&gt;// that does not run R8 cannot reproduce the one class of bug that reaches users,&lt;/span&gt;
    &lt;span class="c1"&gt;// so this one does. It costs build time; it buys the ability to test what ships.&lt;/span&gt;
    &lt;span class="n"&gt;isMinifyEnabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    &lt;span class="nf"&gt;proguardFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;getDefaultProguardFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"proguard-android-optimize.txt"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"proguard-rules.pro"&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 debug variant that skips R8 cannot reproduce any R8 bug. That is not a small blind spot. Reflection-driven initialisation is exactly where release-only failures live, and it fails &lt;em&gt;silently&lt;/em&gt; rather than loudly — no crash, no stack trace, no Vitals entry, just a feature that quietly does nothing.&lt;/p&gt;

&lt;p&gt;Builds got slower. In exchange, the thing I test is the thing I ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Afterwards
&lt;/h2&gt;

&lt;p&gt;On a minified build, against a 1080 × 18660 capture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scan:     299 raw lines over 14 tiles (0 failed)
redact:   286 lines over 14 tiles (0 failed), 3 region(s), full-res
copyText: 286 lines over 14 tiles (0 failed), full-res
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero &lt;code&gt;text recogniser unavailable&lt;/code&gt;. 125 unit tests, 0 failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you ship ML Kit, Firebase, or anything with a ComponentRegistrar
&lt;/h2&gt;

&lt;p&gt;Three checks, all cheap, all on a build you have already made:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;grep '&amp;lt;init&amp;gt;' app/build/outputs/mapping/release/usage.txt&lt;/code&gt;&lt;/strong&gt; — anything reflection reaches whose constructor appears here is already broken in what you shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;grep Registrar app/build/outputs/mapping/release/seeds.txt&lt;/code&gt;&lt;/strong&gt; — every registrar should appear twice: once as a class, once as a constructor. Once means the constructor is gone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Turn on &lt;code&gt;isMinifyEnabled&lt;/code&gt; for debug and launch the app.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third one is the one that finds the &lt;em&gt;next&lt;/em&gt; bug instead of this one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;LongShot is free on Google Play. It captures with &lt;code&gt;MediaProjection&lt;/code&gt; rather than an AccessibilityService, so it cannot read or act inside other apps — the trade is that you scroll manually. It declares no INTERNET permission: &lt;code&gt;INTERNET&lt;/code&gt; and &lt;code&gt;ACCESS_NETWORK_STATE&lt;/code&gt; are removed from the merged manifest with &lt;code&gt;tools:node="remove"&lt;/code&gt;, including the copies ML Kit would otherwise merge in, so the operating system refuses any socket rather than the developer promising not to open one. You do not have to take my word for that one — run &lt;code&gt;aapt dump permissions&lt;/code&gt; on the APK.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why scrolling screenshots fall apart on video feeds</title>
      <dc:creator>Codnet Software Company</dc:creator>
      <pubDate>Mon, 24 Aug 2026 16:20:57 +0000</pubDate>
      <link>https://dev.to/codnet_softwarecompany_7/why-scrolling-screenshots-fall-apart-on-video-feeds-ad0</link>
      <guid>https://dev.to/codnet_softwarecompany_7/why-scrolling-screenshots-fall-apart-on-video-feeds-ad0</guid>
      <description>&lt;p&gt;I maintain a small Android app that stitches a scrolling screen into one long image. It worked well until people started pointing it at social feeds, and then users began reporting that captures "overlapped" around video posts.&lt;/p&gt;

&lt;p&gt;Chasing that report taught me it was two separate defects wearing one description, and that my measurement could only see one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement comes first
&lt;/h2&gt;

&lt;p&gt;Before changing anything I made the failure reproducible offline. Debug builds write every delivered frame's grayscale to a journal file, so a real session can be replayed against any candidate algorithm - the only honest way to compare two engines, since you can never reproduce a hand gesture twice.&lt;/p&gt;

&lt;p&gt;Then I needed ground truth that did not come from the engine. Matching consecutive journal frames directly, with no chaining and no accumulated estimate, gives the real scroll between them, and therefore the real page position of every frame. Now every row the engine commits can be checked: canvas rows increase, so the page rows they carry must increase too. A step backwards means content already emitted was emitted again.&lt;/p&gt;

&lt;p&gt;That audit is what let me stop guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defect one: the video captures the measurement
&lt;/h2&gt;

&lt;p&gt;A playing video is not noise. Uncorrelated noise costs the same at every candidate offset, so it cannot move the argmin - I built a noise fixture first and it passed happily while the real bug sat untouched. Real video is structured and its scene moves coherently, which plants a &lt;strong&gt;second minimum&lt;/strong&gt; in the offset landscape at the video's own motion.&lt;/p&gt;

&lt;p&gt;When the video covers more of the textured pixels than the page does, every majority-of-pixels metric prefers that minimum. Rows land at the wrong page address. On a synthetic feed with a coherently panning video the error compounded at exactly the video's pan rate, every single frame.&lt;/p&gt;

&lt;p&gt;The obvious fix is to exclude moving rows from the measurement. That fix has a trap in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inversion
&lt;/h2&gt;

&lt;p&gt;To know which rows are moving you compare the last two frames - but you have to compare them &lt;em&gt;at some offset&lt;/em&gt;, and the only offset available is the last one you accepted. So the test is anchored to the answer it is supposed to be checking.&lt;/p&gt;

&lt;p&gt;The moment that offset is wrong, the test &lt;strong&gt;inverts&lt;/strong&gt;. Under the video's inflated offset the video's rows line up perfectly and are judged still, while the real page's rows do not and are judged to be the thing that moves. The exclusion mask ends up protecting the noise and discarding the evidence.&lt;/p&gt;

&lt;p&gt;It cannot be repaired downstream either. I tried re-registering against the committed image - but that image was written by the same wrong chain, so it agrees with the error and confirms it. Measured: the trimmed cost was 73-89, flat, at every single offset. There was nothing left to appeal to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rigid slice consensus
&lt;/h2&gt;

&lt;p&gt;What worked was removing the anchor.&lt;/p&gt;

&lt;p&gt;Page content is a &lt;strong&gt;rigid body&lt;/strong&gt;. Every horizontal slice of it moves by the same offset and reproduces the previous frame almost exactly there. Whatever refuses to fit that one offset is moving by itself: a video, a GIF, an advert, a live chart.&lt;/p&gt;

&lt;p&gt;So: split the visible band into slices of about 128 px, score each against the offset the frame just produced, and read off which ones fit. If nothing fits at all, refuse the frame. If the winner explains almost nothing but a distant rival exists, score the rival too and let consensus - not average cost - pick between them.&lt;/p&gt;

&lt;p&gt;This needs no previous estimate, which is the entire point. A consensus cannot invert. At worst it finds too little agreement, and that is a refusal, not a wrong answer.&lt;/p&gt;

&lt;p&gt;Two details mattered more than the idea itself:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The consensus must never be fed back into verification.&lt;/strong&gt; I tried it. An offset that is allowed to excuse whatever it fails to explain will certify itself, and my fuzz harness immediately started putting solid rows on the wrong page. Verification has to use evidence that is independent of the offset under test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Excluding a slice and remembering it need different thresholds.&lt;/strong&gt; Excluding a slice from one frame is cheap and reversible. Recording a page region as animated steers every later frame and survives freezes, so it needs roughly double the evidence - otherwise a one-off entrance animation brands that address for the rest of the session.&lt;/p&gt;

&lt;p&gt;Results on the synthetic worst case - a coherently panning video over 76% of the viewport - went from 10, 30 and 2 misplaced rows across three seeds to 0, 1 and 11. On a real captured session, re-emitted rows went from 1056 to 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defect two: the one placement checks cannot see
&lt;/h2&gt;

&lt;p&gt;Meanwhile the user kept saying the video area still overlapped, and my audit kept reporting zero misplaced rows. We were both right.&lt;/p&gt;

&lt;p&gt;A media area's rows arrive over many frames. The video moves on between them. So the area ends up assembled from &lt;strong&gt;different instants&lt;/strong&gt; - the scene visibly cuts part-way down - while every row sits at exactly the page address it belongs to. Positional accuracy cannot fix it, because the addresses were never wrong. The moment was.&lt;/p&gt;

&lt;p&gt;You need a second measure entirely: for each animated page range, how many distinct source frames contributed. One is coherent. More is a tear. Adding that number to the audit was the first time the defect became visible as a number instead of as a complaint.&lt;/p&gt;

&lt;p&gt;The fix follows directly: stop stitching those rows. Once a media area is wholly on screen in a single frame, that frame holds a coherent picture of it, so write the whole area from that one frame, with static rows riding along on both sides so the canvas can prove the placement before anything lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build ground truth that does not run through the thing you are testing.&lt;/strong&gt; My audit and my engine shared a measurement for a while, so they agreed with each other while both were wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixtures can lie by being too kind.&lt;/strong&gt; The noise-video fixture passed. It felt like evidence. It was the absence of evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When two failures share one symptom, the user is usually reporting the one your instrument cannot see.&lt;/strong&gt; Three rounds of "still broken" against a clean audit should have told me the instrument was incomplete far sooner than it did.&lt;/p&gt;




&lt;p&gt;The app is &lt;a href="https://play.google.com/store/apps/details?id=com.codnet.longshot" rel="noopener noreferrer"&gt;LongShot&lt;/a&gt; - free, no ads, no accounts, and no INTERNET permission at all, which for a tool that reads your screen felt like the only defensible design. Text tools are Latin-script only and you do the scrolling yourself; there is deliberately no accessibility service.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>computervision</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
