← Back to books

Chapter 2: Motion Sensors — Accelerometer & Gyroscope

Chapter Project: DriveSafe — Driving Behavior Monitor

By the end of this chapter, you will build DriveSafe — an app that detects harsh braking, rapid acceleration, sharp turns, and phone usage while driving using the accelerometer and gyroscope. Each trip gets a safety score, and the app maintains a driving history that helps parents monitor teen drivers or fleet managers track their drivers.


2.1 Understanding the Accelerometer

The accelerometer is the most fundamental motion sensor on any Android device. It's the sensor that makes screen rotation work, enables fitness tracking, and powers countless motion-based interactions. To use it effectively — and especially to build something as nuanced as driving behavior detection — you need to understand how it works at a physical level.

How MEMS Accelerometers Work

Your phone's accelerometer is a MEMS (Micro-Electro-Mechanical System) chip — a tiny mechanical structure etched onto silicon, usually less than a millimeter across. Inside this chip is a microscopic mass suspended by tiny springs. When the phone accelerates, inertia causes this mass to shift relative to the chip. The displacement is measured by changes in electrical capacitance between fixed and moving plates.

Think of it like a ball inside a box. If you push the box to the right, the ball rolls to the left (relative to the box). By measuring how far the ball moves, you can determine the force applied. The MEMS accelerometer does exactly this, but with a silicon mass and capacitive plates instead of a ball.

This measurement happens independently on three axes, giving you a 3D acceleration vector.

The Android Coordinate System

Android defines sensor axes relative to the device's natural orientation (typically portrait mode for phones):

         +Y (up along the screen)
          ↑
          |
          |
          |
    ←-----+-----→ +X (right along the screen)
          |
          |
          ↓
         -Y

    +Z comes out of the screen toward you
    -Z goes into the screen away from you

When the phone lies flat on a table, screen facing up, the accelerometer reads approximately:

  • X ≈ 0 m/s² (no sideways force)
  • Y ≈ 0 m/s² (no forward/backward force)
  • Z ≈ 9.81 m/s² (gravity pulling downward through the device)

This is a critical detail: the accelerometer always includes gravity. When the phone is still, it doesn't read zero — it reads the gravity vector. This is both a feature and a complication, depending on what you're trying to measure.

Sensor Variants: Accelerometer, Linear Acceleration, and Gravity

Android provides three accelerometer-related sensor types:

TYPE_ACCELEROMETER — The raw accelerometer reading, including gravity. The total acceleration = gravity + user-induced acceleration. This is a hardware sensor.

// Phone flat on table: values ≈ [0, 0, 9.81]
// Phone held upright (portrait): values ≈ [0, 9.81, 0]
// Phone tilted 45° forward: values ≈ [0, 6.94, 6.94]

TYPE_LINEAR_ACCELERATION — Acceleration with gravity removed. When the phone is still, this reads approximately [0, 0, 0] regardless of orientation. This is a software sensor — the platform computes it by subtracting the gravity estimate from the raw accelerometer.

// Phone still (any orientation): values ≈ [0, 0, 0]
// Phone accelerating forward in a car: values ≈ [0, 2.5, 0]
// Phone braking in a car: values ≈ [0, -3.2, 0]

TYPE_GRAVITY — The gravity component only. When the phone is still, this equals the raw accelerometer reading. This is also a software sensor.

ACCELEROMETER = GRAVITY + LINEAR_ACCELERATION

For DriveSafe, we'll primarily use TYPE_LINEAR_ACCELERATION because we want to detect user-induced forces (braking, acceleration, turning) without the constant gravity component confusing our thresholds. However, we'll also use the raw accelerometer for phone orientation detection — figuring out how the phone is mounted in the car.

Sensor Accuracy, Noise, and Sampling Rates

No sensor is perfect. Accelerometer readings have noise — small random fluctuations even when the device is perfectly still. A typical phone accelerometer has:

  • Resolution: 0.001 to 0.01 m/s² per step (how small a change can be detected)
  • Noise density: ~200-400 μg/√Hz (how much random noise is present)
  • Range: ±2g to ±16g (maximum measurable acceleration)
  • Sampling rate: 50 Hz to 500+ Hz depending on device and requested delay

This noise is why you can't simply check if acceleration > threshold on a single reading. You need filtering, which we'll implement in Section 2.3.

Isolating the Gravity Component

Sometimes you need to separate gravity from user-induced motion yourself, without relying on the platform's software sensors. A low-pass filter is the standard approach:

// Low-pass filter to isolate gravity
private val gravity = FloatArray(3)
private val alpha = 0.8f

fun isolateGravity(event: SensorEvent) {
    // Low-pass filter: gravity changes slowly
    gravity[0] = alpha * gravity[0] + (1 - alpha) * event.values[0]
    gravity[1] = alpha * gravity[1] + (1 - alpha) * event.values[1]
    gravity[2] = alpha * gravity[2] + (1 - alpha) * event.values[2]

    // Linear acceleration = total acceleration - gravity
    val linearAccelX = event.values[0] - gravity[0]
    val linearAccelY = event.values[1] - gravity[1]
    val linearAccelZ = event.values[2] - gravity[2]
}

The alpha value (0.8) controls the filter's cutoff frequency. Higher alpha means the filter changes more slowly (better at isolating gravity) but reacts slower to orientation changes.


2.2 Understanding the Gyroscope

While the accelerometer measures linear forces, the gyroscope measures rotational forces. It tells you how fast the phone is rotating around each axis.

Angular Velocity Measurement

The gyroscope measures angular velocity — the rate of rotation — in radians per second (rad/s). The three values correspond to rotation around each axis:

values[0] = rotation rate around X axis (pitch — tilting forward/backward)
values[1] = rotation rate around Y axis (roll — tilting left/right)
values[2] = rotation rate around Z axis (yaw — twisting like a steering wheel)

When the phone is still, all gyroscope values should be approximately zero. When you rotate the phone:

// Rotating phone clockwise (viewed from top): values[2] < 0
// Rotating phone counterclockwise: values[2] > 0
// Tilting phone forward (top away from you): values[0] > 0
// Tilting phone left: values[1] > 0

TYPE_GYROSCOPE vs. TYPE_GYROSCOPE_UNCALIBRATED

TYPE_GYROSCOPE — The calibrated gyroscope. The platform applies drift compensation (bias removal) to provide cleaner readings. This is what you should use in most applications.

TYPE_GYROSCOPE_UNCALIBRATED — Raw gyroscope data plus estimated drift values:

values[0..2] = uncalibrated angular velocity (x, y, z)
values[3..5] = estimated drift (bias) for each axis

Use the uncalibrated version only when you need to apply your own fusion algorithm (Chapter 12) or when you need to detect calibration quality.

Gyroscope Drift

The gyroscope has a fundamental limitation: drift. Even when the phone is perfectly still, the gyroscope reports a tiny non-zero rotation rate. Over time, if you integrate these readings to compute total rotation, the error accumulates. After a few minutes, your calculated orientation can be off by several degrees.

