<?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: Kordelis</title>
    <description>The latest articles on DEV Community by Kordelis (kordelis).</description>
    <link>https://dev.to/kordelis</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%2Forganization%2Fprofile_image%2F15545%2F170ff8e3-8d59-40a3-9b2d-ea78000e680d.png</url>
      <title>DEV Community: Kordelis</title>
      <link>https://dev.to/kordelis</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kordelis"/>
    <language>en</language>
    <item>
      <title>Two screens need the same warning. Where should the state live?</title>
      <dc:creator>Samya</dc:creator>
      <pubDate>Thu, 12 Nov 2026 06:39:03 +0000</pubDate>
      <link>https://dev.to/kordelis/two-screens-need-the-same-warning-where-should-the-state-live-84k</link>
      <guid>https://dev.to/kordelis/two-screens-need-the-same-warning-where-should-the-state-live-84k</guid>
      <description>&lt;p&gt;Imagine an Android app with two screens: an order history screen and an account screen. Both need to warn the user when an earlier order needs attention. From either screen, the user can open a dialog, dismiss it or start a recovery action.&lt;/p&gt;

&lt;p&gt;The first version might put the logic in the order history ViewModel. Then the account screen needs it too.&lt;/p&gt;

&lt;p&gt;Sharing the recovery use case helps. Both ViewModels can call the same function. But each screen may still need to decide when to show the warning, hold the dialog state and handle the user’s actions. That’s where the duplication starts to feel less trivial.&lt;/p&gt;

&lt;p&gt;One option is to extract that behaviour into a small presentation component that each ViewModel uses:&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;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderWarning&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderWarningState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;dismiss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;recover&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 component could use the shared domain logic while also managing the state needed to show the warning. Each screen can then decide how to expose that state to its UI.&lt;/p&gt;

&lt;p&gt;I like this approach when the behaviour is the same in both places. The ViewModels no longer need to repeat every detail of checking, dismissing and recovering. It also gives the warning behaviour a smaller surface to test.&lt;/p&gt;

&lt;p&gt;But extracting it doesn’t answer every question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does “dismiss” mean?
&lt;/h2&gt;

&lt;p&gt;Suppose the user dismisses the warning on the order history screen and then opens the account screen.&lt;/p&gt;

&lt;p&gt;Should it appear again?&lt;/p&gt;

&lt;p&gt;If each screen has its own warning component, each can hold its own dismissal state. If both screens use the same instance, dismissal can affect both. Either could be right, depending on the experience the app is trying to provide.&lt;/p&gt;

&lt;p&gt;I would separate that decision from the order’s actual status. Dismissing a dialog shouldn’t quietly mark the underlying order as recovered. One is a presentation choice; the other changes what the app knows about the order.&lt;/p&gt;

&lt;p&gt;There is a similar question around recovery. If both screens can start it, the shared domain or data layer needs an appropriate way to handle repeated or overlapping requests. A shared UI component alone doesn’t guarantee that the operation runs only once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about a single screen state?
&lt;/h2&gt;

&lt;p&gt;A ViewModel may already expose a &lt;code&gt;StateFlow&amp;lt;ScreenState&amp;gt;&lt;/code&gt;. Adding a separate &lt;code&gt;OrderWarning.state&lt;/code&gt; gives the UI another state source to observe.&lt;/p&gt;

&lt;p&gt;That isn’t automatically a problem. A small, self-contained warning can be easier to understand on its own. But if the screen already has a clear convention of exposing one state, combining the warning into that state may make the screen easier to follow.&lt;/p&gt;

&lt;p&gt;For example, the screen state could contain an &lt;code&gt;OrderWarningState&lt;/code&gt;, while screen events are forwarded to the warning component. There will still be some wiring in each ViewModel, but it can be a reasonable amount of repetition if it keeps the screen’s public interface clear.&lt;/p&gt;

&lt;p&gt;The other direction is to share a ViewModel at a level above both screens. That can make sense if the warning truly belongs to that shared part of the navigation flow. I’d be more cautious if the only reason for sharing the ViewModel is to avoid a few repeated lines: its scope and lifetime then become part of the decision too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’d look at first
&lt;/h2&gt;

&lt;p&gt;Before choosing a pattern, I’d write down the expected behaviour for a few cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dismiss the warning on one screen, then open the other.&lt;/li&gt;
&lt;li&gt;Start recovery and leave the screen before it finishes.&lt;/li&gt;
&lt;li&gt;Trigger recovery twice.&lt;/li&gt;
&lt;li&gt;Reopen the app while the order still needs attention.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those cases help distinguish the state that belongs to the screen from the order status that should be shared. They also make a useful starting point for tests.&lt;/p&gt;

