Publishing on Google Play
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Building, Signing and Preparing a Release gets TrailMate
to a signed Android App Bundle. This page picks up from that artifact and covers everything Google Play itself
requires around it: the developer account that owns the listing, the store listing and disclosures Play makes an
app fill in before it can be reviewed, the tracks a release moves through on its way to production, the ongoing
policies that keep a published app eligible to stay on the Play Store, and the Play libraries most apps end up
depending on — with Kotlin examples for in-app updates and in-app review. The baseline is the one used across
this section: TrailMate (com.example.trailmate) compiles against API 37 and sets targetSdk = 36, which is
exactly what Play’s current target API policy requires.
The manual Play Console flow is described first on purpose. Automation through the Play Developer Publishing API is covered near the end of this page, and the complete pipeline lives in CI/CD with GitHub Actions; both are much easier to reason about once each console step they replace is understood.
Play Console Accounts
Every app on Google Play is published from a Play Console developer account, and the account type shapes both the verification the owner goes through and, for newer accounts, how quickly a first app can reach production.
| Personal account | Organization account | |
|---|---|---|
Who it is for |
An individual — a student, hobbyist or solo developer, such as the one publishing TrailMate |
A legal entity — a company, nonprofit, government body or other organization |
Identity evidence |
Legal name, address, a government ID and a payment card in the same legal name; verified contact email and phone |
A D-U-N-S Number for the organization (Play uses it to verify the organization’s identity), plus verified organization contact details |
Mandatory for |
— |
Some app categories must be published from an organization account: financial services (banking, crypto exchanges and similar), health apps, VPN apps and government apps |
Extra gate before production |
Accounts created after November 13, 2023 must complete a closed test with at least 12 testers opted in for 14 continuous days (see The 12-Testers-for-14-Days Requirement) |
None of that kind |
Functionality once verified |
The same as an organization account, including monetization through a payments profile |
The same as a personal account |
Both account types pay the same one-time registration fee of US$25, and the account holder must be at least 18. Registration is: sign in with a Google Account, accept the Google Play Developer Distribution Agreement, pay the fee, choose the account type, and complete identity verification. Details that do not match the ID or payment card cause the verification to fail, and the registration fee is not refunded in that case — so the legal name on the account should be entered exactly as it appears on the documents.
Choosing between the two is mostly a question of who owns the app. A personal account is tied to one person; moving an app to an organization account later is possible through an app transfer, but it is extra paperwork and it changes the developer name shown on the listing. If TrailMate were ever to become a company product, it would be cleaner to start in an organization account from day one.
Developer Verification
Separately from the Play Console identity checks, Google is rolling out Android developer verification: a requirement that the developer of any app installed on a certified Android device — not only apps distributed through Play — is verified and has registered the app’s package name, so that the device can block installation of apps from unverified developers regardless of where the APK came from. It applies to certified devices running Android 7 and higher.
| When | What happens |
|---|---|
August 2026 |
Developer verification launches: the developer APIs, limited-distribution accounts and an "advanced flow" that lets power users knowingly install unverified apps. |
September 30, 2026 |
Enforcement begins on certified Android devices in Brazil, Indonesia, Singapore and Thailand, for installs from participating app stores (Google Play and several OEM stores) and sideloading. |
2027 onward |
Enforcement rolls out globally. |
What this means in practice depends on how an app is distributed:
-
Play developers are already verified through Play Console. Google Play registers the vast majority of apps automatically; Play Console is also where a Play developer manually registers any remaining package names, including apps that are also distributed outside Play. TrailMate, published on Play, needs no extra step beyond confirming its package is registered.
-
Developers who distribute exclusively outside Play sign up in the new Android Developer Console instead. After verifying their identity, they register each package name on the console’s Packages page by entering it together with the SHA-256 fingerprint of the app’s signing certificate, and — for a package name that already exists in the wild — prove ownership by uploading an APK signed with that key. Registration can also be automated with the Android Developer Console API.
-
Limited-distribution accounts are for students, teachers and hobbyists who only want to share an app with a small circle: they can install on up to 20 devices, need no government-issued ID and pay no registration fee. This is the right path for a personal prototype that will never be published broadly.
Because verification is keyed on the package name plus the signing certificate, the key-management decisions from Building, Signing and Preparing a Release now matter for sideloaded builds as well as Play builds: a package name registered with one key cannot simply be re-signed with another.
Setting Up the App in Play Console
Creating an app record (Create app on the Play Console home page) asks for the default language, the app name, whether it is an app or a game, and whether it is free or paid — a free app can never later become paid. The Dashboard then shows a checklist of tasks that must be completed before a release can reach any track beyond internal testing. They fall into two groups: the store listing (what users see) and app content declarations (what Play reviews).
Store Listing
The main store listing is the product page on Google Play. Its text and graphics have hard limits:
| Element | Requirement | TrailMate’s choice |
|---|---|---|
App name |
Up to 30 characters |
|
Short description |
Up to 80 characters; shown on the listing and in search results |
"Find hiking trails near you, save favorites and record your hikes offline." |
Full description |
Up to 4,000 characters |
Features, offline support, units, privacy summary |
App icon |
512 x 512 px, 32-bit PNG with alpha, at most 1,024 KB |
Exported from the adaptive icon’s foreground on a solid background |
Feature graphic |
1,024 x 500 px, JPEG or 24-bit PNG (no alpha); also the cover for a preview video |
A trail photo with the app name |
Screenshots |
At least 2 across device types, up to 8 per device type; 320 px to 3,840 px per side; JPEG or 24-bit PNG |
4 phone screenshots (list, detail, recording, settings) plus 7-inch and 10-inch tablet sets from the adaptive layouts |
Preview video |
Optional YouTube URL, public or unlisted, ads disabled |
None at launch |
Play’s listing guidelines also forbid promotional claims in the listing text and graphics ("Best", "#1", "Download now", price or ranking badges), and expect screenshots to show the actual in-app experience rather than marketing art. Large-screen screenshots matter: without them, Play shows the listing less prominently on tablets, foldables and ChromeOS, which undoes much of the work described in Adaptive Layouts, Edge-to-Edge and Large Screens.
Localized listings. Each listing field can be translated per language (Grow users → Store presence →
Main store listing → Manage translations), either by entering translations manually or by buying them from
Play’s translation service. Screenshots and the feature graphic can also be localized. A user whose device is set
to a language with a translation sees it; everyone else sees the default language. TrailMate already ships
values-es/ and values-de/ string resources (see
Resources, Configuration, Localization and Themes), so it
makes sense to translate the listing into the same languages — a Spanish UI behind an English-only listing looks
unfinished. Custom store listings go further and show a different listing to a specific country or to users
arriving from a particular URL.
App Content Declarations
The Policy and programs → App content page collects every declaration Play reviews against the binary:
-
Privacy policy — a public URL (not a PDF, not geofenced, not behind a login) describing what data the app accesses, collects, uses and shares. It is required for every app that targets children and for every app that handles personal or sensitive user data — TrailMate’s location access and user photos included — and in practice for almost every app, because the Data safety form links to it.
-
App access — if any part of the app is behind a login, a subscription or a location check, reviewers need a way in: working demo credentials, instructions, or a flag that disables the restriction. TrailMate’s optional account sign-in (used for sync) gets a dedicated review account whose credentials are entered here, never shipped in the app.
-
Ads — whether the app contains ads, including ads served by any bundled SDK. Apps that declare ads get an "Contains ads" label on the listing. TrailMate has none.
-
Content rating — the IARC questionnaire: questions about violence, sexuality, language, controlled substances, gambling, user interaction and the sharing of location with other users. Play converts the answers into ratings for each regional authority (ESRB, PEGI, USK, ClassInd and others) automatically. Answering inaccurately can get the app removed; answering again is required whenever the app’s content changes.
-
Target audience and content — the age groups the app is designed for. Selecting any group under 13 brings in the Families policy (restricted SDKs, ad networks and data collection, and a stricter review), so TrailMate declares 13+ and adult audiences only.
-
Data safety — described next.
-
Further declarations that apply only to some apps: news apps, COVID-19 apps, government apps, financial features, health apps, advertising ID usage, and the permission declarations for sensitive permissions such as background location, SMS or call log (TrailMate must justify
ACCESS_BACKGROUND_LOCATIONif it ever requests it; see Permissions, Privacy and Security).
The Data Safety Form
The Data safety form produces the "Data safety" section on the store listing. For each data type it asks whether the app collects it (sends it off the device), whether it shares it (transfers it to a third party), whether collection is optional, whether it is encrypted in transit, whether users can request deletion, and for what purposes (app functionality, analytics, personalization, account management, fraud prevention, and so on). The answers cover the whole app, including every bundled SDK — an analytics or crash-reporting library is data collection even though TrailMate’s own code never touches its network calls.
A worked mapping for TrailMate:
| Data type | Collected? | Shared? | Purpose and notes |
|---|---|---|---|
Precise location |
Yes (only when the user uploads a recorded hike) |
No |
App functionality; optional; processing of GPS points for the recorded track happens on-device otherwise |
Photos |
Yes (only when attached to a trail review) |
No |
App functionality; optional; user can delete them from the app |
Email address |
Yes (optional sign-in for sync) |
No |
Account management; deletion available in-app and via a web form |
Crash logs and diagnostics |
Yes (crash-reporting SDK) |
No |
Analytics / app functionality; not linked to identity |
Data processed only on the device, or sent end-to-end encrypted and never readable by the developer, does not count as "collected". Play cross-checks the form against the app’s declared permissions and observed network behavior, so the common failure is not deliberate misrepresentation but drift: an SDK added later, or a new feature that uploads something, without a form update. The form is part of every release checklist, not a one-time launch task. The broader privacy context is in Permissions, Privacy and Security.
Releases
With the listing and declarations complete, releases are created under Test and release. A release is
always created on a specific track, contains one or more App Bundles (identified by versionCode), carries
release notes, and is then sent for review and rolled out.
Play App Signing
Building, Signing and Preparing a Release covers signing the App Bundle with an upload key. Play App Signing — mandatory for every new app, since App Bundles are required — is the other half: Play verifies the upload signature, discards it, and signs the APKs it generates from the bundle with an app signing key that Google holds in its key management service. Enrollment happens the first time a bundle is uploaded to a new app: Play Console offers to generate the app signing key (the recommended default) or to use an existing one exported with the Play Encrypt Private Key (PEPK) tool, which is what an app migrating from self-signed APKs does to keep its signature.
The consequences of the split are worth spelling out:
-
A lost or leaked upload key is recoverable: the account owner requests an upload key reset in Test and release → App integrity, registers a new upload certificate, and carries on. A lost app signing key under the old self-managed model meant the app could never be updated again.
-
API keys and App Links that pin a certificate fingerprint (Maps API keys, OAuth clients,
assetlinks.json) must use the app signing key's SHA-256 fingerprint, shown on the App integrity page — not the upload key’s, which only Play ever sees. This mismatch is the most common "works in debug, broken on Play" bug after launch. -
The App integrity page is also where a key upgrade (rotating to a stronger app signing key, for new installs and, via APK Signature Scheme v3 rotation, for existing ones) is requested.
Uploading the App Bundle
Before the first upload, it is worth building the bundle and checking which certificate it is signed with, since a bundle signed with the debug key or with the wrong upload key is rejected at upload time:
# Run from the TrailMate project root.
./gradlew :app:bundleRelease
# The artifact Play Console expects.
ls -lh app/build/outputs/bundle/release/app-release.aab
# Print the signing certificate; its SHA-256 must match the upload certificate registered in Play Console.
keytool -printcert -jarfile app/build/outputs/bundle/release/app-release.aab
In Play Console the upload itself is part of creating a release: choose a track, Create new release, drag
the .aab into the App bundles area, and Play processes it — validating the signature, the versionCode
(it must be higher than every versionCode already uploaded to the app, on any track), the targetSdk, the
16 KB page-size alignment of any native libraries, and the manifest. The App bundle explorer then shows the
generated APK splits, their download sizes per device, and any warnings. The R8 mapping.txt uploaded
alongside the bundle (Play extracts it automatically when it is embedded, or it can be attached manually) is
what makes the crash stack traces in Android vitals readable.
Testing Tracks
A bundle is uploaded once and then promoted between tracks without rebuilding — the exact binary that was tested is the one that reaches production:
| Track | Audience | Purpose and characteristics |
|---|---|---|
Internal testing |
Up to 100 testers, added by email list |
Available to testers within minutes, before the full review completes. Every TrailMate build lands here first to confirm the signed artifact installs, starts and signs in. |
Closed testing |
Email lists (up to 2,000 users per list) or Google Groups; several closed tracks can coexist |
Invite-only beta, reviewed like a production release. Also the track that satisfies the new-account testing requirement below. |
Open testing |
Anyone who joins from the store listing; unlimited by default, or capped at a number no lower than 1,000 |
Public beta for device diversity and load, with testers' feedback kept private rather than shown as public reviews. |
Production |
All Play users in the selected countries and regions |
The public release, usually rolled out in stages (see below). |
Testers join a test through an opt-in URL shown on the track’s Testers tab; after a track is published for the first time the link can take several hours to become active. The tester’s Google Account must be in the list (or group), and the tester must accept the invitation from the opt-in page — being on the email list alone does not install anything. Once opted in, the test version arrives as a normal Play update. Internal app sharing is a separate, even faster mechanism: any build can be uploaded and shared by link with no versionCode rules and no review, which suits QA of individual feature branches.
The 12-Testers-for-14-Days Requirement
For a personal developer account created after November 13, 2023, Play will not grant access to the production track until the developer has run a closed test with at least 12 testers who have been opted in continuously for at least 14 days. Once the requirement is met, the developer applies for production access from the Dashboard, answering questions about the closed test (how testers were recruited, what feedback came in), about the app, and about production readiness; that application is itself reviewed, usually within seven days. Organization accounts and older personal accounts are exempt.
"Continuously" is the operative word: a tester who opts out on day 10 resets nothing for the others but no longer counts, so recruiting a few more than 12 is prudent. For TrailMate the practical plan is to start the closed test as soon as the app is minimally usable, with a Google Group of friends and hiking-club members, and to keep shipping updates to that track during the 14 days — the production-access questions ask what was learned and changed, and an untouched build suggests the test was only a formality.
The Pre-Launch Report
Every bundle uploaded to a testing track triggers a pre-launch report: Google installs the app on a range of real devices in its test lab, crawls it automatically for a few minutes, and reports stability issues (crashes, ANRs), performance (startup time, frame rendering, memory), accessibility findings (small touch targets, missing content descriptions, low contrast), security and trust warnings (vulnerable libraries, cleartext traffic), and screenshots of every screen the crawler reached, per device and language.
Two settings under Test and release → Testing → Pre-launch report → Settings make the report much more useful for TrailMate:
-
Test account credentials so the crawler can get past the optional sign-in screen (a dedicated test account, never a real user’s).
-
A Robo script — a recorded sequence of user actions uploaded in the same settings page — that drives the crawler through the critical path — open a trail, start a recording — before it explores on its own.
The pre-launch report does not block a release, but a crash it finds on day one is a crash production users would have found on day two. It complements, rather than replaces, the tests described in Instrumented and UI Testing.
Release Notes
Each release carries release notes ("What’s new"), up to 500 Unicode characters per language, shown on the listing and in the Play Store’s update list. In Play Console they are entered in one text box, with each language wrapped in its BCP-47 language tag on separate lines:
<en-US>
Offline maps now load twice as fast.
Recorded hikes show elevation gain.
Fixed a crash when rotating the trail detail screen.
</en-US>
<es-ES>
Los mapas sin conexión cargan el doble de rápido.
Las rutas grabadas muestran el desnivel acumulado.
Corregido un fallo al girar la pantalla de detalle.
</es-ES>
Release notes are for users: they should say what changed for them, not list ticket numbers, and Play’s guidelines forbid using them for promotion. Keeping them in the repository (one file per locale, as the CI example below does) means they are reviewed with the code and can be uploaded automatically.
Production and Staged Rollouts
The first production release of a new app is published to all selected countries at once. Every update after that can use a staged rollout: the release is offered to a random percentage of users — for example 5%, then 20%, 50% and finally 100% — while the rest stay on the previous version. Users in the rollout get the update normally; those outside it see no update until the percentage grows. Rollouts can also be limited to specific countries or regions first.
Managing a rollout happens on the track’s Releases tab:
-
Increase rollout — raise the percentage. There is no automatic progression; each step is a deliberate decision after checking Android vitals, the crash rate and new reviews for the users who already received it.
-
Halt rollout — stop offering the release to anyone new. Users who already installed it keep it: Play cannot downgrade an installed app. A halted release can be resumed later if the problem turns out to be unrelated.
-
Replace a halted or in-progress release — the real fix for a bad release is a new bundle with a higher
versionCode. Creating a new release on the production track supersedes the current one; users who got the broken version update to the fix, and everyone else skips straight to it. -
Complete rollout — set 100% to finish.
The rollout percentage is the main safety valve of the release process, which is why TrailMate’s production releases always start at 5% and why Android vitals thresholds (below) are evaluated per release. A related control is managed publishing: with it enabled, approved changes wait until the developer clicks Publish, which is useful to coordinate a release with a backend deployment or an announcement.
The Review Process
Releases to closed testing, open testing and production — and changes to the store listing or app content declarations — are reviewed before they go live. Review checks the binary and the declarations together against the Developer Program Policies: permissions against the declared use, the Data safety answers against observed behavior, the listing against the metadata policy, app access instructions against what the reviewer actually meets. Most reviews complete within hours to a few days; for new or recently changed developer accounts Google states that reviews can take up to seven days or longer in exceptional cases.
The Publishing overview page shows the state of each pending change (In review, Approved, Rejected, Changes not yet sent for review). A rejection comes with an email and a policy-status entry naming the policy and, usually, the offending element; the fix is a corrected release or declaration followed by a new submission, and an appeal is available when the developer believes the decision is wrong. Planning a public launch date around a build that has not yet passed review is the classic scheduling mistake — the safer pattern is to get the release approved with managed publishing on, then publish on the day.
Figure: The Release Flow
./gradlew bundleRelease"] Sign["Sign with the upload key
Play App Signing re-signs with the app signing key"] Upload["Upload the AAB to Play Console"] Internal["Internal testing
up to 100 named testers"] Closed["Closed testing
invite-only, reviewed"] Open["Open testing
opt-in, reviewed"] Production["Production
staged rollout percentage"] Build --> Sign --> Upload --> Internal --> Closed --> Open --> Production
In practice not every release visits every track: TrailMate’s routine updates go internal → closed → production, with open testing reserved for large changes such as a new map engine.
Policies That Keep a Listing Live
Publishing once is not the end of the obligations Play attaches to a listing; two policy areas apply to every update and, in the case of Android vitals, continuously.
Target API Level
Google Play requires every app to target a recent Android API level, so that new platform privacy and security behaviors actually apply to the apps users install:
| Requirement | Detail |
|---|---|
New apps and app updates (phone and tablet) |
Must target API 36 (Android 16) since August 31, 2026. An extension to November 1, 2026 can be requested in Play Console for apps that need more time. |
Wear OS, Android Automotive OS, Android TV and Android XR |
Lower floors apply: API 35 for Wear OS and Android Automotive OS, API 34 for Android TV and Android XR. |
Existing apps that are not updated |
Must target at least API 35 to remain available to new users on devices running a newer Android version than the app’s target; older targets stay installed for existing users but are not offered to new ones. |
For TrailMate this is a one-line setting, already at the required level:
// app/build.gradle.kts
android {
namespace = "com.example.trailmate"
compileSdk = 37
defaultConfig {
applicationId = "com.example.trailmate"
minSdk = 26
// Play requires API 36 for new apps and updates since August 31, 2026.
targetSdk = 36
}
}
Raising targetSdk is never only a number change: each level opts the app into new behaviors. Targeting 35
enforced edge-to-edge; targeting 36 ignores the edge-to-edge opt-out, enables predictive back animations by
default (so onBackPressed() is no longer called and OnBackPressedCallback must be used) and ignores
orientation and resizability restrictions on screens at least 600 dp wide. Those changes, and how TrailMate
handles them, are covered in Adaptive
Layouts, Edge-to-Edge and Large Screens. The sensible habit is to raise targetSdk shortly after each new
platform release, on a branch, with the behavior-change list open — not in the week before the Play deadline.
Android Vitals
Android vitals collects performance and stability data from opted-in devices and reports it in Play Console under Monitor and improve → Android vitals. A few core vitals have bad behavior thresholds that affect the app’s standing on Play:
| Core vital | Overall threshold | Per phone model |
|---|---|---|
User-perceived crash rate (share of daily active users who saw a crash) |
1.09% |
8% |
User-perceived ANR rate (share of daily active users who saw an ANR) |
0.47% |
8% |
Excessive partial wake locks |
5% |
— |
Play evaluates these on a 28-day rolling average. An app above the overall threshold may be made less visible in Play surfaces (search, recommendations); an app above the per-model threshold may be less visible on that device model and may show a warning on its store listing to users of that model. The warning disappears once the 28-day average recovers. Google has also announced that memory-related metrics will start affecting store visibility from February 2027.
The practical consequences for a release process:
-
Staged rollouts are where vitals regressions are caught. Android vitals breaks the metrics down by release, so a 5% rollout that doubles the ANR rate is visible within a day or two, long before it can move the 28-day average for the whole user base — halt it and ship a fix.
-
ANRs come from blocking the main thread: disk or network I/O on the UI thread, long
BroadcastReceiverwork, or lock contention. Moving work to coroutines on the right dispatcher, as in ViewModel, Lifecycle-Aware Components, Coroutines and Flow, and to WorkManager for deferrable jobs, is the main fix. -
Crash clusters need symbols. Without the release’s
mapping.txt(and native debug symbols, if any), the stack traces in vitals are obfuscated and much harder to act on.
Diagnosing the underlying problems — StrictMode, the profiler, Baseline Profiles, ANR traces — is covered in Debugging, Code Quality and Performance.
Play Libraries
Several Google Play libraries integrate an app with Play features that have no equivalent in the Android framework. They talk to the Play Store app on the device, so they only work for installs that came from Play (a sideloaded or debug build typically gets an error or a "not available" result), and each has a testing fake. The two TrailMate uses directly are added to the version catalog:
# gradle/libs.versions.toml
[versions]
playAppUpdate = "2.1.0"
playReview = "2.0.2"
[libraries]
play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "playAppUpdate" }
play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "playAppUpdate" }
play-review = { group = "com.google.android.play", name = "review", version.ref = "playReview" }
play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "playReview" }
// app/build.gradle.kts
dependencies {
implementation(libs.play.app.update)
implementation(libs.play.app.update.ktx)
implementation(libs.play.review)
implementation(libs.play.review.ktx)
}
The -ktx artifacts add suspend functions and Flow-based APIs (package com.google.android.play.core.ktx) on top
of the Task-based Java APIs, which is what the examples below use. The managers are provided through Hilt so
that tests and debug builds can swap in fakes:
// app/src/main/kotlin/com/example/trailmate/play/PlayModule.kt
package com.example.trailmate.play
import android.content.Context
import com.example.trailmate.BuildConfig
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.review.ReviewManager
import com.google.android.play.core.review.ReviewManagerFactory
import com.google.android.play.core.review.testing.FakeReviewManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PlayModule {
@Provides
@Singleton
fun provideAppUpdateManager(@ApplicationContext context: Context): AppUpdateManager =
AppUpdateManagerFactory.create(context)
// FakeReviewManager returns a fake ReviewInfo and never shows UI, which keeps debug builds
// (which are not installed from Play) from silently doing nothing -- or from spending quota.
@Provides
@Singleton
fun provideReviewManager(@ApplicationContext context: Context): ReviewManager =
if (BuildConfig.DEBUG) FakeReviewManager(context) else ReviewManagerFactory.create(context)
}
BuildConfig.DEBUG requires buildFeatures { buildConfig = true }, as shown in
Project Structure, Gradle and Build Variants; Hilt itself is
covered in Dependency Injection with Hilt, Dagger and
Koin.
In-App Updates
In-app updates (app-update / app-update-ktx 2.1.0) let an app detect that a newer version is available on
Play and install it without the user leaving for the Play Store. There are two flows:
| Flexible | Immediate | |
|---|---|---|
User experience |
A dialog offers the update; the download runs in the background while the app stays usable; the app then asks the user to restart to install it |
A full-screen Play UI blocks the app until the update is downloaded and installed, then the app restarts |
Who completes the install |
The app, by calling |
Play, automatically |
When to use |
Most updates: new features, non-critical fixes |
Critical updates: a security fix, a broken sync protocol, a data-corrupting bug |
Can the user decline |
Yes |
Yes, by cancelling — the app then decides whether it can keep running |
The app decides which flow to use from the AppUpdateInfo Play returns: updateAvailability(), whether each
type is allowed, clientVersionStalenessDays (days since Play learned about the update on this device), and
updatePriority — an integer from 0 to 5 set per release when it is created through the Play Developer
Publishing API (it cannot be set in the Play Console UI). TrailMate’s rule: priority 4 or 5 means immediate;
otherwise offer a flexible update once the installed version is three days stale.
// app/src/main/kotlin/com/example/trailmate/play/InAppUpdateController.kt
package com.example.trailmate.play
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
import com.google.android.play.core.ktx.clientVersionStalenessDays
import com.google.android.play.core.ktx.isFlexibleUpdateAllowed
import com.google.android.play.core.ktx.isImmediateUpdateAllowed
import com.google.android.play.core.ktx.requestAppUpdateInfo
import com.google.android.play.core.ktx.updatePriority
import dagger.hilt.android.scopes.ActivityScoped
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ActivityScoped
class InAppUpdateController @Inject constructor(
private val appUpdateManager: AppUpdateManager,
) {
private val _updateDownloaded = MutableStateFlow(false)
/** True once a flexible update has been downloaded and is waiting for a restart. */
val updateDownloaded: StateFlow<Boolean> = _updateDownloaded.asStateFlow()
private val installListener = InstallStateUpdatedListener { state ->
if (state.installStatus() == InstallStatus.DOWNLOADED) {
_updateDownloaded.value = true
}
}
/** Called once when the activity is first created. */
suspend fun checkForUpdate(launcher: ActivityResultLauncher<IntentSenderRequest>) {
val info = fetchInfo() ?: return
if (info.updateAvailability() != UpdateAvailability.UPDATE_AVAILABLE) return
when {
info.updatePriority >= HIGH_PRIORITY && info.isImmediateUpdateAllowed ->
startFlow(info, AppUpdateType.IMMEDIATE, launcher)
(info.clientVersionStalenessDays ?: 0) >= DAYS_FOR_FLEXIBLE_UPDATE &&
info.isFlexibleUpdateAllowed -> {
appUpdateManager.registerListener(installListener)
startFlow(info, AppUpdateType.FLEXIBLE, launcher)
}
}
}
/** Called from onResume(): resumes interrupted immediate updates, re-surfaces finished downloads. */
suspend fun onResume(launcher: ActivityResultLauncher<IntentSenderRequest>) {
val info = fetchInfo() ?: return
if (info.installStatus() == InstallStatus.DOWNLOADED) {
_updateDownloaded.value = true
}
if (info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
startFlow(info, AppUpdateType.IMMEDIATE, launcher)
}
}
/** Installs a downloaded flexible update; the app process restarts. */
fun completeUpdate() {
appUpdateManager.unregisterListener(installListener)
appUpdateManager.completeUpdate()
}
fun dispose() {
appUpdateManager.unregisterListener(installListener)
}
// Fails for installs that did not come from Play (debug builds, sideloads): treat as "no update".
private suspend fun fetchInfo(): AppUpdateInfo? =
runCatching { appUpdateManager.requestAppUpdateInfo() }.getOrNull()
private fun startFlow(
info: AppUpdateInfo,
type: Int,
launcher: ActivityResultLauncher<IntentSenderRequest>,
) {
appUpdateManager.startUpdateFlowForResult(
info,
launcher,
AppUpdateOptions.newBuilder(type).build(),
)
}
private companion object {
const val HIGH_PRIORITY = 4
const val DAYS_FOR_FLEXIBLE_UPDATE = 3
}
}
MainActivity registers the IntentSender launcher (it must be registered before the activity is started, so it
is a property), runs the first check once, resumes on every onResume(), and shows a "Restart" snackbar in
Compose when a flexible update has finished downloading:
// app/src/main/kotlin/com/example/trailmate/MainActivity.kt
package com.example.trailmate
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.example.trailmate.play.InAppUpdateController
import com.example.trailmate.ui.TrailMateApp
import com.example.trailmate.ui.theme.TrailMateTheme
import com.google.android.play.core.install.model.ActivityResult as PlayActivityResult
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var updateController: InAppUpdateController
private val updateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
when (result.resultCode) {
RESULT_OK -> Unit // Update accepted; flexible downloads continue in the background.
RESULT_CANCELED -> Log.i(TAG, "User declined the update")
PlayActivityResult.RESULT_IN_APP_UPDATE_FAILED -> Log.w(TAG, "In-app update failed")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (savedInstanceState == null) {
lifecycleScope.launch { updateController.checkForUpdate(updateLauncher) }
}
setContent {
TrailMateTheme {
val updateDownloaded by updateController.updateDownloaded.collectAsStateWithLifecycle()
Box(Modifier.fillMaxSize()) {
TrailMateApp()
if (updateDownloaded) {
Snackbar(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(16.dp),
action = {
TextButton(onClick = updateController::completeUpdate) {
Text(stringResource(R.string.update_restart))
}
},
) {
Text(stringResource(R.string.update_downloaded))
}
}
}
}
}
}
override fun onResume() {
super.onResume()
lifecycleScope.launch { updateController.onResume(updateLauncher) }
}
override fun onDestroy() {
updateController.dispose()
super.onDestroy()
}
private companion object {
const val TAG = "TrailMateUpdate"
}
}
Common mistakes with in-app updates: calling checkForUpdate on every onResume() (which re-prompts a user who
just declined), forgetting the DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS check (an immediate update interrupted by
the user switching apps then never resumes), never calling completeUpdate() (a flexible update downloads but
only installs when the app is next killed), and expecting any of it to work in a debug build. Testing uses
internal app sharing or an internal track with two uploaded versions, or FakeAppUpdateManager in
instrumented tests, as described in
Android Developers — Test in-app updates.
In-App Review
In-app review (review / review-ktx 2.0.2) shows Play’s own rating-and-review card on top of the app, so a
user can rate TrailMate without leaving it. The flow is deliberately opaque: the API never says whether the card
was shown or whether the user rated, and Play enforces a quota on how often the card may appear for a given
user, so a call may complete without any UI. The app’s job is only to ask at a good moment and then carry on
exactly as if nothing happened.
// app/src/main/kotlin/com/example/trailmate/play/ReviewPrompter.kt
package com.example.trailmate.play
import android.app.Activity
import com.google.android.play.core.ktx.launchReview
import com.google.android.play.core.ktx.requestReview
import com.google.android.play.core.review.ReviewException
import com.google.android.play.core.review.ReviewManager
import javax.inject.Inject
class ReviewPrompter @Inject constructor(
private val reviewManager: ReviewManager,
) {
/**
* Asks Play to show the review card after the user has completed a few hikes.
* Never shows an error and never changes the app flow: the API gives no feedback by design.
*/
suspend fun maybeAskForReview(activity: Activity, completedHikes: Int) {
if (completedHikes < MIN_COMPLETED_HIKES) return
val reviewInfo = try {
reviewManager.requestReview()
} catch (e: ReviewException) {
return // Play not available, quota exceeded, network error: silently skip.
}
// Suspends until the flow finishes (or immediately, if Play decides not to show it).
reviewManager.launchReview(activity, reviewInfo)
}
private companion object {
const val MIN_COMPLETED_HIKES = 3
}
}
TrailMate calls maybeAskForReview after the summary screen of a finished hike — a natural pause in which the
user has just got value from the app — never at launch, never in the middle of recording, and never behind a
custom "Do you like TrailMate?" pre-dialog, which Play’s guidelines prohibit because it filters for positive
reviews. The ReviewInfo object is only valid for a limited time, so it is requested right before launching the
flow rather than cached at startup. With FakeReviewManager injected in debug builds (see PlayModule above),
the code path runs end to end without UI; testing the real card requires an internal-track or internal app
sharing install.
Play Feature Delivery (Brief)
Play Feature Delivery splits optional functionality into dynamic feature modules
(com.android.dynamic-feature plugin) that Play delivers at install time, conditionally (by country, API level or
device feature), or on demand when the app requests them. The benefit is a smaller initial download; the cost
is extra modularization, a SplitCompat setup, and error handling for downloads that fail. TrailMate is small
enough not to need it, but a hypothetical offlinemaps module holding large map-rendering assets would be
declared on demand in its own manifest:
<!-- offlinemaps/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution">
<dist:module dist:title="@string/title_offline_maps">
<dist:delivery>
<dist:on-demand />
</dist:delivery>
<dist:fusing dist:include="false" />
</dist:module>
</manifest>
and requested from the base module with the feature-delivery-ktx suspend API when the user first opens the
offline maps screen:
// app/src/main/kotlin/com/example/trailmate/play/OfflineMapsInstaller.kt
package com.example.trailmate.play
import android.content.Context
import com.google.android.play.core.ktx.requestInstall
import com.google.android.play.core.splitinstall.SplitInstallManagerFactory
private const val OFFLINE_MAPS_MODULE = "offlinemaps"
/** Returns true if the module is already installed; otherwise starts the download and returns false. */
suspend fun ensureOfflineMapsInstalled(context: Context): Boolean {
val manager = SplitInstallManagerFactory.create(context)
if (OFFLINE_MAPS_MODULE in manager.installedModules) return true
manager.requestInstall(modules = listOf(OFFLINE_MAPS_MODULE)) // Progress: requestProgressFlow().
return false
}
Dynamic feature modules can be tested locally with bundletool build-apks --local-testing, which simulates Play’s
on-demand delivery on a connected device. Play Asset Delivery, the sibling mechanism for large game assets,
follows the same model.
Play Billing (Mention)
Google Play Billing is required for selling digital goods and subscriptions inside an app distributed on Play: in-app items, subscriptions, premium feature unlocks. It involves the Play Billing Library in the app, product configuration in Play Console (Monetize with Play → Products), and — essential for anything beyond a toy — server-side purchase verification and acknowledgement through the Play Developer API, since unacknowledged purchases are refunded automatically. TrailMate is free and sells nothing, so Billing is out of scope here; the integration guide is at Android Developers — Integrate the Google Play Billing Library into your app. Physical goods and services (a guided-hike booking, for example) are not sold through Play Billing.
Play Integrity
The Play Integrity API lets TrailMate’s backend check that a request comes from the genuine, Play-installed TrailMate binary on a genuine Android device, rather than from a repackaged APK or an automated abuse setup. The app requests an integrity token and forwards it to the backend, which decrypts and verifies the verdict server-side. The TrailMate integration, including the Kotlin client code and how it combines with OAuth, is covered in Permissions, Privacy and Security; the API itself is described in Android Developers — Overview of the Play Integrity API. It requires linking a Google Cloud project to the app in Play Console (Test and release → App integrity).
Automating Releases: the Play Developer Publishing API
Everything done in the Releases section above can be automated with the Google Play Developer API, whose Publishing API part manages bundles, tracks, rollouts, release notes and store listings (the other part covers subscriptions and in-app purchases). Setting it up is a one-time job:
-
Create or pick a Google Cloud project and enable the Google Play Android Developer API in it.
-
Create a service account in that project and a JSON key for it.
-
In Play Console, Users and permissions → Invite new users, invite the service account’s email and grant it only the app-level permissions it needs (for a release pipeline: release to testing tracks and, if it will promote, release to production).
-
Store the JSON key as a CI secret — never in the repository.
The API is transactional through edits: create an edit, make any number of changes inside it (upload a bundle, update a track, change listing text), then commit the edit; nothing takes effect until the commit, and an uncommitted edit is simply discarded. The raw REST sequence for a 5% staged production rollout looks like this:
# scripts/play-release.sh -- requires ACCESS_TOKEN: an OAuth 2.0 access token for the service account
# with the https://www.googleapis.com/auth/androidpublisher scope.
set -euo pipefail
PKG=com.example.trailmate
API=https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$PKG
UPLOAD_API=https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/$PKG
AUTH="Authorization: Bearer $ACCESS_TOKEN"
# 1. Open an edit.
EDIT_ID=$(curl -sf -X POST -H "$AUTH" "$API/edits" | jq -r '.id')
# 2. Upload the signed App Bundle into the edit; the response contains its versionCode.
VERSION_CODE=$(curl -sf -X POST -H "$AUTH" \
-H "Content-Type: application/octet-stream" \
--data-binary @app/build/outputs/bundle/release/app-release.aab \
"$UPLOAD_API/edits/$EDIT_ID/bundles?uploadType=media" | jq -r '.versionCode')
# 3. Assign it to production as a 5% staged rollout with release notes and an update priority.
curl -sf -X PUT -H "$AUTH" -H "Content-Type: application/json" \
"$API/edits/$EDIT_ID/tracks/production" \
-d @- <<JSON
{
"track": "production",
"releases": [{
"name": "1.4.0",
"versionCodes": ["$VERSION_CODE"],
"status": "inProgress",
"userFraction": 0.05,
"inAppUpdatePriority": 2,
"releaseNotes": [{ "language": "en-US", "text": "Offline maps now load twice as fast." }]
}]
}
JSON
# 4. Commit: only now does the release exist and go to review.
curl -sf -X POST -H "$AUTH" "$API/edits/$EDIT_ID:commit"
A release’s status maps directly onto the rollout controls described earlier: draft (not served),
inProgress with a userFraction (a staged rollout), halted (stop serving it to new users) and completed
(everyone). Increasing a rollout is another edit that updates the same release with a larger userFraction;
halting is an edit that sets status to halted. inAppUpdatePriority is the only way to set the priority the
in-app updates API reports — the value TrailMate’s InAppUpdateController compares against 4.
Few teams call the REST API by hand. The usual wrappers are the r0adkll/upload-google-play GitHub Action
and the Gradle Play Publisher plugin. The TrailMate production step, with release notes read from
per-locale files in the repository (distribution/whatsnew/whatsnew-en-US, whatsnew-es-ES, …):
# .github/workflows/release-app.yml (excerpt; the full workflow is on the CI/CD page)
- name: Promote to production (5% staged rollout)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.example.trailmate
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: production
status: inProgress
userFraction: 0.05
inAppUpdatePriority: 2
whatsNewDirectory: distribution/whatsnew
mappingFile: app/build/outputs/mapping/release/mapping.txt
The complete workflows — building and signing in CI from secrets, uploading to the internal track on every merge, and promoting to production on a tag — are in CI/CD with GitHub Actions. A good split of responsibilities is: automation uploads and promotes; a human decides when to increase a staged rollout, after looking at Android vitals.
Alternative Distribution
Google Play is not the only way to get an Android app onto a device, and Android has always allowed installing apps from other sources.
Sideloading means installing an APK directly — from a browser download, a file manager, an MDM system, or
adb install during development. An App Bundle cannot be installed directly, so a sideloadable artifact is either
an APK built with assembleRelease or a universal APK extracted from the bundle with bundletool:
# Build a single universal APK from TrailMate's bundle, signed with the release (or upload) key.
bundletool build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=build/trailmate-universal.apks \
--mode=universal \
--ks=release.keystore --ks-key-alias=trailmate
# The .apks file is a ZIP archive containing universal.apk.
unzip -o build/trailmate-universal.apks universal.apk -d build/
# Install on a connected device (for testing or a manual hand-over).
adb install -r build/universal.apk
On the device, installing from a browser or file manager requires the user to grant that app the "Install unknown apps" permission, and Google Play Protect scans the APK. Two constraints matter for anyone combining channels:
-
Signatures must match for updates. An APK signed with the upload key cannot update a Play install (signed with Google’s app signing key), and vice versa — Android refuses an update whose signing certificate differs. Distributing the same app both on Play and elsewhere therefore needs a deliberate choice of which key signs the non-Play build, and users cannot move between channels without uninstalling.
-
Developer verification applies. From September 30, 2026 in Brazil, Indonesia, Singapore and Thailand, and globally from 2027, a sideloaded app on a certified device must come from a verified developer with a registered package name (or be installed through the power-user advanced flow). Non-Play developers register in the Android Developer Console; a limited-distribution account covers the "my own app on up to 20 of my own devices" case.
Other app stores — Samsung Galaxy Store, Amazon Appstore, Huawei AppGallery, OEM stores such as those from
Xiaomi, OPPO and HONOR, and F-Droid for free and open-source apps — each have their own developer account,
submission format (most accept APKs; some now accept bundles), review and policies. Play-specific libraries do not
work outside Play: in-app updates, in-app review, Feature Delivery, Play Billing and Play Integrity all depend on
the Play Store, so a multi-store TrailMate would need a product flavor per store that swaps those implementations
(the play flavor using the classes on this page, a foss flavor with no-op implementations), as described in
Project Structure, Gradle and Build Variants. For enterprise
and internal apps, managed Google Play and private apps, or direct MDM distribution, are the usual channels.
Google’s summary of the options is at
Android Developers — Alternative distribution options.
References
-
Google Play Console Help — Add preview assets to showcase your app
-
Google Play Console Help — Manage target audience and app content settings
-
Google Play Console Help — Set up an open, closed, or internal test
-
Google Play Console Help — Testing requirements for new personal developer accounts
-
Google Play Console Help — Meet Google Play’s target API level requirement
-
Android Developers — Meet Google Play’s target API level requirement
-
Android Developers — Overview of the Google Play Core libraries
-
Android Developers — Support in-app updates (Kotlin or Java)
-
Android Developers — Integrate in-app reviews (Kotlin or Java)
-
Android Developers — Integrate the Google Play Billing Library into your app
-
Google for Developers — Getting Started (Google Play Developer API)