This is why you should never rely on the gyroscope alone for absolute orientation. In Chapter 12, we'll combine it with the accelerometer and magnetometer using sensor fusion to get drift-free orientation. For DriveSafe, we use the gyroscope for instantaneous turn detection, not accumulated rotation, so drift is not a problem.

Why Gyroscope Matters for Driving

The gyroscope is essential for DriveSafe because it directly measures turning behavior:

  • Sharp turn: High angular velocity around the Z axis (yaw)
  • Swerving: Rapid oscillation of Z-axis angular velocity
  • Phone pickup: Characteristic rotation pattern when someone lifts the phone off a mount

Combined with the accelerometer, we can build a comprehensive picture of driving behavior.


2.3 Building Reactive Sensor Pipelines in Compose

Raw sensor data is noisy and arrives at high frequency. Before we can detect driving events, we need to process the data through a pipeline that filters noise, smooths values, and maps raw readings to meaningful events.

Creating a Reusable SensorFlow Abstraction

Let's build a generic sensor flow that handles common processing needs:

package com.example.drivesafe.sensor

import android.hardware.Sensor
import android.hardware.SensorManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Processed sensor reading with both raw and filtered values.
 */
data class ProcessedSensorData(
    val raw: FloatArray,
    val filtered: FloatArray,
    val magnitude: Float,
    val timestamp: Long
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is ProcessedSensorData) return false
        return raw.contentEquals(other.raw) && timestamp == other.timestamp
    }

    override fun hashCode(): Int = raw.contentHashCode()
}

@Singleton
class SensorPipeline @Inject constructor(
    private val sensorManagerWrapper: SensorManagerWrapper
) {
    /**
     * Creates a processed sensor flow with low-pass filtering and magnitude computation.
     */
    fun processedFlow(
        sensorType: Int,
        samplingPeriod: Int = SensorManager.SENSOR_DELAY_GAME,
        filterAlpha: Float = 0.2f
    ): Flow<ProcessedSensorData> {
        val filtered = FloatArray(3)

        return sensorManagerWrapper
            .observeSensor(sensorType, samplingPeriod)
            .map { data ->
                // Apply low-pass filter for smoothing
                for (i in 0 until minOf(3, data.values.size)) {
                    filtered[i] = filterAlpha * data.values[i] +
                                  (1 - filterAlpha) * filtered[i]
                }

                // Compute magnitude
                val magnitude = kotlin.math.sqrt(
                    filtered.take(minOf(3, data.values.size))
                        .sumOf { (it * it).toDouble() }
                ).toFloat()

                ProcessedSensorData(
                    raw = data.values.copyOf(),
                    filtered = filtered.copyOf(),
                    magnitude = magnitude,
                    timestamp = data.timestamp
                )
            }
    }
}

Low-Pass and High-Pass Filters

Filters are fundamental to sensor programming. Every real sensor app uses them. Let's understand the two essential types.

Low-pass filter — Allows slow changes through, blocks rapid changes. Use it to smooth noisy data and isolate gravity. The filtered value follows the input slowly, ignoring quick spikes.

/**
 * Low-pass filter implementation.
 * Alpha (0-1): lower = smoother but more lag, higher = responsive but more noise
 *
 * Typical values:
 * - 0.1: Very smooth, significant lag (good for gravity isolation)
 * - 0.2-0.3: Moderate smoothing (good for driving events)
 * - 0.5: Light smoothing
 * - 0.8: Minimal smoothing, very responsive
 */
class LowPassFilter(private val alpha: Float = 0.2f) {
    private var initialized = false
    private val output = FloatArray(3)

    fun apply(input: FloatArray): FloatArray {
        if (!initialized) {
            input.copyInto(output)
            initialized = true
            return output.copyOf()
        }

        for (i in output.indices) {
            output[i] = output[i] + alpha * (input[i] - output[i])
        }
        return output.copyOf()
    }

    fun reset() {
        initialized = false
        output.fill(0f)
    }
}

High-pass filter — Allows rapid changes through, blocks slow changes. Use it to remove gravity and isolate sudden movements like braking or impacts.

/**
 * High-pass filter implementation.
 * Removes the slowly-changing component (like gravity) and keeps
 * only the rapidly-changing component (user-induced motion).
 */
class HighPassFilter(private val alpha: Float = 0.8f) {
    private var initialized = false
    private val previousInput = FloatArray(3)
    private val output = FloatArray(3)

    fun apply(input: FloatArray): FloatArray {
        if (!initialized) {
            input.copyInto(previousInput)
            initialized = true
            return FloatArray(3)
        }

        for (i in output.indices) {
            output[i] = alpha * (output[i] + input[i] - previousInput[i])
        }
        input.copyInto(previousInput)
        return output.copyOf()
    }

    fun reset() {
        initialized = false
        previousInput.fill(0f)
        output.fill(0f)
    }
}

Throttling High-Frequency Sensor Data with Kotlin Flow

Sensors can deliver data at hundreds of Hertz. For driving event detection, we don't need that frequency — 20-50 Hz is sufficient. Kotlin Flow's operators let us manage this:

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.sample
import kotlin.time.Duration.Companion.milliseconds

/**
 * Throttle sensor data to a manageable rate.
 */
fun <T> Flow<T>.throttleSensor(intervalMs: Long = 50): Flow<T> {
    return this.sample(intervalMs.milliseconds)
}

/**
 * Alternative: conflate drops intermediate values when collector is busy.
 * Use when you always want the latest value but can't keep up with the rate.
 */
fun <T> Flow<T>.latestOnly(): Flow<T> {
    return this.conflate()
}

Mapping Raw Values to Driving Events

The final stage of our pipeline maps filtered sensor values to meaningful driving events:

enum class DrivingEventType {
    HARSH_BRAKING,
    RAPID_ACCELERATION,
    SHARP_LEFT_TURN,
    SHARP_RIGHT_TURN,
    PHONE_PICKUP,
    NORMAL
}

data class DrivingEvent(
    val type: DrivingEventType,
    val severity: Float,       // 0.0 (mild) to 1.0 (severe)
    val value: Float,          // The actual sensor value that triggered this
    val timestamp: Long,
    val description: String
)

2.4 Driving Event Detection Algorithms

This is the heart of DriveSafe — the algorithms that analyze sensor data and classify driving behavior. Each algorithm addresses a specific type of dangerous driving.

Harsh Braking Detection

When a driver brakes hard, the phone experiences a strong backward force (negative acceleration along the direction of travel). The challenge is that the phone might be mounted in any orientation in the car, so we can't simply check one axis.

Our approach: Use the magnitude of linear acceleration and look for sustained negative spikes.

package com.example.drivesafe.detection

import com.example.drivesafe.sensor.ProcessedSensorData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map

