← All problems

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.

Free~18 min

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:

PropertyWhat it controls
minSdkLowest Android version your app installs on. Lower = more devices, more compat code.
targetSdkTells the OS "I've tested on this version." The OS applies new behavioral restrictions at and above this level.
compileSdkSDK 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:

APIVersionKey change
21Android 5.0ART replaces Dalvik. Material Design. Camera2.
23Android 6.0Runtime permissions. Doze mode. Fingerprint API.
26Android 8.0Background execution limits (no implicit broadcast receivers). Notification channels required.
28Android 9.0Cleartext HTTP blocked by default. Foreground service requirement.
29Android 10Scoped storage (no direct filesystem access). Background location restrictions.
30Android 11Package visibility restrictions (queries in manifest). Scoped storage enforced.
31Android 12SplashScreen API. Exact alarms require permission. Notification trampolines blocked.
33Android 13Per-app notification permission (POST_NOTIFICATIONS). Photo Picker. Predictive Back.
34Android 14Partial 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 APIJetpack backportBackports down to
Notification channelsNotificationCompatAPI 4
Runtime permissionsActivityResultContracts.RequestPermissionAPI 16
SplashScreenandroidx.core:core-splashscreenAPI 23
Photo PickerActivityResultContracts.PickVisualMediaAPI 19 (polyfill)
BiometricBiometricPrompt (AndroidX)API 23
WindowInsetsWindowInsetsCompatAPI 21
ShortcutsShortcutManagerCompatAPI 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 (SIGQUIT for ANR) was unstable before API 21; the design notes the minSdk gate explicitly. The Reporter library has to compile against compileSdk 34 to use modern APIs, but runtime-guard each one.
  • Photo Gallery App - scoped storage on API 29 changed how MediaStore cursors return URIs; the READ_MEDIA_IMAGES permission 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 request FOREGROUND_SERVICE_LOCATION.

Done reading? Mark it so it sticks in your dashboard.