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_INTis the major API level of the OS currently running on the device.RELEASEis a user-visible Android version string and is not the right value for API gating.compileSdkcontrols which SDK APIs the source can compile against.targetSdkopts the app into platform behavior expectations and compatibility changes.minSdkis the oldest API level on which the package can normally install.
A small, typed diagnostic snapshot
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_INTis an integer, avoiding fragile string parsing.SECURITY_PATCHwas added in API 23, so access is guarded before class verification/runtime use on older devices.SUPPORTED_ABISdescribes 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
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.TIRAMISUrepresents 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;
checkSelfPermissionreads the current app permission state.The pre-33 branch reflects that
POST_NOTIFICATIONSwas 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.RELEASElexicographically; strings such as9,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_INTand constants from the SDK used to compile the app.When compiling against an older SDK, comparing
SDK_INT >= 36may be a temporary compatibility technique, but upgradingcompileSdkgives 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_FULLat API 36 for major-and-minor platform SDK identification.Most app compatibility decisions should continue to use documented API availability and major
SDK_INTchecks.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
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
remembertakes 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
MANUFACTURERnames the product’s manufacturer as reported by the build.BRANDis the consumer-visible brand associated with the product.MODELis the end-user-visible product model.DEVICEis the industrial design/device code name, whilePRODUCTnames the overall product build.IDis a build identifier;DISPLAYis a user-visible build string.TYPEcommonly distinguishes build types such asuser,userdebug, oreng.TAGSdescribes signing/build tags and should not be used alone as a security verdict.FINGERPRINTidentifies a particular build configuration, not a unique physical unit.
Hardware serial and other restricted identifiers
Build.SERIALis deprecated and returnsUNKNOWNfor 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
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
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
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.abilist36
16
2026-07-01
Google
Pixel ...
google/...:16/...:user/release-keys
arm64-v8a,armeabi-v7aADB is a test oracle, not an app implementation
getpropexposes 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
getpropdump 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
Buildfields.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
Build.VERSION reference defines
SDK_INT, release, preview, security patch, and Android 16 full SDK fields.Build reference documents product/build fields and restricted serial access.
Version compatibility explains runtime version checks.
Best practices for unique identifiers recommends scoped, resettable identifiers and avoiding hardware IDs.
Android 9 privacy changes documents
Build.SERIALbehavior and serial restrictions.
Comments and corrections