DEV Community

Cover image for Master Android Sensors with Jetpack Compose
Mahmoud Ramadan
Mahmoud Ramadan

Posted on

Master Android Sensors with Jetpack Compose

Chapter 1: The Android Sensor Ecosystem

Chapter Project: PhoneCheckup — Device Health Diagnostic

By the end of this chapter, you will build PhoneCheckup — an app that discovers all available sensors on any Android device, runs interactive diagnostic tests, and generates a health report card. People use this app when buying or selling used phones, or when troubleshooting device issues.

1.1 Why Sensors Matter in Modern Android Apps

Pick up your phone and tilt it. The screen rotates. Walk down the street and your fitness app counts every step. Open your banking app and your fingerprint unlocks it. Point your camera at a QR code on a restaurant table and the menu appears. Every single one of these interactions is powered by sensors.

Sensors are the bridge between the physical world and the digital experience on your phone. Without them, a smartphone is just a small computer with a touchscreen. With them, it becomes a context-aware device that understands where it is, how it's moving, what it's seeing, and who is holding it.

Yet most Android developers never go beyond basic sensor usage. They might use the camera or check location, but the full spectrum of sensors — accelerometers, gyroscopes, magnetometers, barometers, proximity detectors, light meters, step counters, and biometric readers — remains largely untapped in most applications.

Read More https://leanpub.com/master_android_sensors

This book changes that. Across 15 chapters, you will master every category of sensor available on Android, and you will do it by building real apps that solve real problems. Not toy demos. Not academic exercises. Apps that people download and use every day.

The Shift to Context-Aware Applications

Traditional mobile apps are reactive — they wait for the user to tap, type, or swipe. Sensor-driven apps are proactive. They understand context and respond to the physical world:

Fitness apps count steps, detect activity type, and measure heart rate without the user pressing a single button.
Navigation apps know which direction you're facing and adjust the map accordingly.
Smart home apps detect when you leave your house (geofencing) and turn off the lights.
Accessibility apps use proximity sensors to let visually impaired users interact through gestures.
Gaming apps turn the phone into a steering wheel, a sword, or a magic wand using motion sensors.
The opportunity for developers who understand sensors is enormous. Most apps in the Play Store still treat the phone as a flat rectangle. The apps that stand out — the ones users love and recommend — are the ones that feel alive, aware, and intelligent. That intelligence comes from sensors.

What You Will Build in This Book

Every chapter in this book pairs sensor knowledge with a practical, daily-life project:

Chapter Sensor Focus Project Real Problem Solved
1 Sensor Framework PhoneCheckup Test a used phone's sensors
2 Accelerometer & Gyroscope DriveSafe Monitor driving behavior
3 Magnetometer & Orientation ProCompass Qibla direction + compass
4 Light, Pressure, Temperature SleepComfort Bedroom sleep monitor
5 Proximity Sensor SmartEtiquette Auto-silence phone
6 Step Counter WalkMate Daily walking companion
7 GPS & Geofencing ParkPin Find your parked car
8 Camera (CameraX) DocPocket Document & QR scanner
9 Microphone BabySentry Baby cry monitor
10 Biometric Sensors AppLock Lock apps with fingerprint
11 Health Sensors MorningVitals Daily health dashboard
12 Sensor Fusion FallGuard Fall detection for elderly
13 Data Visualization MyDay Auto daily activity journal
14 XR & Foldable Sensors FlexMode Smart foldable layouts
15 Testing & Benchmarking SensorBench Developer testing toolkit
Let's start by understanding how the Android sensor system works under the hood.

1.2 Android Sensor Framework Architecture

The Android Sensor Framework is the system-level infrastructure that lets your app communicate with hardware sensors. Understanding its architecture is essential before writing a single line of sensor code — it explains why certain things work the way they do, and helps you avoid common pitfalls.

The Big Picture: From Hardware to Your App

When a sensor detects a change in the physical world, the data travels through several layers before reaching your Kotlin code:

Physical World (motion, light, magnetic field, etc.)

Hardware Sensor (MEMS chip on the phone's circuit board)

Sensor HAL (Hardware Abstraction Layer — vendor-specific driver)

SensorService (Native C++ system service in Android OS)

SensorManager (Java/Kotlin system service your app talks to)

SensorEventListener (Your callback — where you receive data)

Compose State (StateFlow / mutableStateOf — drives your UI)
Let's examine each layer.

Hardware Sensors

Modern Android phones contain a variety of MEMS (Micro-Electro-Mechanical Systems) chips. These are tiny mechanical structures etched onto silicon that physically respond to forces like acceleration, rotation, magnetic fields, and pressure. A typical flagship phone in 2025 includes:

An accelerometer + gyroscope combo chip (e.g., Bosch BMI260)
A magnetometer (e.g., AKM AK09918)
A barometric pressure sensor (e.g., Bosch BMP380)
A proximity sensor (typically infrared)
An ambient light sensor
A fingerprint sensor (under-display ultrasonic or optical)
Budget phones may have fewer sensors. Some phones lack a gyroscope, barometer, or magnetometer entirely. This is why sensor availability checking is critical — and exactly what our PhoneCheckup project will do.

Sensor HAL (Hardware Abstraction Layer)

The HAL is a vendor-specific layer that translates raw hardware signals into standardized data formats. Samsung, Google, Xiaomi, and other manufacturers each implement their own HAL for their specific sensor chips. This is why the same sensor type might behave slightly differently across devices — different noise characteristics, different sampling rates, different accuracy.

As app developers, we never interact with the HAL directly. But understanding that it exists explains why you might see different sensor behavior on a Pixel vs. a Samsung Galaxy.

SensorManager: Your Gateway to Sensors

SensorManager is the system service that your app uses to interact with sensors. You obtain it through the standard Android system service mechanism:

val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
SensorManager provides three essential capabilities:

Discovery — Find out which sensors are available on the device
Registration — Subscribe to sensor data updates
Utility — Helper functions for orientation computation, altitude calculation, etc.
The SensorEvent Lifecycle

Working with sensors follows a consistent lifecycle pattern:

// 1. Get the SensorManager
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager

// 2. Get a specific sensor (returns null if not available)
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)