&lt;p&gt;I wouldn’t extract a component just because two ViewModels call the same use case. But when they repeat the same presentation behaviour around that call, a small reusable component can be worth trying. I’d keep it if it makes the screens and their tests easier to understand—and reconsider it if following the behaviour means chasing through several layers.&lt;/p&gt;

</description>
      <category>android</category>
      <category>architecture</category>
      <category>kotlin</category>
      <category>mobile</category>
    </item>
    <item>
      <title>The upload started. Can the user leave the screen?</title>
      <dc:creator>Samya</dc:creator>
      <pubDate>Mon, 09 Nov 2026 04:59:47 +0000</pubDate>
      <link>https://dev.to/kordelis/the-upload-started-can-the-user-leave-the-screen-4kl8</link>
      <guid>https://dev.to/kordelis/the-upload-started-can-the-user-leave-the-screen-4kl8</guid>
      <description>&lt;p&gt;A user picks a photo, taps &lt;strong&gt;Upload&lt;/strong&gt;, and sees a progress bar.&lt;/p&gt;

&lt;p&gt;Then they switch to another app.&lt;/p&gt;

&lt;p&gt;When they come back, the upload is still sitting there. Did it stop? Is it waiting for a connection? Did the server receive the photo?&lt;/p&gt;

&lt;p&gt;The progress bar doesn’t tell them.&lt;/p&gt;

&lt;p&gt;I think this is an easy part of an upload flow to miss. We tend to test the happy path with the screen open: select a file, start the upload, watch it finish. But people don’t always wait patiently on that screen.&lt;/p&gt;

&lt;p&gt;Before choosing an Android API, I’d start with a simpler question: &lt;strong&gt;what should the person be able to do while the upload is happening?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Should the upload wait, or should it start now?
&lt;/h2&gt;

&lt;p&gt;Not every upload needs the same treatment.&lt;/p&gt;

&lt;p&gt;A small transfer that can wait for a connection has different needs from a large file the user just selected and expects to finish soon.&lt;/p&gt;

&lt;p&gt;For work that can be scheduled and retried, WorkManager is often a reasonable place to start. You can set conditions, such as network availability, and observe the work’s state when the user returns.&lt;/p&gt;

&lt;p&gt;For a longer transfer started by the user, where progress needs to stay visible and interruption would be disruptive, Android also has user-initiated data transfer jobs. They’re available from Android 14 and have their own scheduling and notification requirements.&lt;/p&gt;

&lt;p&gt;I wouldn’t switch every upload to a new API just because it’s available. Android’s guidance says to keep using WorkManager for short, interruptible transfers where it already fits. The useful choice depends on the transfer and the experience you want to provide.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.android.com/develop/background-work/background-tasks/data-transfer-options" rel="noopener noreferrer"&gt;Android’s guide to data-transfer options&lt;/a&gt; walks through the trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the phone never receives the answer?
&lt;/h2&gt;

&lt;p&gt;This case is less obvious.&lt;/p&gt;

&lt;p&gt;Imagine the server receives the photo successfully, but the connection drops before the response reaches the phone.&lt;/p&gt;

&lt;p&gt;The server has the file. The app thinks the attempt failed.&lt;/p&gt;

&lt;p&gt;If the app retries, does it create another copy?&lt;/p&gt;

&lt;p&gt;That isn’t something the background-work API can decide for you. The client and server need a way to recognise that both attempts belong to the same upload. For example, the app could use a stable upload identifier, with the server treating repeated attempts for that identifier as the same operation. Larger transfers may need a resumable protocol that tracks which parts arrived.&lt;/p&gt;

&lt;p&gt;The right approach depends on the backend. But I’d want an answer to that question before calling retries “safe.”&lt;/p&gt;

&lt;h2&gt;
  
  
  When the user comes back
&lt;/h2&gt;

&lt;p&gt;The upload doesn’t have to finish immediately. It does need to be understandable.&lt;/p&gt;

&lt;p&gt;When someone returns to the app, they should be able to tell whether the file is waiting for a connection, still uploading, completed, or needs another attempt.&lt;/p&gt;

&lt;p&gt;That means the screen can’t rely only on a local &lt;code&gt;isUploading&lt;/code&gt; flag that disappears when the screen goes away. It needs to read the relevant work and upload state again.&lt;/p&gt;

&lt;p&gt;I’d also keep &lt;strong&gt;cancelled&lt;/strong&gt; separate from &lt;strong&gt;failed&lt;/strong&gt;. If I cancel an upload, “Something went wrong” is a confusing response. I asked it to stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few tests I’d run
&lt;/h2&gt;

