When an Android bug appears on one phone but not another, build information is genuinely useful. The trap is collecting every field simply because it exists. A good diagnostic snapshot answers a concrete compatibility question, remains readable, and does not quietly become a device fingerprint.

The four version numbers developers often mix up

  • SDK_INT is the major API level of the OS currently running on the device.

  • RELEASE is a user-visible Android version string and is not the right value for API gating.

  • compileSdk controls which SDK APIs the source can compile against.

  • targetSdk opts the app into platform behavior expectations and compatibility changes.

  • minSdk is the oldest API level on which the package can normally install.

A small, typed diagnostic snapshot

DeviceBuildInfo.ktkotlin
package com.example.diagnostics
 
import android.os.Build
 
data class DeviceBuildInfo(
    val apiLevel: Int,
    val release: String,
    val securityPatch: String?,
    val manufacturer: String,
    val brand: String,
    val model: String,
    val device: String,
    val product: String,
    val buildId: String,
    val buildType: String,
    val fingerprint: String,
    val supportedAbis: List<String>,
)
 
fun currentDeviceBuildInfo(): DeviceBuildInfo = DeviceBuildInfo(
    apiLevel = Build.VERSION.SDK_INT,
    release = Build.VERSION.RELEASE.orEmpty(),
    securityPatch = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        Build.VERSION.SECURITY_PATCH.takeIf(String::isNotBlank)
    } else {
        null
    },
    manufacturer = Build.MANUFACTURER.orEmpty(),
    brand = Build.BRAND.orEmpty(),
    model = Build.MODEL.orEmpty(),
    device = Build.DEVICE.orEmpty(),
    product = Build.PRODUCT.orEmpty(),
    buildId = Build.ID.orEmpty(),
    buildType = Build.TYPE.orEmpty(),
    fingerprint = Build.FINGERPRINT.orEmpty(),
    supportedAbis = Build.SUPPORTED_ABIS?.toList().orEmpty(),
)

Why this is a data object instead of scattered log calls

  • A typed snapshot is easy to render, redact, serialize deliberately, and unit-test.

  • SDK_INT is an integer, avoiding fragile string parsing.

  • SECURITY_PATCH was added in API 23, so access is guarded before class verification/runtime use on older devices.

  • SUPPORTED_ABIS describes CPU ABIs in preference order; it is not necessarily the instruction set of one bundled native library.

  • Null/blank normalization prevents vendor anomalies from crashing a diagnostics screen.

Use API levels to guard platform calls

NotificationPermissionState.ktkotlin
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
 
fun canPostNotifications(context: Context): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.POST_NOTIFICATIONS,
        ) == PackageManager.PERMISSION_GRANTED
    } else {
        true
    }
}

The OS version and permission state are separate checks

  • VERSION_CODES.TIRAMISU represents API level 33 without embedding a magic number.

  • The newer permission constant is used only on an OS where the runtime permission applies.

  • A version check does not grant permission; checkSelfPermission reads the current app permission state.

  • The pre-33 branch reflects that POST_NOTIFICATIONS was not a runtime permission on earlier Android versions.

  • Capability checks should follow the specific API’s documentation; some behavior also depends on targetSdk, device features, roles, or vendor support.

Do not parse Android release names

  • Never compare Build.VERSION.RELEASE lexicographically; strings such as 9, 10, previews, or vendor labels do not sort as API capabilities.

  • Do not infer API level from marketing names such as Android 15 or 16.

  • Use SDK_INT and constants from the SDK used to compile the app.

  • When compiling against an older SDK, comparing SDK_INT >= 36 may be a temporary compatibility technique, but upgrading compileSdk gives named constants and API definitions.

  • A new OS version does not guarantee optional hardware or a particular vendor implementation.

Android 16 and minor SDK releases

  • Android 16 introduces Build.VERSION.SDK_INT_FULL at API 36 for major-and-minor platform SDK identification.

  • Most app compatibility decisions should continue to use documented API availability and major SDK_INT checks.

  • Use full/minor checks only for an API or behavior explicitly introduced in a minor SDK release.

  • Do not reference an API 36 field from a project that has not compiled against the corresponding SDK.

  • Preview builds expose PREVIEW_SDK_INT; preview APIs can change between preview revisions and should not be treated as stable production contracts.

Display the snapshot in Jetpack Compose

BuildInfoScreen.ktkotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
 
@Composable
fun BuildInfoScreen(modifier: Modifier = Modifier) {
    val info = remember { currentDeviceBuildInfo() }
 
    Column(
        modifier = modifier
            .verticalScroll(rememberScrollState())
            .padding(16.dp),
    ) {
        Text("Device diagnostics", style = MaterialTheme.typography.headlineSmall)
        InfoRow("Android", "${info.release} (API ${info.apiLevel})")
        InfoRow("Security patch", info.securityPatch ?: "Not reported")
        InfoRow("Manufacturer", info.manufacturer)
        InfoRow("Model", info.model)
        InfoRow("Build ID", info.buildId)
        InfoRow("ABIs", info.supportedAbis.joinToString())
    }
}
 
@Composable
private fun InfoRow(label: String, value: String) {
    Text("$label: $value", style = MaterialTheme.typography.bodyMedium)
}

A diagnostics screen needs restraint

  • remember takes one snapshot for the current composition; build values do not normally change until an OTA/reboot environment changes.

  • Scrollable content prevents truncation on small screens and large font scales.

  • Human-readable labels are better than dumping raw field names.

  • Show only fields that help the support workflow.

  • If users can copy/share the report, preview it and remove identifiers, account data, paths, tokens, network addresses, and unrelated logs.