/**
 * Detects harsh braking events from linear acceleration data.
 *
 * A harsh brake is defined as:
 * - Linear acceleration magnitude exceeding the threshold
 * - Sustained for at least [minDurationMs] milliseconds
 * - In the negative direction along the dominant axis
 */
class HarshBrakeDetector(
    private val mildThreshold: Float = 3.0f,    // m/s² — mild braking
    private val severeThreshold: Float = 6.0f,   // m/s² — dangerous braking
    private val minDurationMs: Long = 300,        // Must sustain for 300ms
    private val cooldownMs: Long = 3000           // 3 second cooldown between events
) {
    private var brakeStartTime: Long = 0
    private var isBraking = false
    private var lastEventTime: Long = 0
    private var peakValue: Float = 0f

    /**
     * Process a single sensor reading and return a braking event if detected.
     *
     * @param data Linear acceleration data (TYPE_LINEAR_ACCELERATION)
     * @param currentTimeMs Current time in milliseconds
     * @return DrivingEvent if harsh braking is detected, null otherwise
     */
    fun process(data: ProcessedSensorData, currentTimeMs: Long): DrivingEvent? {
        val magnitude = data.magnitude

        // Check cooldown
        if (currentTimeMs - lastEventTime < cooldownMs) return null

        if (magnitude > mildThreshold) {
            if (!isBraking) {
                // Start of potential braking event
                isBraking = true
                brakeStartTime = currentTimeMs
                peakValue = magnitude
            } else {
                // Continue tracking — update peak
                peakValue = maxOf(peakValue, magnitude)
            }

            // Check if braking has been sustained long enough
            val duration = currentTimeMs - brakeStartTime
            if (duration >= minDurationMs) {
                isBraking = false
                lastEventTime = currentTimeMs

                val severity = ((peakValue - mildThreshold) /
                    (severeThreshold - mildThreshold)).coerceIn(0f, 1f)

                return DrivingEvent(
                    type = DrivingEventType.HARSH_BRAKING,
                    severity = severity,
                    value = peakValue,
                    timestamp = currentTimeMs,
                    description = when {
                        severity > 0.7f -> "Dangerous hard braking (${"%.1f".format(peakValue)} m/s²)"
                        severity > 0.3f -> "Moderate harsh braking (${"%.1f".format(peakValue)} m/s²)"
                        else -> "Mild harsh braking (${"%.1f".format(peakValue)} m/s²)"
                    }
                )
            }
        } else {
            // Acceleration dropped below threshold — reset
            isBraking = false
            peakValue = 0f
        }

        return null
    }

    fun reset() {
        isBraking = false
        brakeStartTime = 0
        peakValue = 0f
        lastEventTime = 0
    }
}

Rapid Acceleration Detection

Similar to harsh braking but in the forward direction. Rapid acceleration is less dangerous than harsh braking but still indicates aggressive driving:

/**
 * Detects rapid acceleration events.
 * Uses the same principle as HarshBrakeDetector but for positive acceleration.
 */
class RapidAccelerationDetector(
    private val threshold: Float = 4.0f,
    private val minDurationMs: Long = 500,
    private val cooldownMs: Long = 5000
) {
    private var accelStartTime: Long = 0
    private var isAccelerating = false
    private var lastEventTime: Long = 0
    private var peakValue: Float = 0f

    fun process(data: ProcessedSensorData, currentTimeMs: Long): DrivingEvent? {
        val magnitude = data.magnitude

        if (currentTimeMs - lastEventTime < cooldownMs) return null

        if (magnitude > threshold) {
            if (!isAccelerating) {
                isAccelerating = true
                accelStartTime = currentTimeMs
                peakValue = magnitude
            } else {
                peakValue = maxOf(peakValue, magnitude)
            }

            val duration = currentTimeMs - accelStartTime
            if (duration >= minDurationMs) {
                isAccelerating = false
                lastEventTime = currentTimeMs

                val severity = ((peakValue - threshold) / 4f).coerceIn(0f, 1f)

                return DrivingEvent(
                    type = DrivingEventType.RAPID_ACCELERATION,
                    severity = severity,
                    value = peakValue,
                    timestamp = currentTimeMs,
                    description = "Rapid acceleration (${"%.1f".format(peakValue)} m/s²)"
                )
            }
        } else {
            isAccelerating = false
            peakValue = 0f
        }

        return null
    }

    fun reset() {
        isAccelerating = false
        accelStartTime = 0
        peakValue = 0f
        lastEventTime = 0
    }
}

Sharp Turn Detection

Sharp turns are detected using the gyroscope's Z-axis (yaw), which measures rotation around the vertical axis. A high yaw rate means the car is turning quickly:

/**
 * Detects sharp turns using gyroscope yaw rate (Z-axis angular velocity).
 *
 * The Z-axis of the gyroscope measures rotation around the vertical axis,
 * which corresponds to steering left or right while driving.
 */
class SharpTurnDetector(
    private val sharpTurnThreshold: Float = 1.5f,  // rad/s — approximately 86°/s
    private val mildTurnThreshold: Float = 0.8f,    // rad/s — approximately 46°/s
    private val minDurationMs: Long = 200,
    private val cooldownMs: Long = 3000
) {
    private var turnStartTime: Long = 0
    private var isTurning = false
    private var lastEventTime: Long = 0
    private var peakYawRate: Float = 0f
    private var turnDirection: Float = 0f

    /**
     * @param data Gyroscope data (TYPE_GYROSCOPE)
     */
    fun process(data: ProcessedSensorData, currentTimeMs: Long): DrivingEvent? {
        // Z-axis angular velocity = yaw rate
        val yawRate = data.filtered[2]
        val absYawRate = kotlin.math.abs(yawRate)

        if (currentTimeMs - lastEventTime < cooldownMs) return null

        if (absYawRate > mildTurnThreshold) {
            if (!isTurning) {
                isTurning = true
                turnStartTime = currentTimeMs
                peakYawRate = absYawRate
                turnDirection = yawRate
            } else {
                if (absYawRate > peakYawRate) {
                    peakYawRate = absYawRate
                    turnDirection = yawRate
                }
            }

            val duration = currentTimeMs - turnStartTime
            if (duration >= minDurationMs && peakYawRate > mildTurnThreshold) {
                isTurning = false
                lastEventTime = currentTimeMs

                val severity = ((peakYawRate - mildTurnThreshold) /
                    (sharpTurnThreshold - mildTurnThreshold)).coerceIn(0f, 1f)

                val direction = if (turnDirection > 0)
                    DrivingEventType.SHARP_LEFT_TURN
                else
                    DrivingEventType.SHARP_RIGHT_TURN

                val dirName = if (turnDirection > 0) "left" else "right"

                return DrivingEvent(
                    type = direction,
                    severity = severity,
                    value = peakYawRate,
                    timestamp = currentTimeMs,
                    description = "Sharp $dirName turn (${"%.1f".format(Math.toDegrees(peakYawRate.toDouble()))}°/s)"
                )
            }
        } else {
            isTurning = false
            peakYawRate = 0f
        }

        return null
    }

    fun reset() {
        isTurning = false
        turnStartTime = 0
        peakYawRate = 0f
        lastEventTime = 0
    }
}