&lt;p&gt;Start an upload that takes long enough to interrupt, then try these separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Leave the upload screen and return.&lt;/li&gt;
&lt;li&gt;Switch to another app and come back.&lt;/li&gt;
&lt;li&gt;Turn off the network during the transfer, then reconnect.&lt;/li&gt;
&lt;li&gt;Reopen the app while the upload is pending or after it finishes.&lt;/li&gt;
&lt;li&gt;Interrupt the connection near completion and check whether retrying creates a duplicate on the server.&lt;/li&gt;
&lt;li&gt;Cancel the upload and inspect what remains on the phone and server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’d record the device and Android version alongside the results. Leaving a screen, having the system stop the app process, force-stopping the app, and cancelling the work are different situations. Passing one check doesn’t prove the others.&lt;/p&gt;

&lt;p&gt;WorkManager can stop a worker for several reasons, including changed constraints, cancellation, or a system request. Android recommends that long operations cooperate with stopping and release resources appropriately. &lt;a href="https://developer.android.com/develop/background-work/background-tasks/persistent/how-to/manage-work" rel="noopener noreferrer"&gt;Managing work&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A progress bar tells the user that something started. What they see when they come back tells them whether they can trust it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/shorts/XR78cNWF2ek" rel="noopener noreferrer"&gt;Watch: When an Android Upload Stops After You Leave the Screen&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>Location permission denied: what your Android app does next is the real design</title>
      <dc:creator>Samya</dc:creator>
      <pubDate>Thu, 05 Nov 2026 06:11:02 +0000</pubDate>
      <link>https://dev.to/kordelis/location-permission-denied-what-your-android-app-does-next-is-the-real-design-a8f</link>
      <guid>https://dev.to/kordelis/location-permission-denied-what-your-android-app-does-next-is-the-real-design-a8f</guid>
      <description>&lt;p&gt;Someone opens a café app and taps "Find cafés near me." Android asks for location access. They choose "Don't allow."&lt;/p&gt;

&lt;p&gt;Now what?&lt;/p&gt;

&lt;p&gt;Sometimes the answer is: nothing. A dead-end screen, a "location is required" message, maybe a button that fires the same dialog again. I think that is a poor experience when the app could offer another way forward.&lt;/p&gt;

&lt;p&gt;The person said no to &lt;em&gt;one way&lt;/em&gt; of answering "where should I search?" They didn't say no to the café. They may well know exactly which city they want. If the app can search by city, it already has a second way to help, and all it has to do is say so.&lt;/p&gt;

&lt;p&gt;A quick note on the sample before we go on. The permission dialog and app screens in the video are captures from an Android emulator. The character and surroundings are illustrated, and the café names and results are fictional.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/JroM8I5mUnw" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the task, not the permission
&lt;/h2&gt;

&lt;p&gt;Location is one way to find a place to search. It isn't the only way, and sometimes it isn't even the best one. Someone planning tomorrow's trip will probably type a city whether or not you have their GPS.&lt;/p&gt;

&lt;p&gt;The sample shows two outcomes after the same denial. In the first, the app stops at a message saying location is required. In the second, it explains that location access wasn't granted and offers a city field. Type a city, tap Search, and you land on a screen with fixed illustrative results, not results from a real search service.&lt;/p&gt;

&lt;p&gt;Same denial, completely different experience. One of them treats "no" as the end of the conversation. The other treats it as new information.&lt;/p&gt;

&lt;p&gt;To be fair, this won't work for every feature. A navigation app can't produce a live position without a real location source, and pretending otherwise would be worse than the dead end. A café directory is different: it can search a place the person gives it. So pick your fallback based on the task, and be upfront when the results will be less precise or less convenient than the location-based version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask less often than you think you need to
&lt;/h2&gt;

&lt;p&gt;Before you build a permission flow, ask whether the task needs device location at all. If the need is occasional, Android's own &lt;a href="https://developer.android.com/privacy-and-security/minimize-permission-requests#show-nearby-places" rel="noopener noreferrer"&gt;permission-minimization guidance&lt;/a&gt; suggests letting people enter an address or postal code manually. It also covers other approaches depending on how often and how precisely you need location.&lt;/p&gt;

&lt;p&gt;If the person does choose "Find cafés near me" and you genuinely need the device's position, ask in context, at that moment, not on first launch. And don't request precise location when approximate will do. Approximate location may be enough for a café search, depending on the feature and the accuracy it needs. What the system shows will vary with the Android version, the permissions you request and what the person chose before, so don't hard-code assumptions about the dialog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat denial as a state you designed
&lt;/h2&gt;

