Platform or library?
Two phones on the same Android version can run your app differently, because half the API you call is compiled into your APK and half is provided by the device.
The most useful diagnostic question in Android development is: where does this API live?1 The answer tells you whether its behaviour follows the device's OS version, your app's binary, or a Google app the user may not have.
TL;DR#
- Platform APIs ship in the system image. Their behaviour depends on the device's Android version.
- libraries are compiled into your APK. Their behaviour depends on your dependency version, not the OS.
- is a separate app, present on GMS devices only, updated through Play.
- Bundled libraries (Kotlin, OkHttp, Room) are ordinary dependencies in your APK.
- The four have different update paths, which is why "same Android version" does not mean "same behaviour".
Mental model#
Think of a house with four kinds of infrastructure.
The street (platform) is maintained by the city, and its condition depends on your neighbourhood. The wiring inside your walls (AndroidX) came with the house and can be replaced whenever you like. The utility company (Play services) is a separate organisation that may or may not serve your address. Your furniture (bundled libraries) is yours entirely.
Where the analogy breaks#
The four are not layered. An AndroidX library calls the platform; Play services calls the platform; your bundled libraries call both. The question is not "which layer" but "who controls the version" — and one API call can cross all four.
The big picture#
Text description of this diagram
An interactive sorter quiz. Ten APIs are presented one at a time, and the learner sorts each into one of four buckets: platform (OS image), AndroidX library, Google Play services, or bundled in the app. Feedback explains each answer: android.app.Activity is a platform class whose lifecycle is enforced by system_server; androidx.compose.material3.Text is compiled into the APK; WorkManager is AndroidX and schedules through the platform JobScheduler underneath; FusedLocationProviderClient is provided by Google Play services and absent from AOSP; kotlinx.coroutines.flow.Flow is a Kotlin library dependency; Room is an AndroidX library over SQLite; Intent is a core framework class; TelephonyManager is a system service; java.time is in the platform's core libraries since API 26 and desugared on older versions; GoogleSignInClient is Play services only.
The quiz is the fastest way to internalise the distinction. Most "works on my device" bugs are a misclassification: the developer assumed platform, and the API was Play services, or assumed AndroidX, and the API was platform.
Deep dive#
The four homes#
| Home | Ships in | Version controlled by | Absent when |
|---|---|---|---|
| Platform | system image | OS release | never (but old) |
| AndroidX | your APK | your Gradle file | never |
| Play services | a Google app | Play Store | non-GMS device |
| Bundled | your APK | your Gradle file | never |
The distinction that matters for behaviour is who can update it. A platform bug is fixed in an OS update, which may never arrive. An AndroidX bug is fixed in a library update, which you can ship tomorrow.
Why this matters for behaviour changes#
Android changes behaviour by target SDK, not by device version. That is the mechanism that lets a platform fix a mistake without breaking every app at once:
<uses-sdk android:minSdkVersion="26" android:targetSdkVersion="37" />minSdk— the lowest device that can install the app.targetSdk— the compatibility contract; the platform applies the behaviours that were current at that level.compileSdk— the API surface the code is compiled against.
AndroidX: behaviour in your APK#
// The behaviour of this call is determined by the lifecycle-runtime-compose version,
// not by the device's Android version.
val state by viewModel.state.collectAsStateWithLifecycle()That is the point of the library: a fix to collectAsStateWithLifecycle reaches every device that
installs the new APK, including devices that will never get an OS update.
The cost is binary size and a compatibility surface you now own: if a library requires minSdk 23 and
your app supports 21, adding it breaks the build.
Play services: capability that depends on the device#
// Only works on devices with Google Play services.
val client = LocationServices.getFusedLocationProviderClient(context)FusedLocationProviderClient is not in AOSP. On a device without GMS — a Huawei phone sold after 2019,
an AOSP build, an emulator without Google APIs — the class is present in your APK if you compile against
it, but the service it talks to is not.
The defensive pattern is a capability check:
fun hasPlayServices(context: Context): Boolean =
GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESSAnd a fallback that does not depend on it. That is why apps that must work everywhere use
android.location.LocationManager or a platform API where possible, and treat Play services as an
enhancement.
Bundled libraries: the simplest case#
Kotlin's standard library, kotlinx.coroutines, OkHttp, Retrofit, Room's generated code — all compiled into your APK. Their behaviour is entirely determined by the version you declared.
The complication is transitive: a library can pull in a newer version of another library, and Gradle's conflict resolution picks the highest. That is how an innocent dependency update changes a transitive dependency's behaviour.
./gradlew :app:dependencyInsight --dependency kotlinx-coroutines-coreThe mixed case: one call, four homes#
// 1. AndroidX: WorkManager schedules the work (in your APK).
WorkManager.getInstance(context).enqueue(request)
// 2. Platform: WorkManager delegates to JobScheduler (in the OS image).
// 3. Platform: the job runs in your process, started by system_server.
// 4. Bundled: the worker's HTTP call uses OkHttp (in your APK).Debugging a failure here requires knowing which layer failed. A missing job could be a WorkManager version bug, a platform JobScheduler quota, an OEM battery optimisation, or a network client problem.
Debugging by classification#
The question "where does this API live?" turns into a diagnostic procedure:
- Reproduce on a different OS version. If behaviour changes, suspect the platform.
- Change only the library version. If behaviour changes, suspect AndroidX.
- Test on a device without GMS. If the feature disappears, it was Play services.
- Check the dependency tree. If a transitive version moved, suspect a bundled library.
That is four experiments that localise almost any "works here, not there" bug.2
The compatibility surface nobody documents#
Between the four homes sits a fifth, less visible thing: the OEM's platform changes. A Samsung or Xiaomi build is AOSP plus modifications, and those modifications can change behaviour that no documented API mentions — background restrictions, notification handling, permission prompts.
That is why a bug can reproduce on one Android 16 device and not another. The classification procedure still works, but it needs one more step: reproduce on a GSI or a Pixel before concluding that the platform is at fault.
Version checks that are actually correct#
// Correct: check the API level the behaviour was introduced at.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission()
}
// Wrong: check the marketing version string.
if (Build.VERSION.RELEASE.startsWith("13")) { /* … */ }Build.VERSION.RELEASE is a user-facing string: it can be "13", "13.0", or an OEM's custom suffix. The
integer SDK_INT is what every library and every platform check uses, and it is the only one that is
guaranteed to be comparable.
The same care applies to library checks. androidx.core exposes BuildCompat helpers for features that
shipped in a preview, but for released APIs the integer comparison is correct and sufficient.
The GSI and the emulator#
A Generic System Image lets you run a clean AOSP build on a device, which removes the OEM's changes from the equation. An emulator with Google APIs includes Play services; one without does not. Those two knobs — clean platform, with or without GMS — isolate the two most common environment differences.
Version skew in practice#
A useful exercise: list the versions that determine your app's behaviour on a single device.
- The OS build (platform APIs and their fixes).
- The Play services version.
- The WebView version (updated independently through Play).
- The libraries compiled into your APK.
- The OEM's modifications to any of the above.
Five independent version streams, and only one of them is under your control. That is the honest picture of an Android runtime environment, and it explains why the platform ships so much of its behaviour as libraries.
The design consequence#
The reason AndroidX exists at all is that the platform's update path is slow and uneven. Shipping behaviour in the APK is the only way to guarantee that a fix reaches users. That is why the recommended stack leans so heavily on libraries — and why new platform APIs are increasingly delivered as libraries first.
Android 17's Compose-first statement is the logical endpoint: if the UI toolkit is a library, then new UI capabilities can ship without waiting for an OS release at all.3
How it evolved#
Mainline modules are the platform's own answer to the same problem: ship a piece of the OS through Play so it can be updated without a full OTA. The APEX format does for system components what AndroidX did for app libraries.
Under the hood#
- Build.javaVERSION.SDK_INT, the runtime check every library performs
- PackageManager.javahow the platform reports installed packages and their signatures
- Application.javawhere library initialisers run, before most platform services are used
Curiosity corner#
Build.VERSION.SDK_INTis the most-called method in AndroidX. Almost every compatibility helper starts with it.- A library can raise your
minSdkwithout a warning if you do not check its manifest merge result. - Play services is an APK, which is why it appears in
pm list packagesand why it can be updated like any other app.
Try it#
./gradlew :app:dependencyInsight --dependency androidx.core:core --configuration debugRuntimeClasspath
adb shell pm list packages | grep com.google.android.gms
Check yourself#
Check yourself
What changed structurally when the Support Library became AndroidX?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsJetpackdeveloper.android.comaccessed 2026-10-27↩
- docsบันทึกประจำรุ่นของแพลตฟอร์ม SDK | Android Studiodeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
- docs<uses-sdk> | App architecturedeveloper.android.comaccessed 2026-10-27↩
- docsAndroidX রিলিজ | Jetpackdeveloper.android.comaccessed 2026-10-27↩
- docsTarget API level requirements for Google Play appsdeveloper.android.comaccessed 2026-10-27↩
- docsAndroid platform architecture (AOSP)source.android.comaccessed 2026-10-27↩