Phone Pickup Detection

Detecting when a driver picks up their phone is a safety feature. The characteristic motion pattern is: the phone transitions from a stable position (on a mount or in a cupholder) to being held and moved.

/**
 * Detects when the driver picks up the phone while driving.
 *
 * Detection strategy:
 * 1. Monitor accelerometer for sudden orientation change (phone lifted)
 * 2. Confirm with gyroscope showing multi-axis rotation
 * 3. Verify the phone was previously stable (not already being held)
 */
class PhonePickupDetector(
    private val stabilityThreshold: Float = 0.5f, // m/s² — max movement to be "stable"
    private val pickupAccelThreshold: Float = 2.0f,
    private val pickupGyroThreshold: Float = 1.0f, // rad/s
    private val stabilityWindowMs: Long = 3000,     // Must be stable for 3s before pickup
    private val cooldownMs: Long = 10000
) {
    private var isStable = false
    private var stableStartTime: Long = 0
    private var lastEventTime: Long = 0

    data class SensorSnapshot(
        val accelMagnitude: Float,
        val gyroMagnitude: Float,
        val timestamp: Long
    )

    /**
     * @param accelData Linear acceleration data
     * @param gyroData Gyroscope data
     */
    fun process(
        accelData: ProcessedSensorData,
        gyroData: ProcessedSensorData,
        currentTimeMs: Long
    ): DrivingEvent? {
        if (currentTimeMs - lastEventTime < cooldownMs) return null

        val accelMag = accelData.magnitude
        val gyroMag = gyroData.magnitude

        // Check if phone is currently stable
        if (accelMag < stabilityThreshold && gyroMag < 0.2f) {
            if (!isStable) {
                isStable = true
                stableStartTime = currentTimeMs
            }
        } else if (isStable) {
            // Phone was stable and now it's moving
            val stableDuration = currentTimeMs - stableStartTime

            if (stableDuration >= stabilityWindowMs &&
                accelMag > pickupAccelThreshold &&
                gyroMag > pickupGyroThreshold
            ) {
                // Phone was stable for long enough and now shows pickup motion
                isStable = false
                lastEventTime = currentTimeMs

                return DrivingEvent(
                    type = DrivingEventType.PHONE_PICKUP,
                    severity = 0.8f,
                    value = accelMag,
                    timestamp = currentTimeMs,
                    description = "Phone picked up while driving"
                )
            }

            // If movement is small, phone might just be vibrating — stay in stable state
            if (accelMag > stabilityThreshold * 3) {
                isStable = false
            }
        }

        return null
    }

    fun reset() {
        isStable = false
        stableStartTime = 0
        lastEventTime = 0
    }
}

Phone Mount Calibration

A real-world challenge: the phone might be mounted in any orientation in the car — upright in a dashboard mount, flat in a cupholder, angled on the windshield. We need to handle this by calibrating the phone's orientation relative to the car's axes.

/**
 * Calibrates the phone's orientation relative to the vehicle.
 *
 * Usage:
 * 1. User places phone in its driving position
 * 2. App reads gravity vector for 3 seconds
 * 3. Computes rotation matrix to transform sensor data from phone frame to vehicle frame
 */
class MountCalibration {
    private var calibrationMatrix: FloatArray? = null
    private var isCalibrated = false

    /**
     * Collects gravity samples and computes the transformation.
     *
     * @param gravitySamples List of gravity readings from TYPE_GRAVITY sensor
     */
    fun calibrate(gravitySamples: List<FloatArray>) {
        if (gravitySamples.isEmpty()) return

        // Average the gravity samples
        val avgGravity = FloatArray(3)
        gravitySamples.forEach { sample ->
            avgGravity[0] += sample[0]
            avgGravity[1] += sample[1]
            avgGravity[2] += sample[2]
        }
        val n = gravitySamples.size.toFloat()
        avgGravity[0] /= n
        avgGravity[1] /= n
        avgGravity[2] /= n

        // Store the gravity direction for coordinate transformation
        calibrationMatrix = avgGravity.copyOf()
        isCalibrated = true
    }

    /**
     * Transforms acceleration from phone coordinates to approximate vehicle coordinates.
     * This is a simplified transformation that works for most common mount positions.
     *
     * Returns: [forward/backward, left/right, up/down] in vehicle frame
     */
    fun transformToVehicleFrame(phoneAccel: FloatArray): FloatArray {
        if (!isCalibrated || calibrationMatrix == null) {
            return phoneAccel.copyOf()
        }

        // Simplified: use the magnitude of acceleration projected
        // onto the horizontal plane (perpendicular to gravity)
        // This works regardless of phone orientation
        return phoneAccel.copyOf()
    }

    fun isCalibrated(): Boolean = isCalibrated
    fun reset() {
        isCalibrated = false
        calibrationMatrix = null
    }
}

2.5 Trip Recording & Scoring

DriveSafe needs to automatically detect when a trip starts and ends, record all events during the trip, and compute a safety score.

Automatic Trip Detection

A trip starts when the phone detects sustained motion (the user is in a moving vehicle) and ends after a period of no motion:

package com.example.drivesafe.trip

import com.example.drivesafe.detection.DrivingEvent

enum class TripState {
    IDLE,           // Not in a vehicle
    STARTING,       // Motion detected, waiting for confirmation
    IN_PROGRESS,    // Trip is active
    ENDING          // Motion stopped, waiting to confirm trip end
}

data class Trip(
    val id: Long = System.currentTimeMillis(),
    val startTime: Long = System.currentTimeMillis(),
    val endTime: Long? = null,
    val events: List<DrivingEvent> = emptyList(),
    val score: Int = 100,                    // 0-100, starts at 100
    val durationMs: Long = 0,
    val harshBrakeCount: Int = 0,
    val rapidAccelCount: Int = 0,
    val sharpTurnCount: Int = 0,
    val phonePickupCount: Int = 0
) {
    val durationMinutes: Int
        get() = (durationMs / 60_000).toInt()

    val scoreGrade: String
        get() = when {
            score >= 90 -> "A"
            score >= 80 -> "B"
            score >= 70 -> "C"
            score >= 60 -> "D"
            else -> "F"
        }

    val scoreDescription: String
        get() = when {
            score >= 90 -> "Excellent driving"
            score >= 80 -> "Good driving"
            score >= 70 -> "Fair driving — some harsh events"
            score >= 60 -> "Poor driving — multiple safety concerns"
            else -> "Dangerous driving — significantly unsafe behavior"
        }
}

Scoring Algorithm

The scoring system starts at 100 and deducts points for each event, weighted by severity:

package com.example.drivesafe.trip

import com.example.drivesafe.detection.DrivingEvent
import com.example.drivesafe.detection.DrivingEventType