&lt;p&gt;A denied permission should land on a screen somebody thought about, not on whatever the error path happened to produce. For this example, that means four things.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Say what changed, in plain words.&lt;/strong&gt; "Location access wasn't granted. Search by city instead" tells the person why the nearby search can't run and what to do next. No blame, no jargon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the alternative actually work.&lt;/strong&gt; A city field that returns nothing useful is worse than no field. Handle empty input, no results and network errors too, because those are the first things a real person will hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave everything else alone.&lt;/strong&gt; Someone who denied location can still browse their saved places. If a feature doesn't need location, don't punish it for the permission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't nag.&lt;/strong&gt; Re-showing the same request right after a "no" is how you teach people to distrust your app. If they later want a location-dependent feature, give them a clear, deliberate way to turn it on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. Android's guidance says the same thing: request permission in context, check it every time an operation needs it, and degrade gracefully when access is denied or revoked. See &lt;a href="https://developer.android.com/training/permissions/requesting" rel="noopener noreferrer"&gt;Request runtime permissions&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the whole path, not just the dialog
&lt;/h2&gt;

&lt;p&gt;Seeing the permission dialog appear proves very little. What matters is whether a person can understand what happened and still finish what they came to do.&lt;/p&gt;

&lt;p&gt;On the Android versions you support, try these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deny the request.&lt;/strong&gt; Is the change explained? Can the person reach the alternative without hunting?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the alternative end to end.&lt;/strong&gt; Enter a city, submit it, follow the results to the next meaningful action. Then break it: empty input, no matches, a failed request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant, then revoke in system settings.&lt;/strong&gt; Come back to the app and trigger the location action again. Does it re-check access and recover, or crash?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant approximate, not precise.&lt;/strong&gt; Does the feature work with the accuracy it got, or at least explain its limit?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk away from the location flow.&lt;/strong&gt; Can the rest of the app still be used?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart the app.&lt;/strong&gt; Does what's on screen match the current permission and what the person chose?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we did and didn't verify
&lt;/h2&gt;

&lt;p&gt;The sample is deliberately small. On an Android 16 / API 36 emulator, we captured a real approximate-location prompt, denied it, entered a city and reached the alternative results screen. The results are fixed examples, not output from a search service. We didn't verify the flow on a physical device, and we didn't test every Android version. A production app needs its own search implementation and its own tests for the devices and permission states it supports.&lt;/p&gt;

&lt;p&gt;So take the sample as a demonstration of the shape, not as proof it works everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that matters
&lt;/h2&gt;

&lt;p&gt;After someone says no, what can they still do?&lt;/p&gt;

&lt;p&gt;In this example, a city field gives them a way forward that the nearby search alone never would. That's a small amount of work for the difference between an app that works with the person's decision and one that argues with it.&lt;/p&gt;




&lt;p&gt;Disclosure: This article was drafted with AI assistance.&lt;/p&gt;

</description>
      <category>android</category>
      <category>mobile</category>
      <category>ux</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>When larger text puts a button out of reach on Android</title>
      <dc:creator>Samya</dc:creator>
      <pubDate>Mon, 02 Nov 2026 16:48:08 +0000</pubDate>
      <link>https://dev.to/kordelis/when-larger-text-puts-a-button-out-of-reach-on-android-1o3c</link>
      <guid>https://dev.to/kordelis/when-larger-text-puts-a-button-out-of-reach-on-android-1o3c</guid>
      <description>&lt;p&gt;Someone opens a library app to collect a book. They make the text on their phone larger, read the collection details and try to confirm. The label is cut off. On a compact screen, the confirmation button has disappeared below the visible area—and an upward swipe doesn't bring it back.&lt;/p&gt;

&lt;p&gt;This is an illustrative example, built to make a layout problem visible. The app screens in our video are real captures from a small Jetpack Compose sample; the surrounding scenes are illustrated. The sample deliberately constrains the layout. It is not a customer app or a claim that larger text breaks every app.&lt;/p&gt;

&lt;p&gt;The short video shows the problem and the corrected flow:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/C6VUaWby9kU" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The text grew. The layout didn't make room.
&lt;/h2&gt;

&lt;p&gt;In the broken version, we used a custom action with a fixed height and clipped its contents. At a larger font size, the text needed more vertical space than the action allowed. The content area also did not scroll, so the action could move below the viewport with no way to reach it.&lt;/p&gt;

&lt;p&gt;Those are two separate problems. A label can be clipped even when its button is on screen. A perfectly readable button can still be unreachable when the page cannot scroll.&lt;/p&gt;

&lt;p&gt;Here's the shape of the constrained custom action in the sample, simplified to focus on the constraint:&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="nc"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clipToBounds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Confirm collection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fontSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top&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;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrapContentHeight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unbounded&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;align&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Alignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is deliberately restrictive custom UI, not standard Material &lt;code&gt;Button&lt;/code&gt; behavior. A fixed height alone does not prove that every button will clip: padding, font metrics, constraints, width and text all affect the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the content room and a way to scroll
&lt;/h2&gt;

