Mobile Core Concepts
Android Compatibility
Why "it works on my device" is not a shippable answer, and the version-gating strategy that keeps one codebase correct across a decade of Android releases.
Android has a problem no other major platform shares: a multi-year-tail install base across thousands of OEM device variants, none of which Google can directly update. Every behavioral change has to ship without breaking the device that's three years old, two majors behind, and managed by a carrier that hasn't approved an OS update in 18 months. The strategies Google evolved to solve this - Jetpack backports, Mainline modules updated via Play, Play Services as a compat layer, runtime SDK_INT checks - are the substrate of every "what minSdk should we use" decision, and they get asked about in every senior interview.
minSdk vs targetSdk vs compileSdk
Three properties on defaultConfig, easy to confuse, governs different things:
| Property | What it controls |
|---|---|
minSdk | Lowest Android version your app installs on. Lower = more devices, more compat code. |
targetSdk | Tells the OS "I've tested on this version." The OS applies new behavioral restrictions at and above this level. |
compileSdk | SDK version the compiler sees. Should always be latest - lets you access new APIs at compile time even if targetSdk is lower. |
The most common misconception is that minSdk controls behavior. It doesn't. targetSdk does. An app with targetSdk = 28 running on Android 14 still gets pre-scoped-storage behavior because the OS reads "this app says it's tested up to API 28" and grants the old contract.
Capping minSdk high "for simpler code" is a tempting trap: each bump silently cuts off a fraction of the install base, and most teams underestimate the long tail. Profile actual user-base distribution (Play Console -> Statistics -> Android version) before raising it.
Google Play requires targetSdk to be within ~1-2 years of the latest API level. In 2026 the minimum allowed targetSdk is 35. You cannot ship a new app to Play with an older one.
API level inflection points
Every Android release introduces some behavioral change. A handful broke enough apps to be worth memorizing:
| API | Version | Key change |
|---|---|---|
| 21 | Android 5.0 | ART replaces Dalvik. Material Design. Camera2. |
| 23 | Android 6.0 | Runtime permissions. Doze mode. Fingerprint API. |
| 26 | Android 8.0 | Background execution limits (no implicit broadcast receivers). Notification channels required. |
| 28 | Android 9.0 | Cleartext HTTP blocked by default. Foreground service requirement. |
| 29 | Android 10 | Scoped storage (no direct filesystem access). Background location restrictions. |
| 30 | Android 11 | Package visibility restrictions (queries in manifest). Scoped storage enforced. |
| 31 | Android 12 | SplashScreen API. Exact alarms require permission. Notification trampolines blocked. |
| 33 | Android 13 | Per-app notification permission (POST_NOTIFICATIONS). Photo Picker. Predictive Back. |
| 34 | Android 14 | Partial photo access. Foreground service types required. minSdk enforcement tightened. |
The three most asked in interviews: API 26 (background limits, why WorkManager exists), 29 (scoped storage, why FileProvider is mandatory), 33 (notification permission, why apps targeting older API can suddenly stop notifying).
Sources: Android version notes
How Google ships fixes to non-updatable devices
This is the heart of the topic. Three mechanisms, layered.
Jetpack (AndroidX) - library-shipped backports
Most "new" features ship as a Jetpack library that implements the modern API down to a much lower minSdk internally. The compat library detects the device's SDK level at runtime and uses the OS API on new devices, falls back to a manual implementation on old ones.
| Modern API | Jetpack backport | Backports down to |
|---|---|---|
| Notification channels | NotificationCompat | API 4 |
| Runtime permissions | ActivityResultContracts.RequestPermission | API 16 |
| SplashScreen | androidx.core:core-splashscreen | API 23 |
| Photo Picker | ActivityResultContracts.PickVisualMedia | API 19 (polyfill) |
| Biometric | BiometricPrompt (AndroidX) | API 23 |
| WindowInsets | WindowInsetsCompat | API 21 |
| Shortcuts | ShortcutManagerCompat | API 25 |
Jetpack is updated via Maven / Gradle, not the OS. Your app picks up a new version by bumping a build dependency - the user's device version doesn't matter.
Mainline - OS components updated via Play
Since Android 10, Google factored portions of the OS into Mainline modules (officially "Project Mainline"): individually-updatable system components delivered as APKs through Play Store, signed by Google. WebView is the headline example - com.google.android.webview is a Mainline module updated every few weeks across every device API 21+. Other Mainline modules include MediaCodec, ConnectivityManager, DNS resolver, ART itself (Android 14+).
The implication: Chrome / WebView security and rendering improvements reach users without an OS update. The Rendering Core Concept's WebView section depends on this - the modern VizCompositor architecture is in WebView Mainline, not in the OS proper.
Google Play Services - the compatibility layer
com.google.android.gms runs on essentially every Google-certified Android device, updated continuously via Play. It carries APIs that Google wants on every device immediately: FCM (push), Maps, Auth, Play Billing, Location, Safety Net, Play Integrity. From the app's perspective these look like Android APIs but they live in a Google-owned process and get updates independent of the OS.
The gotcha: not every Android device has Play Services. AOSP / non-Google certified builds (Huawei since the trade ban, some Amazon Fire devices, Chinese OEMs) lack it. Anything that depends on Play Services has to either gracefully degrade or document the device-class restriction.
Compatibility strategies in your code
Four techniques, in order of preference:
1. Prefer Jetpack *Compat classes
When a NotificationCompat, WindowInsetsCompat, ContextCompat exists, use it. Even if your minSdk is high enough to call the OS API directly, the Compat wrapper future-proofs you - when the OS adds a new behavioral wrinkle in API 38, you get it by bumping a library, not by adding a new branch.
2. Runtime version checks - the universal fallback
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// API 33+
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
} else {
// Pre-33: notifications auto-granted; nothing to request.
}Use when no Jetpack backport exists and you need genuinely different behavior per version.
3. Lint annotations - enforce at compile time
@RequiresApi(Build.VERSION_CODES.S)
fun scheduleExactAlarm() {
alarmManager.setExactAndAllowWhileIdle(...)
}
// Tell lint "I've already checked the version":
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
fun isExactAlarmSupported(): Boolean = Build.VERSION.SDK_INT >= 31@RequiresApi prevents calling a new-API function without a guard. @ChecksSdkIntAtLeast teaches lint that your helper is the guard, so it doesn't warn inside the if block. This is the right way to centralize version checks behind a meaningful predicate.
The wrong way is @SuppressLint("NewApi"): it silences the lint warning without guarding the call, so the code still crashes on old devices. It is only valid when the code path is genuinely unreachable on older API - for example, inside an already-guarded block lint can't see through.
4. Capability abstraction in DI - keep version checks out of UI
When the same conditional appears in multiple places, hide it behind an interface and let DI pick the implementation at the application boundary:
interface NotificationPermissionHandler {
fun requestIfNeeded(launcher: ActivityResultLauncher<String>)
}
class Api33NotificationHandler : NotificationPermissionHandler {
override fun requestIfNeeded(launcher: ActivityResultLauncher<String>) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
class LegacyNotificationHandler : NotificationPermissionHandler {
override fun requestIfNeeded(launcher: ActivityResultLauncher<String>) { /* auto-granted */ }
}
@Provides
fun provideNotificationHandler(): NotificationPermissionHandler =
if (Build.VERSION.SDK_INT >= 33) Api33NotificationHandler() else LegacyNotificationHandler()ViewModels never see Build.VERSION.SDK_INT. Version branching lives at the DI seam.
Testing across API levels
Two pragmatic paths:
// Robolectric - specific API level without a device, runs in unit tests:
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28, 33])
class NotificationTest { ... }
// Gradle Managed Devices - multiple emulator API levels in CI:
managedDevices {
localDevices {
create("api28") { device = "Pixel 2"; apiLevel = 28 }
create("api34") { device = "Pixel 6"; apiLevel = 34 }
}
}The interview-relevant point: behavioral changes are tested by running on different API levels, not by inspecting SDK_INT in tests. Robolectric's @Config(sdk = [..]) is the cheap path for unit-level coverage.
Sources: Compatibility framework · Robolectric SDK configuration
See also
- Platform knowledge - the API inflection points (scoped storage on 29, FGS types on 34) all map to lifecycle / background-work constraints covered there.
- App Size and Delivery - AAB lets you ship per-API-level assets so you don't bloat the install for old devices.
- Security and Crypto - cleartext blocking on 28 and Network Security Config live there.
- Background Work and Scheduling - the API 26 background-execution limits are why WorkManager exists.
Used in
- Crash Reporter SDK - signal handling (
SIGQUITfor ANR) was unstable before API 21; the design notes theminSdkgate explicitly. The Reporter library has to compile againstcompileSdk34 to use modern APIs, but runtime-guard each one. - Photo Gallery App - scoped storage on API 29 changed how
MediaStorecursors return URIs; theREAD_MEDIA_IMAGESpermission split landed on API 33. Both shape the MediaStore deep dive. - Messenger App - notification permission (API 33) means new installs must request it; an upgrading user from API 32 has it auto-granted and the request would no-op.
- Doc Editor App - WebView updates via Mainline; the editor design has to handle "user is on Android 11 but WebView is current" because WebView features evolve independently of the OS.
- Ride-Sharing App - foreground service types on API 34 are mandatory for
location; the design has to declare<service ... android:foregroundServiceType="location"/>and requestFOREGROUND_SERVICE_LOCATION.
Done reading? Mark it so it sticks in your dashboard.