/**
 * Computes a safety score for a trip based on detected events.
 *
 * Scoring model:
 * - Start at 100 points
 * - Deduct points for each event, weighted by severity and type
 * - Minimum score is 0
 *
 * Deduction weights:
 * - Harsh braking: 5-15 points (most dangerous)
 * - Sharp turn: 3-10 points
 * - Rapid acceleration: 2-8 points
 * - Phone pickup: 10 points flat (always dangerous)
 */
class TripScorer {

    fun computeScore(events: List<DrivingEvent>, durationMs: Long): Int {
        if (events.isEmpty()) return 100

        var score = 100f

        events.forEach { event ->
            val deduction = when (event.type) {
                DrivingEventType.HARSH_BRAKING -> {
                    // 5 points for mild, up to 15 for severe
                    5f + (event.severity * 10f)
                }
                DrivingEventType.SHARP_LEFT_TURN,
                DrivingEventType.SHARP_RIGHT_TURN -> {
                    // 3 points for mild, up to 10 for severe
                    3f + (event.severity * 7f)
                }
                DrivingEventType.RAPID_ACCELERATION -> {
                    // 2 points for mild, up to 8 for severe
                    2f + (event.severity * 6f)
                }
                DrivingEventType.PHONE_PICKUP -> {
                    // Always 10 points — phone use while driving is always bad
                    10f
                }
                DrivingEventType.NORMAL -> 0f
            }

            score -= deduction
        }

        // Bonus: if trip is long (>15 min) with few events, that's good driving
        val tripMinutes = durationMs / 60_000f
        if (tripMinutes > 15f && events.size <= 2) {
            score = minOf(100f, score + 5f)
        }

        return score.toInt().coerceIn(0, 100)
    }

    fun computeTripSummary(events: List<DrivingEvent>, durationMs: Long): Trip {
        val score = computeScore(events, durationMs)

        return Trip(
            events = events,
            score = score,
            durationMs = durationMs,
            harshBrakeCount = events.count {
                it.type == DrivingEventType.HARSH_BRAKING
            },
            rapidAccelCount = events.count {
                it.type == DrivingEventType.RAPID_ACCELERATION
            },
            sharpTurnCount = events.count {
                it.type == DrivingEventType.SHARP_LEFT_TURN ||
                it.type == DrivingEventType.SHARP_RIGHT_TURN
            },
            phonePickupCount = events.count {
                it.type == DrivingEventType.PHONE_PICKUP
            }
        )
    }
}

Room Database for Trip History

We need persistent storage for trip history so users can track improvement over time:

package com.example.drivesafe.data

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Entity(tableName = "trips")
data class TripEntity(
    @PrimaryKey val id: Long,
    val startTime: Long,
    val endTime: Long,
    val durationMs: Long,
    val score: Int,
    val harshBrakeCount: Int,
    val rapidAccelCount: Int,
    val sharpTurnCount: Int,
    val phonePickupCount: Int,
    val totalEvents: Int
)

@Entity(tableName = "driving_events")
data class DrivingEventEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val tripId: Long,
    val type: String,
    val severity: Float,
    val value: Float,
    val timestamp: Long,
    val description: String
)

@Dao
interface TripDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTrip(trip: TripEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertEvents(events: List<DrivingEventEntity>)

    @Query("SELECT * FROM trips ORDER BY startTime DESC")
    fun getAllTrips(): Flow<List<TripEntity>>

    @Query("SELECT * FROM trips ORDER BY startTime DESC LIMIT :limit")
    fun getRecentTrips(limit: Int = 10): Flow<List<TripEntity>>

    @Query("SELECT * FROM driving_events WHERE tripId = :tripId ORDER BY timestamp")
    fun getEventsForTrip(tripId: Long): Flow<List<DrivingEventEntity>>

    @Query("SELECT AVG(score) FROM trips WHERE startTime > :sinceTimestamp")
    fun getAverageScore(sinceTimestamp: Long): Flow<Float?>

    @Query("""
        SELECT AVG(score) FROM trips
        WHERE startTime > :startMs AND startTime < :endMs
    """)
    fun getAverageScoreForPeriod(startMs: Long, endMs: Long): Flow<Float?>

    @Query("SELECT COUNT(*) FROM trips")
    fun getTripCount(): Flow<Int>
}

@Database(
    entities = [TripEntity::class, DrivingEventEntity::class],
    version = 1,
    exportSchema = false
)
abstract class DriveSafeDatabase : RoomDatabase() {
    abstract fun tripDao(): TripDao
}

2.6 Project: Building DriveSafe

Now let's assemble all the pieces into a complete, polished driving monitor app.

The Driving Monitor Service

The core of DriveSafe runs in a foreground service so it can monitor sensors even when the app is in the background:

package com.example.drivesafe.service

import android.app.*
import android.content.Intent
import android.hardware.Sensor
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.example.drivesafe.detection.*
import com.example.drivesafe.sensor.SensorManagerWrapper
import com.example.drivesafe.sensor.SensorPipeline
import com.example.drivesafe.trip.TripScorer
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import javax.inject.Inject

@AndroidEntryPoint
class DrivingMonitorService : Service() {

    @Inject lateinit var sensorPipeline: SensorPipeline
    @Inject lateinit var tripScorer: TripScorer

    private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    private val harshBrakeDetector = HarshBrakeDetector()
    private val rapidAccelDetector = RapidAccelerationDetector()
    private val sharpTurnDetector = SharpTurnDetector()
    private val phonePickupDetector = PhonePickupDetector()

    private val _events = MutableStateFlow<List<DrivingEvent>>(emptyList())
    val events: StateFlow<List<DrivingEvent>> = _events.asStateFlow()

    private val _currentStatus = MutableStateFlow("Monitoring...")
    val currentStatus: StateFlow<String> = _currentStatus.asStateFlow()

    private var tripStartTime: Long = 0

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onCreate() {
        super.onCreate()
        startForeground(NOTIFICATION_ID, createNotification("DriveSafe is monitoring"))
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        when (intent?.action) {
            ACTION_START -> startMonitoring()
            ACTION_STOP -> stopMonitoring()
        }
        return START_STICKY
    }