&lt;p&gt;In the corrected sample, the content sits in a vertically scrollable container. The action has a &lt;strong&gt;minimum&lt;/strong&gt; height instead of the broken fixed height, with content-driven padding. Its text can wrap as needed.&lt;/p&gt;

&lt;p&gt;The relevant shape looks like this:&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="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxSize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verticalScroll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rememberScrollState&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Book and collection details&lt;/span&gt;

    &lt;span class="nc"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heightIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clickable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* confirm the collection */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;contentAlignment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Alignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Center&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Confirm collection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a simplified illustration of the layout choices, not a drop-in replacement for every screen. A production action should also have appropriate semantics, a clear role and accessible interaction behavior; a Material button may be a better starting point than a custom clickable &lt;code&gt;Box&lt;/code&gt;. Check the surrounding constraints too: changing only the button's minimum height will not fix a parent that clips its children or prevents scrolling. Compose's &lt;a href="https://developer.android.com/develop/ui/compose/touch-input/scroll/scroll-modifiers" rel="noopener noreferrer"&gt;scroll modifier guidance&lt;/a&gt; explains how to make content larger than its available space scrollable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually tested
&lt;/h2&gt;

&lt;p&gt;We tested the sample on an Android 16 / API 36 emulator in portrait, using a compact configuration of approximately 309 × 686 dp. With the system font scale at 2.0, the broken, non-scrolling layout left the action outside the visible content area. An upward swipe did not change the content. In the corrected version, we scrolled to the action, tapped it and observed the “Reserved” state.&lt;/p&gt;

&lt;p&gt;At a 1.6 font scale, the broken custom action also clipped part of its label. The clipping in the capture is subtle: roughly 13% of the glyph height compared with an uncut reference.&lt;/p&gt;

&lt;p&gt;This is one emulator configuration, not an accessibility certification or proof of behavior on every device. In an earlier, taller configuration, the action remained reachable. Screen size, density, language, content and font settings can all change the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the task, not just the first screen
&lt;/h2&gt;

&lt;p&gt;Android 14 introduced non-linear font scaling up to 200%. Larger text sizes do not necessarily scale at the same rate as smaller ones. Android recommends using &lt;code&gt;sp&lt;/code&gt; for text and testing at the maximum setting; see &lt;a href="https://developer.android.com/about/versions/14/features#non-linear-font-scaling" rel="noopener noreferrer"&gt;Android's font-scaling guidance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For a practical check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set the system font size to its maximum on a test device or emulator. Also try a smaller screen or split-screen window where appropriate.&lt;/li&gt;
&lt;li&gt;Open a real flow in your app and complete it. Can you read each label, scroll to the next step and reach the action?&lt;/li&gt;
&lt;li&gt;Check long or translated text, error messages and dynamic content. Look for clipping, overlaps and actions hidden by fixed regions.&lt;/li&gt;
&lt;li&gt;Repeat at your usual text size too. A change that helps at one size should not make the ordinary flow harder to use.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The aim isn't to make every screen look identical at every setting. It's to make sure the person using the app can still understand it and finish what they came to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: This article was drafted with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>a11y</category>
      <category>jetpackcompose</category>
    </item>
    <item>
      <title>Why did my birthday move? A date-only bug across time zones</title>
      <dc:creator>Samya</dc:creator>
      <pubDate>Fri, 30 Oct 2026 03:48:11 +0000</pubDate>
      <link>https://dev.to/kordelis/why-did-my-birthday-move-a-date-only-bug-across-time-zones-32gi</link>
      <guid>https://dev.to/kordelis/why-did-my-birthday-move-a-date-only-bug-across-time-zones-32gi</guid>
      <description>&lt;p&gt;Someone enters their birthday as 12 June. After travelling, they open the app and see 11 June.&lt;/p&gt;

&lt;p&gt;They haven't changed it. One possible cause is that the app turned a calendar date into a moment in time, then displayed that moment in a different time zone.&lt;/p&gt;

&lt;p&gt;Here's a small example of how that can happen, and what to test in an app that stores birthdays.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://youtube.com/shorts/bbXYEGVCp_8" rel="noopener noreferrer"&gt;Watch the short video: Why did my birthday move?&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  From 12 June to 11 June
&lt;/h2&gt;

&lt;p&gt;Suppose a birthday is &lt;code&gt;1994-06-12&lt;/code&gt;. An app saves it as midnight UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1994-06-12T00:00:00Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That timestamp identifies a moment. At that moment in Los Angeles, the local date and time are &lt;strong&gt;11 June 1994, 17:00&lt;/strong&gt;. If the app converts the timestamp to the device's time zone and takes the date, it will show 11 June.&lt;/p&gt;

&lt;p&gt;The time-zone conversion is doing what it was asked to do. The problem is that a birthday was represented as a moment in the first place. Travelling doesn't change a correctly stored birthday.&lt;/p&gt;

