---
title: "The In-App Update Button Wasn't the Hard Part"
slug: using-an-agent-to-implement-in-app-updates
summary: "Many apps rely on store-managed updates rather than an explicit in-app path. I asked Codex to add one to Sääksi Bike. The useful proof came from two Play-delivered Internal App Sharing builds, a temporary recording, and a real phone."
tags: ai agents, Codex, Android, Flutter, Google Play, mobile development, software testing
ai_model: gpt-5.6-sol
published_at: 2026-09-02T04:58:21.507+00:00
canonical_url: https://jarkkolietolahti.com/articles/using-an-agent-to-implement-in-app-updates
markdown_url: https://cemecdwfdvdbvpvowuln.supabase.co/functions/v1/content-md?slug=using-an-agent-to-implement-in-app-updates
---

I asked Codex, an AI coding agent, to add in-app updates to Sääksi Bike, our Flutter app for Android and iOS. Later that day, I watched an Android phone discover a new version through Google Play, keep the app usable while Play downloaded it, refuse to restart and install while a ride was being recorded, and enable installation again after I deleted the temporary test ride. The app restarted into the higher build.

That sounds like a small feature. There is an **Update** button, after all. Many apps rely on the stores' automatic update behavior and omit an explicit in-app path. From the outside, the difference can look like one missing button.

In practice, the feature crosses application state, native platform APIs, store identity, signing, versioning, release tracks, and a test device that Google Play recognizes. An agent did not make those constraints disappear. It made it practical to follow them in one continuous piece of work.

## The first decision was not technical

Sääksi Bike records a route in the background. My initial request was simply to let users update when a new version is detected. Then the awkward case became obvious: what happens if the update is offered while a recording is active?

We could have tried to preserve and recover every state across the application restart. Instead, I made the product rule explicit: **the app may start or complete an in-app update only when no recording is active and the ride controller has returned fully to idle**. If a flexible download started before a ride begins, Play may finish downloading it, but the app must wait before completing the update. The rider can stop at a convenient point and update then.

That rule shaped the implementation. The update banner had to remain visible while recording, but its action had to be disabled. The guard also had to account for less visible states: an upload still pending, a recovered ride, or a controller that had not returned fully to idle.

This is where an agent is most useful. It does not choose the product rule. Once a human states the rule, however, it can trace the consequences through the state model, UI, platform bridge, tests, and release flow without treating them as separate tickets.

## Android already provides the machinery

Google's Play Core library supports two update experiences. An **immediate** update takes over the screen for a critical update. A **flexible** update downloads while the app remains usable, then lets the app ask the user when to restart and install. [Google documents both flows](https://developer.android.com/guide/playcore/in-app-updates).