// 3. Create a listener
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
// 4. Receive data — this is called at high frequency
val x = event.values[0]
val y = event.values[1]
val z = event.values[2]
}

override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
    // Called when sensor accuracy changes
}
Enter fullscreen mode Exit fullscreen mode

}

// 5. Register the listener (start receiving data)
sensorManager.registerListener(listener, accelerometer, SensorManager.SENSOR_DELAY_UI)

// 6. Unregister when done (CRITICAL — prevents battery drain and memory leaks)
sensorManager.unregisterListener(listener)
The registerListener call takes a delay parameter that suggests how frequently you want updates:

Constant Approximate Delay Use Case
SENSOR_DELAY_FASTEST 0 ms (as fast as possible) Scientific data collection
SENSOR_DELAY_GAME 20 ms (~50 Hz) Games, AR
SENSOR_DELAY_UI 60 ms (~16 Hz) UI animations
SENSOR_DELAY_NORMAL 200 ms (~5 Hz) Screen rotation, casual monitoring
Important: These are suggestions, not guarantees. The actual delivery rate depends on the hardware, HAL implementation, and system load. In Chapter 15, we'll build SensorBench to measure actual vs. requested rates on your device.

The SensorEvent Object

Every sensor callback delivers a SensorEvent object containing:

event.sensor // The Sensor object that generated this event
event.values // FloatArray of sensor readings (size depends on sensor type)
event.accuracy // Accuracy level (UNRELIABLE, LOW, MEDIUM, HIGH)
event.timestamp // Nanoseconds since boot (NOT wall clock time)
The values array is the most important field. Its size and meaning depend on the sensor type:

Sensor Type values[0] values[1] values[2] Unit
Accelerometer x-axis y-axis z-axis m/s²
Gyroscope x rotation y rotation z rotation rad/s
Magnetometer x-axis y-axis z-axis μT
Light lux level — — lux
Proximity distance — — cm
Pressure atmospheric — — hPa
Step Counter cumulative steps — — steps
1.3 Sensor Categories Overview

Android organizes sensors into three broad categories. Understanding these categories helps you choose the right sensor for your app and understand the characteristics of each.

Motion Sensors

Motion sensors measure acceleration forces and rotational forces along three axes. They are the most commonly used sensor family.

TYPE_ACCELEROMETER — Measures acceleration force in m/s² applied to the device on all three physical axes (x, y, z), including the force of gravity. When the phone lies flat on a table, values read approximately (0, 0, 9.81) because gravity pulls along the z-axis.

TYPE_LINEAR_ACCELERATION — Same as accelerometer but with gravity removed. This is a software sensor (also called a virtual or composite sensor) that the platform computes by subtracting gravity from the accelerometer reading. Useful when you want to detect user-initiated movement without the constant gravity component.

TYPE_GRAVITY — Reports just the gravity vector. Combined with LINEAR_ACCELERATION, it equals the raw ACCELEROMETER reading. This is also a software sensor.

TYPE_GYROSCOPE — Measures the rate of rotation in rad/s around each axis. Essential for detecting twists, turns, and rotational movements. Used heavily in gaming, AR, and motion tracking.

TYPE_GYROSCOPE_UNCALIBRATED — Raw gyroscope data without bias compensation. Includes estimated drift values. Useful when you need to apply your own calibration or fusion algorithm.

TYPE_STEP_COUNTER — Counts the total number of steps taken since the last device reboot. This is a hardware sensor on most modern phones, meaning it continues counting even when your app is not running. The count resets on reboot.

TYPE_STEP_DETECTOR — Fires an event each time the user takes a step. Unlike STEP_COUNTER, this gives you individual step events with timestamps.

TYPE_SIGNIFICANT_MOTION — A trigger sensor that fires once when the device detects significant motion (like the user starting to walk). This is a special one-shot sensor designed for power-efficient wake-up scenarios.

Environmental Sensors

Environmental sensors measure properties of the physical environment around the device.

TYPE_LIGHT — Measures ambient light level in lux. The system uses this for auto-brightness. Your app can use it for adaptive UI themes or, as we'll do in Chapter 4, monitoring bedroom darkness for sleep quality.

TYPE_PRESSURE — Measures atmospheric pressure in hPa (hectopascals). Used for altitude estimation, weather prediction, and detecting floor changes in buildings. Not available on all devices.

TYPE_AMBIENT_TEMPERATURE — Measures room temperature in degrees Celsius. Rare on phones — mostly found on specialized devices.

TYPE_RELATIVE_HUMIDITY — Measures ambient relative humidity as a percentage. Even rarer than the temperature sensor.

Position Sensors

Position sensors determine the device's physical position in the world.

TYPE_MAGNETIC_FIELD — Measures the geomagnetic field strength in μT (microteslas) along three axes. Essential for compass applications. Combined with the accelerometer, it provides full device orientation.

TYPE_MAGNETIC_FIELD_UNCALIBRATED — Raw magnetic field data without hard iron calibration. Includes estimated calibration values.

TYPE_PROXIMITY — Measures the distance of an object from the device screen, typically in centimeters. Most implementations are binary — they report only "near" (0 cm) or "far" (maximum range, usually 5 cm). Used by the system to turn off the screen during calls.

TYPE_ROTATION_VECTOR — A software sensor that provides the device's orientation as a quaternion. This is the platform's built-in sensor fusion, combining accelerometer, gyroscope, and magnetometer data for stable, drift-free orientation. We'll explore this deeply in Chapter 12.

TYPE_GAME_ROTATION_VECTOR — Like ROTATION_VECTOR but without the magnetometer. Faster and more responsive, but subject to yaw drift over time. Ideal for games where relative rotation matters more than absolute compass bearing.

Deprecated and Removed Sensors

Be aware of sensors that should no longer be used:

TYPE_ORIENTATION (deprecated API 8) — Use SensorManager.getOrientation() with rotation matrix instead.
TYPE_TEMPERATURE (deprecated API 14) — Measured device temperature, not ambient. Replaced by TYPE_AMBIENT_TEMPERATURE.
Always check the minimum API level for each sensor type you use, and always handle the case where a sensor is not available on the device.