&lt;p&gt;Here's the conversion in Kotlin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.Instant&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.ZoneId&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;savedMoment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1994-06-12T00:00:00Z"&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;displayedDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;savedMoment&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ZoneId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"America/Los_Angeles"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocalDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;displayedDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 1994-06-11&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The offset depends on the zone's rules for the date in question. Using a named zone such as &lt;code&gt;America/Los_Angeles&lt;/code&gt; lets the time API apply those rules, including daylight saving time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep a birthday as a date
&lt;/h2&gt;

&lt;p&gt;For a birthday, the value we need is a year, month and day. In Java or Kotlin, &lt;code&gt;LocalDate&lt;/code&gt; represents that without a time or time zone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.LocalDate&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;birthday&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1994-06-12"&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;apiValue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;birthday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// "1994-06-12"&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;loadedBirthday&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loadedBirthday&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;birthday&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is what happens &lt;em&gt;between&lt;/em&gt; those two operations. Agree on a date-only value in the API, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"birthday"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1994-06-12"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then preserve it as a date through the server and database. For example, PostgreSQL's &lt;code&gt;DATE&lt;/code&gt; type stores a calendar date without a time of day. On the way back, parse the value as a date and format it for the person's locale—without converting it through an instant.&lt;/p&gt;

&lt;p&gt;On Android, &lt;code&gt;java.time.LocalDate&lt;/code&gt; is available from API level 26. If you support older Android versions, check your project's &lt;a href="https://developer.android.com/studio/write/java8-support" rel="noopener noreferrer"&gt;Java API desugaring configuration&lt;/a&gt; and supported APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the full round trip
&lt;/h2&gt;

&lt;p&gt;A parsing test alone can't catch a conversion hidden in the API, database mapping or UI. Try a test that exercises the actual save and reload path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In UTC, enter and save &lt;code&gt;1994-06-12&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Reload it and check that the API returns &lt;code&gt;1994-06-12&lt;/code&gt; and the screen displays 12 June.&lt;/li&gt;
&lt;li&gt;Change the device or test environment to &lt;code&gt;America/Los_Angeles&lt;/code&gt;, reload the same record and check both values again.&lt;/li&gt;
&lt;li&gt;Repeat with a zone east of UTC, such as &lt;code&gt;Asia/Tokyo&lt;/code&gt;, and include dates near year boundaries and a leap day.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For an automated integration test, run the save and reload through your real serialization and persistence layers in each relevant test environment. Inspect what travels over the API as well as what appears on screen; a date-only string can still be converted incorrectly by a client.&lt;/p&gt;

&lt;p&gt;Existing timestamp data needs care. Before migrating, establish what the stored timestamps meant and which time zone was used when the original dates were converted. A timestamp alone may not reliably reveal the intended birthday; don't apply a blanket conversion without checking the data and assumptions.&lt;/p&gt;

&lt;p&gt;A birthday isn't the only date-only value: a due date or anniversary may be one too. Scheduled meetings and payment events, on the other hand, represent moments and need appropriate time-zone handling. Choose the representation based on what the value means.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/LocalDate.html" rel="noopener noreferrer"&gt;Java &lt;code&gt;LocalDate&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/Instant.html" rel="noopener noreferrer"&gt;Java &lt;code&gt;Instant&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/datatype-datetime.html" rel="noopener noreferrer"&gt;PostgreSQL date and time types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/studio/write/java8-support" rel="noopener noreferrer"&gt;Android Java API desugaring&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: AI assisted with drafting this article. The example is illustrative.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>java</category>
      <category>testing</category>
    </item>
    <item>
      <title>They closed checkout. That doesn’t mean the payment failed.</title>
      <dc:creator>Samya</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:39:36 +0000</pubDate>
      <link>https://dev.to/kordelis/they-closed-checkout-that-doesnt-mean-the-payment-failed-54d5</link>
      <guid>https://dev.to/kordelis/they-closed-checkout-that-doesnt-mean-the-payment-failed-54d5</guid>
      <description>&lt;p&gt;Someone opens the payment screen in your Android app, pauses, then closes it.&lt;/p&gt;

&lt;p&gt;The app responds: “Payment failed.”&lt;/p&gt;

&lt;p&gt;They may have simply changed their mind. Now the app is telling them something went wrong.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/TGVy-LjH5Fs" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What did Google Play actually return?
&lt;/h2&gt;

&lt;p&gt;Google Play Billing has a response for leaving the purchase flow: &lt;code&gt;USER_CANCELED&lt;/code&gt;. Google lists it among the responses that &lt;a href="https://developer.android.com/google/play/billing/errors" rel="noopener noreferrer"&gt;do not indicate an error&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It tells you that the person left without completing the flow. It doesn’t tell you why.&lt;/p&gt;