Fields worth understanding

  • MANUFACTURER names the product’s manufacturer as reported by the build.

  • BRAND is the consumer-visible brand associated with the product.

  • MODEL is the end-user-visible product model.

  • DEVICE is the industrial design/device code name, while PRODUCT names the overall product build.

  • ID is a build identifier; DISPLAY is a user-visible build string.

  • TYPE commonly distinguishes build types such as user, userdebug, or eng.

  • TAGS describes signing/build tags and should not be used alone as a security verdict.

  • FINGERPRINT identifies a particular build configuration, not a unique physical unit.

Hardware serial and other restricted identifiers

  • Build.SERIAL is deprecated and returns UNKNOWN for apps targeting modern Android versions.

  • Build.getSerial() is restricted and ordinary Play-distributed apps generally should not build product logic around it.

  • IMEI, serial number, and MAC address are persistent hardware identifiers with strong privacy and permission restrictions.

  • Choose the narrowest, resettable identifier that satisfies the use case.

  • For an app installation, use an app-scoped random UUID stored internally or a Firebase Installation ID when that service fits the architecture.

  • For signed-in experiences, an account-scoped server identifier is usually more meaningful than hardware identity.

Generate an app-install identifier when needed

InstallationIdStore.ktkotlin
import android.content.Context
import java.util.UUID
 
private const val PREFS = "installation_identity"
private const val KEY_ID = "installation_id"
 
fun installationId(context: Context): String {
    val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
    preferences.getString(KEY_ID, null)?.let { return it }
 
    val generated = UUID.randomUUID().toString()
    preferences.edit().putString(KEY_ID, generated).apply()
    return generated
}

This identifies one installation, not one human or device forever

  • UUID.randomUUID() creates a high-entropy random value rather than deriving identity from hardware.

  • Private preferences keep the value app-scoped under normal sandbox rules.

  • The identifier can change after app-data clearing, uninstall/reinstall, restore policy, or explicit reset.

  • Concurrent first calls could generate competing values; use a synchronized repository/DataStore design if strict single-write behavior matters.

  • Disclose collection and retention, and do not join reset identifiers back to old profiles without a legitimate consented basis.

Create a privacy-aware support report

SupportReport.ktkotlin
fun DeviceBuildInfo.toSupportText(appVersion: String): String = buildString {
    appendLine("App: $appVersion")
    appendLine("Android: $release (API $apiLevel)")
    securityPatch?.let { appendLine("Security patch: $it") }
    appendLine("Device: $manufacturer $model")
    appendLine("Build: $buildId ($buildType)")
    appendLine("ABIs: ${supportedAbis.joinToString()}")
}

Notice what the report deliberately leaves out

  • No serial number, IMEI, Android ID, phone number, account, IP address, or advertising identifier is collected.

  • The fingerprint is omitted from the default human-facing report because model/build ID may already answer the support question.

  • The app version belongs beside OS details because many compatibility defects are release-specific.

  • A report should be generated after user intent, previewed, and sent only over an approved support channel.

  • Apply retention controls and access restrictions on the receiving system too.

Verify values from ADB during development

Development host with an authorized test deviceadb
adb shell getprop ro.build.version.sdk
adb shell getprop ro.build.version.release
adb shell getprop ro.build.version.security_patch
adb shell getprop ro.product.manufacturer
adb shell getprop ro.product.model
adb shell getprop ro.build.fingerprint
adb shell getprop ro.product.cpu.abilist
36
16
2026-07-01
Google
Pixel ...
google/...:16/...:user/release-keys
arm64-v8a,armeabi-v7a

ADB is a test oracle, not an app implementation

  • getprop exposes Android system properties through an authorized debugging shell.

  • Values vary by device, build channel, emulator image, and OTA state.

  • An app should use public SDK APIs rather than executing shell commands or reading hidden properties.

  • Never publish a full getprop dump without review; it can contain more identifying and environment data than expected.

  • Disconnect/revoke debugging authorization on devices that no longer need it.

Testing version-dependent branches

  • Run instrumented tests on emulators/devices below and above each guarded API boundary.

  • Use Gradle Managed Devices or CI device matrices where they fit the project.

  • Keep pure decision logic separate so boundary comparisons can be unit-tested without changing static Build fields.

  • Test preview/minor SDK behavior only when the product intentionally supports it.

  • A mocked model string cannot prove vendor firmware behavior; reproduce hardware-specific defects on representative real devices.

Common mistakes

  • Comparing `RELEASE` strings: use numeric SDK_INT.

  • Using `Build.VERSION.SDK`: it is deprecated; use SDK_INT.

  • Logging every `Build` field: collect the minimum required for diagnosis and review export paths.

  • Treating model as capability: query the relevant feature/API and handle absence.

  • Treating fingerprint as security proof: use platform-backed attestation/integrity mechanisms appropriate to the threat model and validate server-side.

  • Reading serial/IMEI for analytics: choose resettable, scoped identifiers and comply with platform/policy requirements.

  • Accessing a new field without guarding: compile against the right SDK and branch before runtime access.

  • Confusing targetSdk with OS API: both can influence behavior, but they describe different sides of the compatibility contract.

Review checklist

  • Every version-specific call has the documented API-level/capability/permission guard.

  • User-visible release text is not used for program flow.

  • Only support-relevant build fields are collected and retention/sharing are documented.

  • No hardware identifier is used where an app/account/install-scoped alternative works.

  • Diagnostics remain usable on old APIs, vendor-modified builds, emulators, large text, and offline devices.

  • Tests cover both sides of important API boundaries and representative physical hardware.

Official Android references