1.4 Setting Up a Compose-First Sensor Project

Let's set up the foundation project that we'll use throughout this chapter and build into PhoneCheckup.

Project Structure

Create a new Android project in Android Studio with the Empty Compose Activity template. Here's the dependency setup:

build.gradle.kts (app level):

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
}

android {
namespace = "com.example.phonecheckup"
compileSdk = 35

defaultConfig {
    applicationId = "com.example.phonecheckup"
    minSdk = 26
    targetSdk = 35
    versionCode = 1
    versionName = "1.0"
}

buildFeatures {
    compose = true
}

composeOptions {
    kotlinCompilerExtensionVersion = "1.5.14"
}

kotlinOptions {
    jvmTarget = "17"
}
Enter fullscreen mode Exit fullscreen mode

}

dependencies {
// Compose BOM
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.activity)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)

// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)

// Testing
testImplementation(libs.junit)
androidTestImplementation(composeBom)
androidTestImplementation(libs.compose.ui.test)
debugImplementation(libs.compose.ui.tooling)
Enter fullscreen mode Exit fullscreen mode

}
The SensorManager Wrapper

The raw SensorManager API is callback-based and lifecycle-unaware. We need a clean abstraction that works well with Compose's reactive model. Let's create a wrapper that exposes sensor data as Kotlin Flows:

package com.example.phonecheckup.sensor

import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
import javax.inject.Singleton

data class SensorData(
val values: FloatArray,
val accuracy: Int,
val timestamp: Long
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SensorData) return false
return values.contentEquals(other.values) &&
accuracy == other.accuracy &&
timestamp == other.timestamp
}

override fun hashCode(): Int {
    var result = values.contentHashCode()
    result = 31 * result + accuracy
    result = 31 * result + timestamp.hashCode()
    return result
}
Enter fullscreen mode Exit fullscreen mode

}

@singleton
class SensorManagerWrapper @Inject constructor(
@ApplicationContext private val context: Context
) {
private val sensorManager: SensorManager =
context.getSystemService(Context.SENSOR_SERVICE) as SensorManager

/**
 * Returns a list of all available sensors on this device.
 */
fun getAllSensors(): List<Sensor> {
    return sensorManager.getSensorList(Sensor.TYPE_ALL)
}

/**
 * Returns the default sensor for the given type, or null if not available.
 */
fun getSensor(type: Int): Sensor? {
    return sensorManager.getDefaultSensor(type)
}

/**
 * Returns true if the device has a sensor of the given type.
 */
fun isSensorAvailable(type: Int): Boolean {
    return sensorManager.getDefaultSensor(type) != null
}

/**
 * Observes sensor data as a Flow. Automatically registers when collected
 * and unregisters when the collector is cancelled.
 */
fun observeSensor(
    type: Int,
    samplingPeriod: Int = SensorManager.SENSOR_DELAY_UI
): Flow<SensorData> = callbackFlow {
    val sensor = sensorManager.getDefaultSensor(type)

    if (sensor == null) {
        close(IllegalStateException("Sensor type $type is not available"))
        return@callbackFlow
    }

    val listener = object : SensorEventListener {
        override fun onSensorChanged(event: SensorEvent) {
            trySend(
                SensorData(
                    values = event.values.copyOf(),
                    accuracy = event.accuracy,
                    timestamp = event.timestamp
                )
            )
        }

        override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
            // Accuracy changes are handled through SensorData.accuracy
        }
    }

    sensorManager.registerListener(listener, sensor, samplingPeriod)

    awaitClose {
        sensorManager.unregisterListener(listener)
    }
}
Enter fullscreen mode Exit fullscreen mode

}
There are several important design decisions in this wrapper worth understanding.

First, we use callbackFlow to bridge the callback-based SensorEventListener to Kotlin's Flow API. When a composable starts collecting this Flow, the sensor is registered. When the composable leaves the composition or the coroutine is cancelled, awaitClose fires and unregisters the listener. This prevents the most common sensor bug — forgetting to unregister.

Second, we call event.values.copyOf() instead of storing the reference directly. This is critical because Android reuses the same SensorEvent object across callbacks. If you store the reference, all your historical data will point to the same array with only the latest values.

Third, we use trySend instead of send because trySend is non-suspending. Sensor callbacks come from a system thread and should return quickly. If the collector is slow, trySend simply drops the event rather than blocking the sensor thread.

Permissions Model

Most physical sensors (accelerometer, gyroscope, magnetometer, light, pressure, proximity) require no runtime permissions. The system grants access by default. However, some sensor categories require explicit permissions:


Feature Declarations for Play Store Filtering

If your app requires a specific sensor to function, declare it as a hardware feature. The Play Store uses this to filter your app from devices that lack the sensor:


For PhoneCheckup, all sensor features should be required="false" since the whole point is to detect what's available on any device.

1.5 Integrating Sensors with Jetpack Compose

Now let's build the bridge between our SensorManagerWrapper and Jetpack Compose's reactive UI model.

Building rememberSensorState()

The cleanest way to consume sensor data in Compose is through a custom remember function that returns a State object:

package com.example.phonecheckup.sensor

import android.hardware.SensorManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext

/**

  • Remembers and observes sensor data as Compose State. *
  • Usage:
  • val accelData by rememberSensorState(Sensor.TYPE_ACCELEROMETER)
  • accelData?.let { data ->
  • Text("X: ${data.values[0]}")
  • }
    */
    @Composable
    fun rememberSensorState(
    sensorType: Int,
    samplingPeriod: Int = SensorManager.SENSOR_DELAY_UI,
    sensorManagerWrapper: SensorManagerWrapper
    ): State {
    val flow = remember(sensorType, samplingPeriod) {
    sensorManagerWrapper.observeSensor(sensorType, samplingPeriod)
    }

    return flow.collectAsState(initial = null)
    }
    Using this in a composable is as simple as:

@Composable
fun AccelerometerDisplay(sensorManagerWrapper: SensorManagerWrapper) {
val sensorData by rememberSensorState(
sensorType = Sensor.TYPE_ACCELEROMETER,
sensorManagerWrapper = sensorManagerWrapper
)

sensorData?.let { data ->
    Column {
        Text("X: ${"%.2f".format(data.values[0])} m/s²")
        Text("Y: ${"%.2f".format(data.values[1])} m/s²")
        Text("Z: ${"%.2f".format(data.values[2])} m/s²")
    }
} ?: Text("Accelerometer not available")
Enter fullscreen mode Exit fullscreen mode

}
Lifecycle-Aware Sensor Registration with DisposableEffect

Sometimes you need more control over sensor registration — for example, when you want to register in onResume and unregister in onPause. Here's a pattern using DisposableEffect and LifecycleEventObserver:

@Composable
fun LifecycleAwareSensor(
sensorType: Int,
sensorManagerWrapper: SensorManagerWrapper,
onSensorData: (SensorData) -> Unit
) {
val lifecycleOwner = LocalLifecycleOwner.current

DisposableEffect(lifecycleOwner, sensorType) {
    val sensorManager = /* get from context */
    val sensor = sensorManager.getDefaultSensor(sensorType)

    val listener = object : SensorEventListener {
        override fun onSensorChanged(event: SensorEvent) {
            onSensorData(
                SensorData(
                    values = event.values.copyOf(),
                    accuracy = event.accuracy,
                    timestamp = event.timestamp
                )
            )
        }
        override fun onAccuracyChanged(s: Sensor, accuracy: Int) {}
    }

    val observer = LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_RESUME -> {
                sensor?.let {
                    sensorManager.registerListener(
                        listener, it, SensorManager.SENSOR_DELAY_UI
                    )
                }
            }
            Lifecycle.Event.ON_PAUSE -> {
                sensorManager.unregisterListener(listener)
            }
            else -> {}
        }
    }

    lifecycleOwner.lifecycle.addObserver(observer)

    onDispose {
        sensorManager.unregisterListener(listener)
        lifecycleOwner.lifecycle.removeObserver(observer)
    }
}
Enter fullscreen mode Exit fullscreen mode

}
This pattern ensures sensors are only active when the UI is visible, which is exactly what you want for most user-facing sensor displays.

Handling Sensor Availability Gracefully

Not every device has every sensor. A well-designed sensor app handles missing sensors gracefully instead of crashing:

@Composable
fun SensorCard(
sensorType: Int,
sensorName: String,
sensorManagerWrapper: SensorManagerWrapper,
content: @Composable (SensorData) -> Unit
) {
val isAvailable = remember {
sensorManagerWrapper.isSensorAvailable(sensorType)
}

Card(
    modifier = Modifier
        .fillMaxWidth()
        .padding(8.dp),
    colors = CardDefaults.cardColors(
        containerColor = if (isAvailable)
            MaterialTheme.colorScheme.surface
        else
            MaterialTheme.colorScheme.surfaceVariant
    )
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween,
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(
                text = sensorName,
                style = MaterialTheme.typography.titleMedium
            )

            if (isAvailable) {
                Icon(
                    imageVector = Icons.Default.CheckCircle,
                    contentDescription = "Available",
                    tint = Color(0xFF34A853)
                )
            } else {
                Icon(
                    imageVector = Icons.Default.Cancel,
                    contentDescription = "Not Available",
                    tint = Color(0xFFEA4335)
                )
            }
        }

        if (isAvailable) {
            Spacer(modifier = Modifier.height(8.dp))
            val sensorData by rememberSensorState(
                sensorType = sensorType,
                sensorManagerWrapper = sensorManagerWrapper
            )
            sensorData?.let { content(it) }
        } else {
            Text(
                text = "This sensor is not available on your device",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

}
1.6 Project: Building PhoneCheckup

Now let's bring everything together into a complete, polished app. PhoneCheckup discovers all sensors, runs diagnostic tests, and generates a health report card.

App Architecture

We'll follow MVVM with Clean Architecture, using Hilt for dependency injection:

com.example.phonecheckup/
├── di/
│ └── SensorModule.kt // Hilt module providing SensorManagerWrapper
├── sensor/
│ ├── SensorManagerWrapper.kt // Our sensor abstraction (already built)
│ ├── SensorData.kt // Data classes
│ └── SensorDiagnostics.kt // Diagnostic test logic
├── ui/
│ ├── theme/
│ │ └── Theme.kt
│ ├── screens/
│ │ ├── SensorListScreen.kt // Main sensor discovery screen
│ │ ├── DiagnosticScreen.kt // Interactive diagnostic tests
│ │ └── ReportScreen.kt // Health report card
│ ├── components/
│ │ ├── SensorCard.kt // Expandable sensor card
│ │ ├── DiagnosticTestCard.kt // Interactive test UI
│ │ └── StatusIndicator.kt // Pass/Fail/Degraded indicator
│ └── viewmodel/
│ └── PhoneCheckupViewModel.kt // Main ViewModel
├── model/
│ ├── SensorInfo.kt // Sensor metadata model
│ ├── DiagnosticResult.kt // Test result model
│ └── SensorCategory.kt // Category grouping
└── MainActivity.kt
Data Models

First, let's define the data structures that drive the app:

package com.example.phonecheckup.model

import android.hardware.Sensor

enum class SensorCategory(val displayName: String) {
MOTION("Motion Sensors"),
ENVIRONMENT("Environmental Sensors"),
POSITION("Position Sensors"),
BIOMETRIC("Biometric Sensors"),
OTHER("Other Sensors")
}

enum class SensorStatus {
AVAILABLE, // Sensor exists and working
DEGRADED, // Sensor exists but accuracy is low
NOT_AVAILABLE, // Sensor not present on device
NOT_TESTED // Haven't run diagnostic yet
}

enum class DiagnosticStatus {
PENDING,
RUNNING,
PASSED,
FAILED,
SKIPPED
}

data class SensorInfo(
val type: Int,
val name: String,
val vendor: String,
val version: Int,
val maxRange: Float,
val resolution: Float,
val power: Float, // mA consumption
val minDelay: Int, // minimum delay between events in microseconds
val maxDelay: Int, // maximum delay between events in microseconds
val isWakeUp: Boolean,
val category: SensorCategory,
val status: SensorStatus = SensorStatus.NOT_TESTED,
val currentValues: FloatArray? = null
) {
val maxSamplingRateHz: Double
get() = if (minDelay > 0) 1_000_000.0 / minDelay else 0.0

val powerDescription: String
    get() = when {
        power < 0.5f -> "Ultra-low (${power} mA)"
        power < 1.0f -> "Low (${power} mA)"
        power < 3.0f -> "Moderate (${power} mA)"
        else -> "High (${power} mA)"
    }

override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (other !is SensorInfo) return false
    return type == other.type && name == other.name
}

override fun hashCode(): Int = 31 * type + name.hashCode()
Enter fullscreen mode Exit fullscreen mode

}

data class DiagnosticTest(
val sensorType: Int,
val testName: String,
val instruction: String, // "Rotate your phone 360°"
val durationMs: Long = 5000, // How long the test runs
val status: DiagnosticStatus = DiagnosticStatus.PENDING,
val result: String = "" // "Passed — all axes responsive"
)

data class HealthReport(
val deviceName: String,
val androidVersion: String,
val totalSensors: Int,
val availableSensors: Int,
val passedTests: Int,
val failedTests: Int,
val overallScore: Int, // 0-100
val sensorResults: List,
val diagnosticResults: List,
val timestamp: Long = System.currentTimeMillis()
)
Sensor Discovery and Categorization

The core logic for discovering and categorizing sensors:

package com.example.phonecheckup.sensor

import android.hardware.Sensor
import com.example.phonecheckup.model.SensorCategory
import com.example.phonecheckup.model.SensorInfo
import com.example.phonecheckup.model.SensorStatus
import javax.inject.Inject
import javax.inject.Singleton

@singleton
class SensorDiscovery @Inject constructor(
private val sensorManagerWrapper: SensorManagerWrapper
) {
/**
* Maps sensor types to human-friendly names.
*/
private val sensorTypeNames = mapOf(
Sensor.TYPE_ACCELEROMETER to "Accelerometer",
Sensor.TYPE_MAGNETIC_FIELD to "Magnetometer",
Sensor.TYPE_GYROSCOPE to "Gyroscope",
Sensor.TYPE_LIGHT to "Light Sensor",
Sensor.TYPE_PRESSURE to "Barometer",
Sensor.TYPE_PROXIMITY to "Proximity Sensor",
Sensor.TYPE_GRAVITY to "Gravity Sensor",
Sensor.TYPE_LINEAR_ACCELERATION to "Linear Acceleration",
Sensor.TYPE_ROTATION_VECTOR to "Rotation Vector",
Sensor.TYPE_RELATIVE_HUMIDITY to "Humidity Sensor",
Sensor.TYPE_AMBIENT_TEMPERATURE to "Temperature Sensor",
Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED to "Magnetometer (Uncalibrated)",
Sensor.TYPE_GAME_ROTATION_VECTOR to "Game Rotation Vector",
Sensor.TYPE_GYROSCOPE_UNCALIBRATED to "Gyroscope (Uncalibrated)",
Sensor.TYPE_SIGNIFICANT_MOTION to "Significant Motion",
Sensor.TYPE_STEP_DETECTOR to "Step Detector",
Sensor.TYPE_STEP_COUNTER to "Step Counter",
Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR to "Geomagnetic Rotation",
Sensor.TYPE_HEART_RATE to "Heart Rate",
Sensor.TYPE_STATIONARY_DETECT to "Stationary Detect",
Sensor.TYPE_MOTION_DETECT to "Motion Detect",
Sensor.TYPE_HEART_BEAT to "Heart Beat",
Sensor.TYPE_LOW_LATENCY_OFFBODY_DETECT to "Off-Body Detect",
Sensor.TYPE_ACCELEROMETER_UNCALIBRATED to "Accelerometer (Uncalibrated)",
Sensor.TYPE_HINGE_ANGLE to "Hinge Angle"
)

/**
 * Maps sensor types to categories.
 */
private fun categorize(type: Int): SensorCategory = when (type) {
    Sensor.TYPE_ACCELEROMETER,
    Sensor.TYPE_LINEAR_ACCELERATION,
    Sensor.TYPE_GRAVITY,
    Sensor.TYPE_GYROSCOPE,
    Sensor.TYPE_GYROSCOPE_UNCALIBRATED,
    Sensor.TYPE_ACCELEROMETER_UNCALIBRATED,
    Sensor.TYPE_SIGNIFICANT_MOTION,
    Sensor.TYPE_STEP_COUNTER,
    Sensor.TYPE_STEP_DETECTOR,
    Sensor.TYPE_MOTION_DETECT,
    Sensor.TYPE_STATIONARY_DETECT -> SensorCategory.MOTION

    Sensor.TYPE_LIGHT,
    Sensor.TYPE_PRESSURE,
    Sensor.TYPE_AMBIENT_TEMPERATURE,
    Sensor.TYPE_RELATIVE_HUMIDITY -> SensorCategory.ENVIRONMENT

    Sensor.TYPE_MAGNETIC_FIELD,
    Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED,
    Sensor.TYPE_PROXIMITY,
    Sensor.TYPE_ROTATION_VECTOR,
    Sensor.TYPE_GAME_ROTATION_VECTOR,
    Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR,
    Sensor.TYPE_HINGE_ANGLE -> SensorCategory.POSITION

    Sensor.TYPE_HEART_RATE,
    Sensor.TYPE_HEART_BEAT,
    Sensor.TYPE_LOW_LATENCY_OFFBODY_DETECT -> SensorCategory.BIOMETRIC

    else -> SensorCategory.OTHER
}

/**
 * Discovers all sensors on the device and returns them as SensorInfo objects,
 * grouped by category.
 */
fun discoverAll(): Map<SensorCategory, List<SensorInfo>> {
    val allSensors = sensorManagerWrapper.getAllSensors()

    return allSensors.map { sensor ->
        SensorInfo(
            type = sensor.type,
            name = sensorTypeNames[sensor.type] ?: sensor.name,
            vendor = sensor.vendor,
            version = sensor.version,
            maxRange = sensor.maximumRange,
            resolution = sensor.resolution,
            power = sensor.power,
            minDelay = sensor.minDelay,
            maxDelay = sensor.maxDelay,
            isWakeUp = sensor.isWakeUpSensor,
            category = categorize(sensor.type),
            status = SensorStatus.AVAILABLE
        )
    }
    .distinctBy { it.type }
    .groupBy { it.category }
    .toSortedMap(compareBy { it.ordinal })
}

/**
 * Checks which important sensors are missing from this device.
 */
fun findMissingSensors(): List<SensorInfo> {
    val importantTypes = listOf(
        Sensor.TYPE_ACCELEROMETER,
        Sensor.TYPE_GYROSCOPE,
        Sensor.TYPE_MAGNETIC_FIELD,
        Sensor.TYPE_LIGHT,
        Sensor.TYPE_PROXIMITY,
        Sensor.TYPE_PRESSURE,
        Sensor.TYPE_STEP_COUNTER,
        Sensor.TYPE_ROTATION_VECTOR
    )

    return importantTypes
        .filter { !sensorManagerWrapper.isSensorAvailable(it) }
        .map { type ->
            SensorInfo(
                type = type,
                name = sensorTypeNames[type] ?: "Unknown",
                vendor = "N/A",
                version = 0,
                maxRange = 0f,
                resolution = 0f,
                power = 0f,
                minDelay = 0,
                maxDelay = 0,
                isWakeUp = false,
                category = categorize(type),
                status = SensorStatus.NOT_AVAILABLE
            )
        }
}
Enter fullscreen mode Exit fullscreen mode

}
Diagnostic Tests Engine

The diagnostic engine runs interactive tests that verify sensors are actually working correctly — not just present:

package com.example.phonecheckup.sensor

import android.hardware.Sensor
import com.example.phonecheckup.model.DiagnosticStatus
import com.example.phonecheckup.model.DiagnosticTest
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
import kotlin.math.sqrt

@singleton
class SensorDiagnostics @Inject constructor(
private val sensorManagerWrapper: SensorManagerWrapper
) {
fun getAvailableTests(): List {
val tests = mutableListOf()

    if (sensorManagerWrapper.isSensorAvailable(Sensor.TYPE_ACCELEROMETER)) {
        tests.add(
            DiagnosticTest(
                sensorType = Sensor.TYPE_ACCELEROMETER,
                testName = "Accelerometer Test",
                instruction = "Place your phone flat on a table, then pick it up and tilt it in all directions.",
                durationMs = 6000
            )
        )
    }

    if (sensorManagerWrapper.isSensorAvailable(Sensor.TYPE_GYROSCOPE)) {
        tests.add(
            DiagnosticTest(
                sensorType = Sensor.TYPE_GYROSCOPE,
                testName = "Gyroscope Test",
                instruction = "Slowly rotate your phone 360° like turning a steering wheel.",
                durationMs = 8000
            )
        )
    }

    if (sensorManagerWrapper.isSensorAvailable(Sensor.TYPE_MAGNETIC_FIELD)) {
        tests.add(
            DiagnosticTest(
                sensorType = Sensor.TYPE_MAGNETIC_FIELD,
                testName = "Magnetometer Test",
                instruction = "Move your phone in a figure-8 pattern, then point it north.",
                durationMs = 8000
            )
        )
    }

    if (sensorManagerWrapper.isSensorAvailable(Sensor.TYPE_PROXIMITY)) {
        tests.add(
            DiagnosticTest(
                sensorType = Sensor.TYPE_PROXIMITY,
                testName = "Proximity Sensor Test",
                instruction = "Cover the top of your phone with your hand, then remove it.",
                durationMs = 6000
            )
        )
    }

    if (sensorManagerWrapper.isSensorAvailable(Sensor.TYPE_LIGHT)) {
        tests.add(
            DiagnosticTest(
                sensorType = Sensor.TYPE_LIGHT,
                testName = "Light Sensor Test",
                instruction = "Cover your phone screen completely, then uncover it to expose to light.",
                durationMs = 6000
            )
        )
    }

    if (sensorManagerWrapper.isSensorAvailable(Sensor.TYPE_PRESSURE)) {
        tests.add(
            DiagnosticTest(
                sensorType = Sensor.TYPE_PRESSURE,
                testName = "Barometer Test",
                instruction = "Hold your phone still. The barometer will check for stable pressure readings.",
                durationMs = 5000
            )
        )
    }

    return tests
}

/**
 * Runs the accelerometer diagnostic test.
 * Checks that all 3 axes respond to movement and that gravity is detected.
 */
suspend fun runAccelerometerTest(): DiagnosticTest {
    val test = DiagnosticTest(
        sensorType = Sensor.TYPE_ACCELEROMETER,
        testName = "Accelerometer Test",
        instruction = "Tilt your phone in all directions",
        status = DiagnosticStatus.RUNNING
    )

    var minX = Float.MAX_VALUE; var maxX = Float.MIN_VALUE
    var minY = Float.MAX_VALUE; var maxY = Float.MIN_VALUE
    var minZ = Float.MAX_VALUE; var maxZ = Float.MIN_VALUE

    val sensorFlow = sensorManagerWrapper.observeSensor(
        Sensor.TYPE_ACCELEROMETER
    )

    val result = withTimeoutOrNull(6000L) {
        var sampleCount = 0
        sensorFlow.collect { data ->
            val x = data.values[0]
            val y = data.values[1]
            val z = data.values[2]

            minX = minOf(minX, x); maxX = maxOf(maxX, x)
            minY = minOf(minY, y); maxY = maxOf(maxY, y)
            minZ = minOf(minZ, z); maxZ = maxOf(maxZ, z)

            sampleCount++
            if (sampleCount > 300) return@collect
        }
    }

    val xRange = maxX - minX
    val yRange = maxY - minY
    val zRange = maxZ - minZ

    val allAxesResponsive = xRange > 2f && yRange > 2f && zRange > 2f

    // Check if gravity magnitude is approximately 9.81 m/s²
    val gravityMagnitude = sqrt(
        (maxX * maxX + maxY * maxY + maxZ * maxZ).toDouble()
    ).toFloat()
    val gravityDetected = gravityMagnitude > 8f && gravityMagnitude < 12f

    val passed = allAxesResponsive && gravityDetected

    return test.copy(
        status = if (passed) DiagnosticStatus.PASSED else DiagnosticStatus.FAILED,
        result = if (passed) {
            "All 3 axes responsive. Gravity detected at ${"%.1f".format(gravityMagnitude)} m/s²."
        } else {
            buildString {
                if (!allAxesResponsive) append("Some axes not responding (X: ${"%.1f".format(xRange)}, Y: ${"%.1f".format(yRange)}, Z: ${"%.1f".format(zRange)}). ")
                if (!gravityDetected) append("Gravity reading abnormal (${"%.1f".format(gravityMagnitude)} m/s²). ")
            }
        }
    )
}

/**
 * Runs the proximity sensor diagnostic test.
 * Checks that the sensor can detect near and far states.
 */
suspend fun runProximityTest(): DiagnosticTest {
    val test = DiagnosticTest(
        sensorType = Sensor.TYPE_PROXIMITY,
        testName = "Proximity Sensor Test",
        instruction = "Cover and uncover the top of your phone",
        status = DiagnosticStatus.RUNNING
    )

    var sawNear = false
    var sawFar = false

    val sensorFlow = sensorManagerWrapper.observeSensor(
        Sensor.TYPE_PROXIMITY
    )

    withTimeoutOrNull(6000L) {
        sensorFlow.collect { data ->
            val distance = data.values[0]
            if (distance < 1f) sawNear = true
            if (distance > 3f) sawFar = true

            if (sawNear && sawFar) return@collect
        }
    }

    val passed = sawNear && sawFar

    return test.copy(
        status = if (passed) DiagnosticStatus.PASSED else DiagnosticStatus.FAILED,
        result = if (passed) {
            "Near and far states detected successfully."
        } else {
            buildString {
                if (!sawNear) append("Near state not detected — try covering the top sensor area. ")
                if (!sawFar) append("Far state not detected. ")
            }
        }
    )
}

/**
 * Runs the light sensor diagnostic test.
 * Checks that the sensor responds to light level changes.
 */
suspend fun runLightTest(): DiagnosticTest {
    val test = DiagnosticTest(
        sensorType = Sensor.TYPE_LIGHT,
        testName = "Light Sensor Test",
        instruction = "Cover then uncover your phone",
        status = DiagnosticStatus.RUNNING
    )

    var minLux = Float.MAX_VALUE
    var maxLux = Float.MIN_VALUE

    val sensorFlow = sensorManagerWrapper.observeSensor(
        Sensor.TYPE_LIGHT
    )

    withTimeoutOrNull(6000L) {
        var sampleCount = 0
        sensorFlow.collect { data ->
            val lux = data.values[0]
            minLux = minOf(minLux, lux)
            maxLux = maxOf(maxLux, lux)

            sampleCount++
            if (sampleCount > 200) return@collect
        }
    }

    val luxRange = maxLux - minLux
    val passed = luxRange > 50f // Should see significant change when covering/uncovering

    return test.copy(
        status = if (passed) DiagnosticStatus.PASSED else DiagnosticStatus.FAILED,
        result = if (passed) {
            "Light range detected: ${"%.0f".format(minLux)} – ${"%.0f".format(maxLux)} lux."
        } else {
            "Insufficient light variation detected (${"%.0f".format(luxRange)} lux). Try covering and uncovering the sensor."
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

}
The ViewModel

The ViewModel orchestrates the sensor discovery and diagnostic testing:

package com.example.phonecheckup.ui.viewmodel

import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.phonecheckup.model.*
import com.example.phonecheckup.sensor.SensorDiagnostics
import com.example.phonecheckup.sensor.SensorDiscovery
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

data class PhoneCheckupUiState(
val sensorsByCategory: Map> = emptyMap(),
val missingSensors: List = emptyList(),
val totalSensorCount: Int = 0,
val diagnosticTests: List = emptyList(),
val isRunningDiagnostics: Boolean = false,
val currentTestIndex: Int = -1,
val healthReport: HealthReport? = null,
val selectedTab: Int = 0
)

@HiltViewModel
class PhoneCheckupViewModel @Inject constructor(
private val sensorDiscovery: SensorDiscovery,
private val sensorDiagnostics: SensorDiagnostics
) : ViewModel() {

private val _uiState = MutableStateFlow(PhoneCheckupUiState())
val uiState: StateFlow<PhoneCheckupUiState> = _uiState.asStateFlow()

init {
    discoverSensors()
}

private fun discoverSensors() {
    val sensorsByCategory = sensorDiscovery.discoverAll()
    val missingSensors = sensorDiscovery.findMissingSensors()
    val totalCount = sensorsByCategory.values.sumOf { it.size }

    _uiState.update { it.copy(
        sensorsByCategory = sensorsByCategory,
        missingSensors = missingSensors,
        totalSensorCount = totalCount,
        diagnosticTests = sensorDiagnostics.getAvailableTests()
    )}
}

fun runAllDiagnostics() {
    viewModelScope.launch {
        _uiState.update { it.copy(isRunningDiagnostics = true, currentTestIndex = 0) }

        val results = mutableListOf<DiagnosticTest>()
        val tests = _uiState.value.diagnosticTests

        tests.forEachIndexed { index, test ->
            _uiState.update { it.copy(currentTestIndex = index) }

            val result = when (test.sensorType) {
                android.hardware.Sensor.TYPE_ACCELEROMETER ->
                    sensorDiagnostics.runAccelerometerTest()
                android.hardware.Sensor.TYPE_PROXIMITY ->
                    sensorDiagnostics.runProximityTest()
                android.hardware.Sensor.TYPE_LIGHT ->
                    sensorDiagnostics.runLightTest()
                else -> test.copy(status = DiagnosticStatus.SKIPPED)
            }

            results.add(result)
            _uiState.update { it.copy(diagnosticTests = results.toList()) }
        }

        _uiState.update { it.copy(
            isRunningDiagnostics = false,
            currentTestIndex = -1,
            diagnosticTests = results
        )}

        generateReport(results)
    }
}

private fun generateReport(diagnosticResults: List<DiagnosticTest>) {
    val state = _uiState.value
    val passedTests = diagnosticResults.count { it.status == DiagnosticStatus.PASSED }
    val failedTests = diagnosticResults.count { it.status == DiagnosticStatus.FAILED }
    val totalTests = diagnosticResults.size

    // Score: base 50 for having sensors, up to 50 more for passing tests
    val sensorScore = ((state.totalSensorCount.toFloat() / 20f) * 50).toInt().coerceAtMost(50)
    val testScore = if (totalTests > 0) {
        ((passedTests.toFloat() / totalTests) * 50).toInt()
    } else 0
    val overallScore = (sensorScore + testScore).coerceIn(0, 100)

    val report = HealthReport(
        deviceName = "${Build.MANUFACTURER} ${Build.MODEL}",
        androidVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
        totalSensors = state.totalSensorCount,
        availableSensors = state.totalSensorCount,
        passedTests = passedTests,
        failedTests = failedTests,
        overallScore = overallScore,
        sensorResults = state.sensorsByCategory.values.flatten(),
        diagnosticResults = diagnosticResults
    )

    _uiState.update { it.copy(healthReport = report) }
}

fun selectTab(index: Int) {
    _uiState.update { it.copy(selectedTab = index) }
}
Enter fullscreen mode Exit fullscreen mode

}
Main UI: Sensor List Screen

The main screen shows all discovered sensors in expandable cards grouped by category:

package com.example.phonecheckup.ui.screens

import android.hardware.Sensor
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.phonecheckup.model.SensorCategory
import com.example.phonecheckup.model.SensorInfo

@Composable
fun SensorListScreen(
sensorsByCategory: Map>,
missingSensors: List,
totalCount: Int
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Summary card
item {
SensorSummaryCard(
totalSensors = totalCount,
missingSensors = missingSensors.size
)
}

    // Sensor categories
    sensorsByCategory.forEach { (category, sensors) ->
        item {
            Text(
                text = category.displayName,
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
                color = MaterialTheme.colorScheme.primary
            )
        }

        items(sensors, key = { it.type }) { sensor ->
            ExpandableSensorCard(sensorInfo = sensor)
        }
    }

    // Missing sensors section
    if (missingSensors.isNotEmpty()) {
        item {
            Text(
                text = "Missing Sensors",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
                color = MaterialTheme.colorScheme.error
            )
        }

        items(missingSensors, key = { "missing_${it.type}" }) { sensor ->
            MissingSensorCard(sensorInfo = sensor)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

}

@Composable
fun SensorSummaryCard(totalSensors: Int, missingSensors: Int) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
SummaryItem(
count = totalSensors.toString(),
label = "Sensors Found",
color = MaterialTheme.colorScheme.primary
)
SummaryItem(
count = missingSensors.toString(),
label = "Missing",
color = if (missingSensors > 0)
MaterialTheme.colorScheme.error
else
Color(0xFF34A853)
)
}
}
}

@Composable
fun SummaryItem(count: String, label: String, color: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = count,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

@Composable
fun ExpandableSensorCard(sensorInfo: SensorInfo) {
var expanded by remember { mutableStateOf(false) }

Card(
    modifier = Modifier
        .fillMaxWidth()
        .clickable { expanded = !expanded }
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = sensorInfo.name,
                    style = MaterialTheme.typography.titleSmall,
                    fontWeight = FontWeight.SemiBold
                )
                Text(
                    text = sensorInfo.vendor,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }

            Row(verticalAlignment = Alignment.CenterVertically) {
                Icon(
                    imageVector = Icons.Default.CheckCircle,
                    contentDescription = "Available",
                    tint = Color(0xFF34A853),
                    modifier = Modifier.size(20.dp)
                )
                Spacer(modifier = Modifier.width(4.dp))
                Icon(
                    imageVector = if (expanded)
                        Icons.Default.ExpandLess
                    else
                        Icons.Default.ExpandMore,
                    contentDescription = "Expand"
                )
            }
        }

        AnimatedVisibility(
            visible = expanded,
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Column(modifier = Modifier.padding(top = 12.dp)) {
                HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))

                DetailRow("Max Range", "${sensorInfo.maxRange}")
                DetailRow("Resolution", "${sensorInfo.resolution}")
                DetailRow("Power", sensorInfo.powerDescription)
                DetailRow("Max Sampling Rate", "${"%.1f".format(sensorInfo.maxSamplingRateHz)} Hz")
                DetailRow("Wake-up Sensor", if (sensorInfo.isWakeUp) "Yes" else "No")
                DetailRow("Version", "${sensorInfo.version}")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

}

@Composable
fun DetailRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
}

@Composable
fun MissingSensorCard(sensorInfo: SensorInfo) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = sensorInfo.name,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error
)
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = "Not Available",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
}
}
Hilt Module

