Dev.to · 30 min read

Master Android Sensors with Jetpack Compose

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 } } // 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" } } 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) } 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 } } @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 { 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 = 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) } } } 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") } 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) } } } 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 ) } } } } 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() } 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 { 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 { 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 ) } } } 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." } ) } } 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 = _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() 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) { 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) } } } 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) } } } } @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}") } } } } } @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) } } 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

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News