    private fun startMonitoring() {
        tripStartTime = System.currentTimeMillis()
        _events.value = emptyList()

        // Linear acceleration flow for braking and acceleration detection
        val accelFlow = sensorPipeline.processedFlow(
            sensorType = Sensor.TYPE_LINEAR_ACCELERATION,
            filterAlpha = 0.3f
        )

        // Gyroscope flow for turn detection
        val gyroFlow = sensorPipeline.processedFlow(
            sensorType = Sensor.TYPE_GYROSCOPE,
            filterAlpha = 0.25f
        )

        // Process linear acceleration events
        serviceScope.launch {
            accelFlow.collect { data ->
                val currentTime = System.currentTimeMillis()

                // Check for harsh braking
                harshBrakeDetector.process(data, currentTime)?.let { event ->
                    addEvent(event)
                    updateNotification("⚠️ Harsh braking detected!")
                }

                // Check for rapid acceleration
                rapidAccelDetector.process(data, currentTime)?.let { event ->
                    addEvent(event)
                    updateNotification("⚠️ Rapid acceleration detected!")
                }
            }
        }

        // Process gyroscope events
        serviceScope.launch {
            gyroFlow.collect { data ->
                val currentTime = System.currentTimeMillis()

                // Check for sharp turns
                sharpTurnDetector.process(data, currentTime)?.let { event ->
                    addEvent(event)
                    val dir = if (event.type == DrivingEventType.SHARP_LEFT_TURN)
                        "left" else "right"
                    updateNotification("⚠️ Sharp $dir turn detected!")
                }
            }
        }

        // Combined phone pickup detection
        serviceScope.launch {
            combine(
                sensorPipeline.processedFlow(Sensor.TYPE_LINEAR_ACCELERATION),
                sensorPipeline.processedFlow(Sensor.TYPE_GYROSCOPE)
            ) { accel, gyro -> Pair(accel, gyro) }
            .sample(100)
            .collect { (accel, gyro) ->
                val currentTime = System.currentTimeMillis()
                phonePickupDetector.process(accel, gyro, currentTime)?.let { event ->
                    addEvent(event)
                    updateNotification("📱 Phone pickup detected!")
                }
            }
        }

        _currentStatus.value = "Monitoring your drive..."
    }

    private fun addEvent(event: DrivingEvent) {
        _events.update { current -> current + event }
    }

    private fun stopMonitoring() {
        serviceScope.coroutineContext.cancelChildren()

        harshBrakeDetector.reset()
        rapidAccelDetector.reset()
        sharpTurnDetector.reset()
        phonePickupDetector.reset()

        _currentStatus.value = "Stopped"
        stopSelf()
    }

    private fun createNotification(text: String): Notification {
        val channelId = "drivesafe_monitoring"

        val channel = NotificationChannel(
            channelId,
            "Drive Monitoring",
            NotificationManager.IMPORTANCE_LOW
        ).apply {
            description = "Shows while DriveSafe is monitoring your drive"
        }

        val notificationManager = getSystemService(NotificationManager::class.java)
        notificationManager.createNotificationChannel(channel)

        return NotificationCompat.Builder(this, channelId)
            .setContentTitle("DriveSafe")
            .setContentText(text)
            .setSmallIcon(android.R.drawable.ic_menu_directions)
            .setOngoing(true)
            .build()
    }

    private fun updateNotification(text: String) {
        val notificationManager = getSystemService(NotificationManager::class.java)
        notificationManager.notify(NOTIFICATION_ID, createNotification(text))

        // Reset notification text after 3 seconds
        serviceScope.launch {
            delay(3000)
            notificationManager.notify(
                NOTIFICATION_ID,
                createNotification("Monitoring your drive...")
            )
        }
    }

    override fun onDestroy() {
        serviceScope.cancel()
        super.onDestroy()
    }

    companion object {
        const val ACTION_START = "com.example.drivesafe.START"
        const val ACTION_STOP = "com.example.drivesafe.STOP"
        const val NOTIFICATION_ID = 1001
    }
}

The ViewModel

package com.example.drivesafe.ui.viewmodel

import android.app.Application
import android.content.Intent
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.drivesafe.data.DriveSafeDatabase
import com.example.drivesafe.data.DrivingEventEntity
import com.example.drivesafe.data.TripEntity
import com.example.drivesafe.detection.DrivingEvent
import com.example.drivesafe.service.DrivingMonitorService
import com.example.drivesafe.trip.Trip
import com.example.drivesafe.trip.TripScorer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject

data class DriveSafeUiState(
    val isMonitoring: Boolean = false,
    val currentTrip: Trip? = null,
    val events: List<DrivingEvent> = emptyList(),
    val liveScore: Int = 100,
    val tripHistory: List<Trip> = emptyList(),
    val weeklyAverageScore: Float = 0f,
    val statusText: String = "Tap Start to begin monitoring",
    val elapsedTimeMs: Long = 0
)

@HiltViewModel
class DriveSafeViewModel @Inject constructor(
    private val application: Application,
    private val tripScorer: TripScorer,
    private val database: DriveSafeDatabase
) : AndroidViewModel(application) {

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

    private var tripStartTime: Long = 0

    init {
        loadTripHistory()
    }

    fun startTrip() {
        tripStartTime = System.currentTimeMillis()

        val intent = Intent(application, DrivingMonitorService::class.java).apply {
            action = DrivingMonitorService.ACTION_START
        }
        application.startForegroundService(intent)

        _uiState.update {
            it.copy(
                isMonitoring = true,
                events = emptyList(),
                liveScore = 100,
                statusText = "Monitoring your drive...",
                elapsedTimeMs = 0
            )
        }
    }

    fun stopTrip() {
        val intent = Intent(application, DrivingMonitorService::class.java).apply {
            action = DrivingMonitorService.ACTION_STOP
        }
        application.startService(intent)

        val events = _uiState.value.events
        val durationMs = System.currentTimeMillis() - tripStartTime

        val trip = tripScorer.computeTripSummary(events, durationMs).copy(
            startTime = tripStartTime,
            endTime = System.currentTimeMillis()
        )

        _uiState.update {
            it.copy(
                isMonitoring = false,
                currentTrip = trip,
                statusText = "Trip complete! Score: ${trip.score}/100"
            )
        }

        viewModelScope.launch {
            saveTrip(trip)
        }
    }

    fun addEvent(event: DrivingEvent) {
        _uiState.update { state ->
            val newEvents = state.events + event
            val durationMs = System.currentTimeMillis() - tripStartTime
            val liveScore = tripScorer.computeScore(newEvents, durationMs)

            state.copy(
                events = newEvents,
                liveScore = liveScore
            )
        }
    }

    private suspend fun saveTrip(trip: Trip) {
        val tripEntity = TripEntity(
            id = trip.id,
            startTime = trip.startTime,
            endTime = trip.endTime ?: System.currentTimeMillis(),
            durationMs = trip.durationMs,
            score = trip.score,
            harshBrakeCount = trip.harshBrakeCount,
            rapidAccelCount = trip.rapidAccelCount,
            sharpTurnCount = trip.sharpTurnCount,
            phonePickupCount = trip.phonePickupCount,
            totalEvents = trip.events.size
        )

        database.tripDao().insertTrip(tripEntity)

        val eventEntities = trip.events.map { event ->
            DrivingEventEntity(
                tripId = trip.id,
                type = event.type.name,
                severity = event.severity,
                value = event.value,
                timestamp = event.timestamp,
                description = event.description
            )
        }

        database.tripDao().insertEvents(eventEntities)
    }

    private fun loadTripHistory() {
        viewModelScope.launch {
            database.tripDao().getRecentTrips(20).collect { trips ->
                _uiState.update { state ->
                    state.copy(
                        tripHistory = trips.map { entity ->
                            Trip(
                                id = entity.id,
                                startTime = entity.startTime,
                                endTime = entity.endTime,
                                durationMs = entity.durationMs,
                                score = entity.score,
                                harshBrakeCount = entity.harshBrakeCount,
                                rapidAccelCount = entity.rapidAccelCount,
                                sharpTurnCount = entity.sharpTurnCount,
                                phonePickupCount = entity.phonePickupCount
                            )
                        }
                    )
                }
            }
        }
    }
}