package com.example.phonecheckup.di

import android.content.Context
import com.example.phonecheckup.sensor.SensorManagerWrapper
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object SensorModule {

@Provides
@Singleton
fun provideSensorManagerWrapper(
    @ApplicationContext context: Context
): SensorManagerWrapper {
    return SensorManagerWrapper(context)
}
Enter fullscreen mode Exit fullscreen mode

}
Chapter Summary

In this chapter, you've built a solid foundation for everything that follows:

Sensor Framework Architecture — You understand how sensor data flows from hardware through the HAL to your Kotlin code via SensorManager and SensorEventListener.
Sensor Categories — You know the three families of sensors (motion, environmental, position), what each sensor type measures, and which ones are common vs. rare across devices.
Compose Integration — You built a reusable SensorManagerWrapper that exposes sensor data as Kotlin Flows, a rememberSensorState() composable for reactive sensor consumption, and lifecycle-aware registration patterns.
PhoneCheckup App — You built a practical app that discovers sensors, runs diagnostic tests, and generates health reports — the kind of app real people use when buying used phones.
In Chapter 2, we'll dive into the accelerometer and gyroscope in depth and build DriveSafe — an app that monitors driving behavior and scores trip safety.

Read More https://leanpub.com/master_android_sensors

Top comments (0)