Flexible was the natural choice for a tracking app. Sääksi Bike asks Play for update information, starts the Play-owned consent and download flow, listens to installation state, and exposes the downloaded state back to Flutter. When Play reports that the update is ready, the app can call `completeUpdate()`—but only after the recording guard says it is safe. Google recommends asking for confirmation before the restart in a flexible flow because Play does not restart the app automatically as it does for an immediate update. [The Kotlin and Java guide describes that lifecycle](https://developer.android.com/guide/playcore/in-app-updates/kotlin-java).

The agent added an Android coordinator in Kotlin, a small Flutter-facing method-channel contract, the state controller and banner in Dart, localization, and native and widget tests. The feature commit touched 14 files across those layers.

The iOS path is different. It checks the App Store version and opens Apple's StoreKit product view. That keeps the user inside the app while viewing the store page, but it is not an iOS equivalent of Play Core's in-app installer. We built that path and delivered the artifact to App Store Connect; we did not verify the complete iOS interaction on a device in this session.

## Unit tests could not prove store delivery

The tests proved that an available update produced the right action while idle, that recording kept the banner visible while disabling the action, and that a downloaded update waited rather than restarting the app.

They could not prove that Google Play would offer the update. A Play-enabled emulator can be a useful test device, but unit tests or a simulator alone would not prove the Play-delivered flow we cared about.

Play's test instructions call for a lower version installed from an Internal App Sharing URL, a second bundle with a higher version code, and a particular final step: open the second sharing link, but **do not install from the Play page**. Return to the installed app and let the in-app update API detect it. [The official test guide describes the sequence](https://developer.android.com/guide/playcore/in-app-updates/test).

Internal App Sharing accepts an app bundle or APK signed with any key; it does not require the production or upload key. Google Play re-signs every generated APK with its Internal App Sharing test key. Both versions therefore had to come through the same Play-delivered test path; sideloading an APK would not prove the boundary.

We used a connected OnePlus 15. The agent built baseline version code 2029 from an isolated worktree and uploaded it to Internal App Sharing. I installed it from Google Play. The agent then checked the phone: installer `com.android.vending`, version 2029, and the certificate digest reported for the Internal App Sharing artifact.

Next it built the same source as version code 2030 and uploaded that bundle separately. We opened the new sharing URL but did not press the Play Store's update button. Back in Sääksi Bike, the app found the newer version and displayed its own update banner.

These were throwaway Internal App Sharing artifacts for the upgrade test, separate from release-track history. Version code 2030 also appeared later on the internal track as part of unrelated release work; the subsequent release containing the in-app-update feature used version code 2031.

I tapped **Update** while the app was idle. Google's flexible-update sheet appeared. I accepted it, the download completed, and the app changed its action to **Restart and install**. Only then did we start a real temporary recording.

## The test that mattered was one disabled button

With recording active, the downloaded update remained visible. Its message changed to tell me to stop recording at a convenient point. The agent inspected the Android UI hierarchy and confirmed that **Restart and install** was both non-clickable and disabled. The installed package was still version 2029.

I used **Delete my ride**, the flow designed to remove the server ride and exclude it from the heat map, and confirmed that the app returned to idle. We did not independently query the heat map during this test. The install action became available again. I tapped it. Play completed the update, restarted the application, and Android reported version code 2030. The installer still belonged to Google Play, and the installed certificate matched the Internal App Sharing artifact.

That end-to-end test proved something a mocked update manager could not: the boundary between our application and the store, and the safety rule in the exact state that motivated it.

The human role was small but essential. I chose the policy. I enabled Internal App Sharing. I accepted Play's consent sheet. I explicitly started the location recording, then deleted the temporary ride. The agent handled the repetitive work around those decisions: builds, uploads, package inspection, certificates, UI state, logs, tests, and version checks.

## Agents make cross-boundary integration economical

Why add an explicit in-app path when the stores can already update apps automatically?

Because store support is not the same as application integration. Someone still has to decide between flexible and immediate behavior, fit it into the app's lifecycle, localize the messages, handle interrupted downloads, prevent unsafe restarts, set up a realistic store test, produce two Play-delivered builds, and prove the installed result. In a cross-platform app, that work also spans languages and build systems.

This kind of work is valuable but not glamorous. It requires several specialties, and its happy path is hard to demonstrate until almost everything is connected. An agent changes the economics: it can read the platform documentation, inspect the existing state machine, implement native and Flutter layers, write tests, run the release machinery, and operate a connected device without handing the work from person to person.

But this does not mean “ask once and ship.” The work succeeded because we gave the agent boundaries:

- Work in an isolated branch and worktree; do not touch `main` until the device test passes.
- Do not start or complete an in-app update while a recording, pending upload, or recovery is active.
- Use Google Play's distribution path rather than sideloading an APK.
- Let the human make consent and location-publication decisions on the phone.
- Verify version code, installer identity, and signing certificate after installation.
- Run the full native release gate before merging.

These are not incidental implementation details. They are the harness that turns agent activity into evidence.

## External systems still need a single writer

The subsequent release exposed one more boundary. Another release process was using Google Play Publisher at the same time. Play Publisher changes happen inside an “edit,” and one upload returned the literal error **“This Edit has been deleted.”** The error was consistent with a competing edit superseding ours, although the message alone did not prove the exact sequence.

The agent stopped instead of retrying blindly. It opened a disposable diagnostic edit, confirmed that version 2031 had not been retained on a track, deleted the diagnostic edit, checked that no other release process was running, and made one controlled retry. The internal-track upload then succeeded. Code, tests, research, and documentation can often run in parallel; an external single-writer transaction still needs coordination.

Android 1.2.0+31 reached Play internal testing, and iOS 1.2.0+31 reached App Store Connect. A separate earlier iOS build, 1.2.0+30 without the in-app-update feature, had been submitted for review for an unrelated fix. Google Play production was not yet available to this developer account because its closed-testing prerequisite was incomplete. That was an account gate, not a rejection of the feature or build.

## The useful result was evidence

Google and Apple had already built the installation and store mechanisms. The difficult part was coordinating the application state, platform APIs, store delivery, device interaction, and release state around them.

The agent compressed those boundaries into one working session: product rule to code, code to tests, tests to Play-delivered Internal App Sharing builds, and the installed result back to verified device state. When a store transaction collided with another release process, it treated the external state as evidence rather than guessing.

That is the practical value of coding agents for ordinary product engineering. They do not remove platform rules or human decisions. They make it reasonable to finish a cross-boundary feature and collect evidence that it behaves as intended.