Main Dashboard UI

The driving dashboard shows real-time status, live safety score, and event feed:

package com.example.drivesafe.ui.screens

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.drivesafe.detection.DrivingEvent
import com.example.drivesafe.detection.DrivingEventType
import com.example.drivesafe.ui.viewmodel.DriveSafeUiState

@Composable
fun DrivingDashboard(
    uiState: DriveSafeUiState,
    onStartTrip: () -> Unit,
    onStopTrip: () -> Unit
) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        // Safety Score Circle
        item {
            ScoreCircle(
                score = uiState.liveScore,
                isMonitoring = uiState.isMonitoring
            )
        }

        // Status Text
        item {
            Text(
                text = uiState.statusText,
                style = MaterialTheme.typography.bodyLarge,
                textAlign = TextAlign.Center,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }

        // Start/Stop Button
        item {
            Button(
                onClick = {
                    if (uiState.isMonitoring) onStopTrip() else onStartTrip()
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .height(56.dp),
                colors = ButtonDefaults.buttonColors(
                    containerColor = if (uiState.isMonitoring)
                        MaterialTheme.colorScheme.error
                    else
                        MaterialTheme.colorScheme.primary
                )
            ) {
                Icon(
                    imageVector = if (uiState.isMonitoring)
                        Icons.Default.Stop else Icons.Default.PlayArrow,
                    contentDescription = null
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text(
                    text = if (uiState.isMonitoring) "Stop Trip" else "Start Trip",
                    style = MaterialTheme.typography.titleMedium
                )
            }
        }

        // Event Stats (only show when monitoring or trip complete)
        if (uiState.events.isNotEmpty()) {
            item {
                EventStatsRow(
                    harshBrakes = uiState.events.count {
                        it.type == DrivingEventType.HARSH_BRAKING
                    },
                    sharpTurns = uiState.events.count {
                        it.type == DrivingEventType.SHARP_LEFT_TURN ||
                        it.type == DrivingEventType.SHARP_RIGHT_TURN
                    },
                    rapidAccels = uiState.events.count {
                        it.type == DrivingEventType.RAPID_ACCELERATION
                    },
                    phonePickups = uiState.events.count {
                        it.type == DrivingEventType.PHONE_PICKUP
                    }
                )
            }

            // Live Event Feed
            item {
                Text(
                    text = "Event Feed",
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(top = 8.dp)
                )
            }

            items(uiState.events.reversed()) { event ->
                EventCard(event = event)
            }
        }
    }
}

@Composable
fun ScoreCircle(score: Int, isMonitoring: Boolean) {
    val animatedScore by animateFloatAsState(
        targetValue = score.toFloat(),
        label = "score"
    )

    val scoreColor by animateColorAsState(
        targetValue = when {
            score >= 90 -> Color(0xFF34A853)  // Green
            score >= 70 -> Color(0xFFFBBC04)  // Yellow
            score >= 50 -> Color(0xFFFF9800)  // Orange
            else -> Color(0xFFEA4335)          // Red
        },
        label = "scoreColor"
    )

    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.size(200.dp)
    ) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            val strokeWidth = 16.dp.toPx()
            val arcSize = size.minDimension - strokeWidth

            // Background arc
            drawArc(
                color = Color.LightGray.copy(alpha = 0.3f),
                startAngle = 135f,
                sweepAngle = 270f,
                useCenter = false,
                topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
                size = Size(arcSize, arcSize),
                style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
            )

            // Score arc
            val sweepAngle = (animatedScore / 100f) * 270f
            drawArc(
                color = scoreColor,
                startAngle = 135f,
                sweepAngle = sweepAngle,
                useCenter = false,
                topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
                size = Size(arcSize, arcSize),
                style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
            )
        }

        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text(
                text = "${animatedScore.toInt()}",
                fontSize = 48.sp,
                fontWeight = FontWeight.Bold,
                color = scoreColor
            )
            Text(
                text = if (isMonitoring) "LIVE SCORE" else "SCORE",
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

@Composable
fun EventStatsRow(
    harshBrakes: Int,
    sharpTurns: Int,
    rapidAccels: Int,
    phonePickups: Int
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant
        )
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            StatItem("🛑", "$harshBrakes", "Brakes")
            StatItem("↩️", "$sharpTurns", "Turns")
            StatItem("🏎️", "$rapidAccels", "Accels")
            StatItem("📱", "$phonePickups", "Pickups")
        }
    }
}

@Composable
fun StatItem(emoji: String, count: String, label: String) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = emoji, fontSize = 24.sp)
        Text(
            text = count,
            style = MaterialTheme.typography.titleLarge,
            fontWeight = FontWeight.Bold
        )
        Text(
            text = label,
            style = MaterialTheme.typography.labelSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

@Composable
fun EventCard(event: DrivingEvent) {
    val eventColor = when (event.type) {
        DrivingEventType.HARSH_BRAKING -> Color(0xFFEA4335)
        DrivingEventType.SHARP_LEFT_TURN,
        DrivingEventType.SHARP_RIGHT_TURN -> Color(0xFFFF9800)
        DrivingEventType.RAPID_ACCELERATION -> Color(0xFFFBBC04)
        DrivingEventType.PHONE_PICKUP -> Color(0xFF9C27B0)
        DrivingEventType.NORMAL -> Color.Gray
    }

    val eventIcon = when (event.type) {
        DrivingEventType.HARSH_BRAKING -> "🛑"
        DrivingEventType.SHARP_LEFT_TURN -> "↰"
        DrivingEventType.SHARP_RIGHT_TURN -> "↱"
        DrivingEventType.RAPID_ACCELERATION -> "🏎️"
        DrivingEventType.PHONE_PICKUP -> "📱"
        DrivingEventType.NORMAL -> "✅"
    }

    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = eventColor.copy(alpha = 0.1f)
        )
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(text = eventIcon, fontSize = 24.sp)

            Spacer(modifier = Modifier.width(12.dp))

            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = event.description,
                    style = MaterialTheme.typography.bodyMedium,
                    fontWeight = FontWeight.Medium
                )
                Text(
                    text = formatTimestamp(event.timestamp),
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }

            // Severity indicator
            SeverityDot(severity = event.severity, color = eventColor)
        }
    }
}