&lt;p&gt;The problem can start with a simple shortcut in the code: treating every non-&lt;code&gt;OK&lt;/code&gt; response as a failed payment.&lt;/p&gt;

&lt;p&gt;Give cancellation its own branch:&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;purchasesUpdatedListener&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PurchasesUpdatedListener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;setCheckoutLoading&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="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;responseCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;BillingClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BillingResponseCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;USER_CANCELED&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;recordCheckoutCanceled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="c1"&gt;// Leave the person free to browse. No payment-error alert.&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;BillingClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BillingResponseCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OK&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;handlePurchaseUpdates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;handleOtherBillingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="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 helper functions here stand in for your app’s own code. Checkout closes over the browsing screen in this example, so there’s no extra navigation to do.&lt;/p&gt;

&lt;p&gt;The purchase checks still belong in &lt;code&gt;handlePurchaseUpdates&lt;/code&gt;. Don’t unlock content just because the response is &lt;code&gt;OK&lt;/code&gt;: wait for a pending purchase to complete, then verify it before granting access. Google’s &lt;a href="https://developer.android.com/google/play/billing/integrate#process" rel="noopener noreferrer"&gt;purchase-processing guide&lt;/a&gt; covers those steps.&lt;/p&gt;

&lt;p&gt;Other response codes need their own handling, too. Some call for a retry; others don’t. That’s the job of &lt;code&gt;handleOtherBillingResponse&lt;/code&gt; here. This callback is only part of the integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The report can get this wrong, too
&lt;/h2&gt;

&lt;p&gt;Even after the warning is fixed, the same mistake can remain in your reports.&lt;/p&gt;

&lt;p&gt;If someone backing out counts as “purchase failed” alongside a network problem, you can end up investigating the wrong thing.&lt;/p&gt;

&lt;p&gt;You may still want to know how often people leave checkout. Keep that information, but separate it from technical errors.&lt;/p&gt;

&lt;p&gt;An outcome such as &lt;code&gt;checkout_canceled&lt;/code&gt; is enough to make the difference clear. Record it once for that attempt, without purchase tokens or other sensitive data.&lt;/p&gt;

&lt;h2&gt;
  
  
  One path worth checking
&lt;/h2&gt;

&lt;p&gt;Open checkout in your test setup, then close it without completing the purchase. This checks leaving a new checkout, not cancelling an existing subscription.&lt;/p&gt;

&lt;p&gt;Check that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The loading state ends.&lt;/li&gt;
&lt;li&gt;The person can keep browsing without a payment-error warning.&lt;/li&gt;
&lt;li&gt;The report records a cancellation rather than a technical error.&lt;/li&gt;
&lt;li&gt;No new access is granted, and any access they already had stays intact.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>mobile</category>
      <category>ux</category>
    </item>
    <item>
      <title>The app keeps loading. Did my order go through?</title>
      <dc:creator>Samya</dc:creator>
      <pubDate>Sun, 25 Oct 2026 07:12:03 +0000</pubDate>
      <link>https://dev.to/kordelis/the-app-keeps-loading-did-my-order-go-through-3lae</link>
      <guid>https://dev.to/kordelis/the-app-keeps-loading-did-my-order-go-through-3lae</guid>
      <description>&lt;p&gt;The kitchen has your order. Your phone is still waiting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/feed/update/urn:li:ugcPost:7519954306851184640/" rel="noopener noreferrer"&gt;Watch the short illustrated example on LinkedIn.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Imagine ordering lunch through an app. You tap &lt;strong&gt;Place order&lt;/strong&gt;, but nothing seems to happen. After a while, you try again.&lt;/p&gt;

&lt;p&gt;The server may have saved the first order. Its reply just never reached your phone.&lt;/p&gt;

&lt;p&gt;From your side, that looks much like an order that failed. You probably aren't thinking about the server. You want to know whether lunch is coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should happen when you try again?
&lt;/h2&gt;

&lt;p&gt;For this example, say the first request created order &lt;code&gt;041&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the app sends the same request again, the server needs a way to recognise it. Otherwise, it might create order &lt;code&gt;042&lt;/code&gt; as well.&lt;/p&gt;

&lt;p&gt;Give each order attempt a unique ID before sending it. Keep that ID if you retry, along with the same order details. The server can then check whether it has already handled that attempt.&lt;/p&gt;

&lt;p&gt;This ID is often called an &lt;strong&gt;idempotency key&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For an API that supports it, a request might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /orders
Idempotency-Key: 9de69b3a-6ca8-4d9d-a62c-cf34ce127487
Content-Type: application/json