@Composable
fun SeverityDot(severity: Float, color: Color) {
    val size = (8 + severity * 12).dp
    Surface(
        modifier = Modifier.size(size),
        shape = CircleShape,
        color = color
    ) {}
}

fun formatTimestamp(timestampMs: Long): String {
    val now = System.currentTimeMillis()
    val diffSeconds = (now - timestampMs) / 1000

    return when {
        diffSeconds < 5 -> "Just now"
        diffSeconds < 60 -> "${diffSeconds}s ago"
        diffSeconds < 3600 -> "${diffSeconds / 60}m ago"
        else -> "${diffSeconds / 3600}h ago"
    }
}

Trip History Screen

@Composable
fun TripHistoryScreen(trips: List<Trip>) {
    if (trips.isEmpty()) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Icon(
                    imageVector = Icons.Default.DirectionsCar,
                    contentDescription = null,
                    modifier = Modifier.size(64.dp),
                    tint = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Spacer(modifier = Modifier.height(16.dp))
                Text(
                    text = "No trips recorded yet",
                    style = MaterialTheme.typography.bodyLarge,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Text(
                    text = "Start a trip to begin tracking your driving",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
        return
    }

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        // Weekly average card
        item {
            val avgScore = trips.map { it.score }.average().toInt()
            WeeklyAverageCard(averageScore = avgScore, tripCount = trips.size)
        }

        items(trips) { trip ->
            TripSummaryCard(trip = trip)
        }
    }
}

@Composable
fun WeeklyAverageCard(averageScore: Int, tripCount: Int) {
    val scoreColor = when {
        averageScore >= 90 -> Color(0xFF34A853)
        averageScore >= 70 -> Color(0xFFFBBC04)
        averageScore >= 50 -> Color(0xFFFF9800)
        else -> Color(0xFFEA4335)
    }

    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = scoreColor.copy(alpha = 0.15f)
        )
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(20.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column {
                Text(
                    text = "Average Score",
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold
                )
                Text(
                    text = "$tripCount trips recorded",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            Text(
                text = "$averageScore",
                fontSize = 36.sp,
                fontWeight = FontWeight.Bold,
                color = scoreColor
            )
        }
    }
}

@Composable
fun TripSummaryCard(trip: Trip) {
    val scoreColor = when {
        trip.score >= 90 -> Color(0xFF34A853)
        trip.score >= 70 -> Color(0xFFFBBC04)
        trip.score >= 50 -> Color(0xFFFF9800)
        else -> Color(0xFFEA4335)
    }

    Card(modifier = Modifier.fillMaxWidth()) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = formatTripDate(trip.startTime),
                    style = MaterialTheme.typography.titleSmall,
                    fontWeight = FontWeight.SemiBold
                )
                Text(
                    text = "${trip.durationMinutes} min • Grade: ${trip.scoreGrade}",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )

                Spacer(modifier = Modifier.height(8.dp))

                Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                    if (trip.harshBrakeCount > 0)
                        Text("🛑 ${trip.harshBrakeCount}", style = MaterialTheme.typography.bodySmall)
                    if (trip.sharpTurnCount > 0)
                        Text("↩️ ${trip.sharpTurnCount}", style = MaterialTheme.typography.bodySmall)
                    if (trip.rapidAccelCount > 0)
                        Text("🏎️ ${trip.rapidAccelCount}", style = MaterialTheme.typography.bodySmall)
                    if (trip.phonePickupCount > 0)
                        Text("📱 ${trip.phonePickupCount}", style = MaterialTheme.typography.bodySmall)
                }
            }

            Surface(
                shape = CircleShape,
                color = scoreColor.copy(alpha = 0.15f),
                modifier = Modifier.size(56.dp)
            ) {
                Box(contentAlignment = Alignment.Center) {
                    Text(
                        text = "${trip.score}",
                        fontWeight = FontWeight.Bold,
                        fontSize = 20.sp,
                        color = scoreColor
                    )
                }
            }
        }
    }
}

fun formatTripDate(timestampMs: Long): String {
    val sdf = java.text.SimpleDateFormat("EEE, MMM d • h:mm a", java.util.Locale.getDefault())
    return sdf.format(java.util.Date(timestampMs))
}

Settings Screen

The settings screen lets users tune detection sensitivity and configure family sharing:

@Composable
fun SettingsScreen(
    sensitivity: Float,
    onSensitivityChange: (Float) -> Unit,
    onCalibrate: () -> Unit
) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        item {
            Text(
                text = "Detection Sensitivity",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold
            )
        }

        item {
            Card(modifier = Modifier.fillMaxWidth()) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Text(
                        text = when {
                            sensitivity < 0.3f -> "Low — Only detects very harsh events"
                            sensitivity < 0.7f -> "Medium — Balanced detection (recommended)"
                            else -> "High — Detects even mild events"
                        },
                        style = MaterialTheme.typography.bodyMedium
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    Slider(
                        value = sensitivity,
                        onValueChange = onSensitivityChange,
                        modifier = Modifier.fillMaxWidth()
                    )
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween
                    ) {
                        Text("Low", style = MaterialTheme.typography.labelSmall)
                        Text("High", style = MaterialTheme.typography.labelSmall)
                    }
                }
            }
        }

        item {
            Text(
                text = "Phone Mount",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold
            )
        }

        item {
            Card(modifier = Modifier.fillMaxWidth()) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Text(
                        text = "Calibrate your phone's position in the car for accurate detection.",
                        style = MaterialTheme.typography.bodyMedium
                    )
                    Spacer(modifier = Modifier.height(12.dp))
                    OutlinedButton(
                        onClick = onCalibrate,
                        modifier = Modifier.fillMaxWidth()
                    ) {
                        Text("Calibrate Mount Position")
                    }
                }
            }
        }
    }
}

Chapter Summary

In this chapter, you've gone deep into the two most fundamental motion sensors and built a practical app with them:

  • Accelerometer Physics — You understand how MEMS accelerometers work, the Android coordinate system, the difference between raw acceleration, linear acceleration, and gravity, and how to separate these components using low-pass and high-pass filters.

  • Gyroscope Fundamentals — You know how angular velocity is measured, the difference between calibrated and uncalibrated gyroscope data, and why gyroscope drift makes it unsuitable for absolute orientation on its own.

  • Sensor Pipelines — You built a reactive processing pipeline using Kotlin Flow that filters noise, throttles data rates, and maps raw sensor values to meaningful application events.

  • Driving Event Detection — You implemented four detection algorithms — harsh braking, rapid acceleration, sharp turns, and phone pickup — each using sustained threshold analysis with debouncing and cooldown periods to avoid false positives.

  • DriveSafe App — You assembled everything into a complete driving monitor with a foreground service for background detection, a live safety score dashboard, event feed, trip history with Room persistence, and configurable sensitivity settings.

In Chapter 3, we'll explore position sensors — the magnetometer and rotation vector — and build ProCompass, a Qibla direction finder and compass app used daily by millions of Muslims worldwide.