{"cartId":"cart-17"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server creates order &lt;code&gt;041&lt;/code&gt;. The reply gets lost. The app retries with the same key and cart, and the server returns order &lt;code&gt;041&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One detail is easy to miss here: a new key on every retry defeats the purpose. The key belongs to the order attempt, not to each network request.&lt;/p&gt;

&lt;p&gt;The header alone won't prevent duplicates, either. The server has to support this behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before trusting that retry
&lt;/h2&gt;

&lt;p&gt;There are a few things to check on the server.&lt;/p&gt;

&lt;p&gt;Keep the key tied to the authenticated caller. If someone sends the same key with different order details, reject the mismatch or follow the API's documented rules.&lt;/p&gt;

&lt;p&gt;Two requests can also arrive together. Both must not pass an “order doesn't exist yet” check and create separate orders. The server needs to handle that race, and a crash must not leave it with an order it has forgotten how to recognise.&lt;/p&gt;

&lt;p&gt;Check how long the server remembers a key. After that time, the same key may be treated as a new request. A genuinely new order needs its own key.&lt;/p&gt;

&lt;p&gt;There is a phone-side detail too. If someone closes the app while it is waiting, the pending attempt ID and order details need to survive that restart if the app will retry them later.&lt;/p&gt;

&lt;p&gt;Disabling the button can help with repeated taps. It won't settle what happened to a request whose reply went missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small test worth trying
&lt;/h2&gt;

&lt;p&gt;Let the server create the order, then drop the reply before the app receives it.&lt;/p&gt;

&lt;p&gt;Retry with the same key and order details. Check that the server still has one order and returns that order to the app.&lt;/p&gt;

&lt;p&gt;Then send two requests with the same key at once. Also try reusing the key with different details. Those are different cases, and passing the first test doesn't prove the others work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;Stripe documents one implementation of idempotent requests&lt;/a&gt;. Its rules for stored results, key expiry and concurrent requests are specific to Stripe, so check the API you actually use.&lt;/p&gt;

&lt;p&gt;Back at the lunch counter, the person is still looking at their phone. “Try again” should be a useful next step, not a decision about whether to risk ordering twice.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your app unlocked the purchase. Did Google Play get the confirmation?</title>
      <dc:creator>Samya</dc:creator>
      <pubDate>Fri, 23 Oct 2026 19:02:20 +0000</pubDate>
      <link>https://dev.to/kordelis/your-app-unlocked-the-purchase-did-google-play-get-the-confirmation-51k5</link>
      <guid>https://dev.to/kordelis/your-app-unlocked-the-purchase-did-google-play-get-the-confirmation-51k5</guid>
      <description>&lt;p&gt;Someone buys a chapter in your Android app. It opens, and they start reading. From the app screen, the purchase looks finished.&lt;/p&gt;

&lt;p&gt;There is one more result to check: did Google Play receive confirmation that your app processed it?&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.youtube.com/shorts/pOrUZcvjJIE" rel="noopener noreferrer"&gt;30-second illustration&lt;/a&gt; shows those two outcomes side by side. It is an example flow, not a recording of a customer purchase.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can go wrong
&lt;/h2&gt;

&lt;p&gt;For a completed purchase, your app needs to verify the purchase, grant the right access, and tell Play it was processed. If an eligible purchase is left unacknowledged, Play can automatically refund it and revoke the entitlement. A consumable follows a different final step: consuming it also fulfills the acknowledgment requirement. A pending purchase is different again; it should not unlock new access until it becomes &lt;code&gt;PURCHASED&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test worth running
&lt;/h2&gt;

&lt;p&gt;Use a &lt;strong&gt;license tester&lt;/strong&gt; and a Play test payment method for a non-consumable item. After the purchase completes, check both outcomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can the person open exactly what they bought?&lt;/li&gt;
&lt;li&gt;Is the purchase acknowledged in Play, rather than only marked accessible in the app?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then close and reopen the app. The correct access should still be there, without a second grant or a new purchase prompt. Repeat the exercise for any consumable or pending flow you support; their expected outcomes differ. Play's test-purchase timing is accelerated, so do not use the test refund window as the production deadline.&lt;/p&gt;

&lt;p&gt;This small test catches a gap that is easy to miss when you watch only the app screen. It also gives you two concrete places to investigate when a purchase is later refunded: the app's entitlement record and Play's processing state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt; &lt;a href="https://developer.android.com/google/play/billing/integrate#process" rel="noopener noreferrer"&gt;Google Play purchase processing&lt;/a&gt; · &lt;a href="https://developer.android.com/google/play/billing/test" rel="noopener noreferrer"&gt;Testing Play Billing&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Prepared with AI assistance and checked against the linked Google documentation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>testing</category>
      <category>kotlin</category>
      <category>googleplay</category>
    </item>
  </channel>
</rss>
