Skip to content

Motion Sensor Tutorial

FatPotato1 edited this page Dec 3, 2025 · 25 revisions

The specialty topic of focus in our project is using the integrated motion sensor in android phones. Specifially, we chose to use the integrated gyroscope to calculate all of the needed data relating to squatting. Our demo app uses the gyroscope to detect a series of phases during the squat, which allows rep count, duration, average speed, and lowest depth to be calculated effectively. The idea of this demo app is to allow people to track basic squat data easily and quickly, and save the data for later comparison.

Requirements for current development:

Android Studio Narwhal 3 Feature Drop | 2025.1.3 https://developer.android.com/studio/releases/past-releases/as-narwhal-3-feature-drop-release-notes

Gradle 8.13

Kotlin 1.9+

Android SDK 24+

Phone with gyroscope sensor

Technical summary:

A gyroscope works by detecting rate of rotation, so by using modern android phones' gyroscopes, we can determine when a person's phone is rotating in their pocket during the different parts during a squat (down/at bottom/up) by measuring the rate of rotation

image

Development instructions:

The gyroscope functionality should be declared in a viewmodel, and basic creation and functions should resemble something close to the following:

class TrackingScreenViewModel(app: Application) : AndroidViewModel(app), SensorEventListener {
    private val _gyro = MutableStateFlow(FloatArray(3))
    val gyro = _gyro.asStateFlow()

fun startSensors() {
        accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
        gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)

        accelSensor?.let {
            sensorManager.registerListener(
                this,
                it,
                SensorManager.SENSOR_DELAY_GAME
            )
        }

        gyroSensor?.let {
            sensorManager.registerListener(
                this,
                it,
                SensorManager.SENSOR_DELAY_GAME
            )
        }
    }

    fun stopSensors() {
        sensorManager.unregisterListener(this)
    }
}

This allows sensors to be enabled and disabled when called, and allows views to access data sourced from the sensors.

As for our squat state detection system, the main logic is as follows:

override fun onSensorChanged(event: SensorEvent) {
        when (event.sensor.type) {
            Sensor.TYPE_GYROSCOPE -> {
                _gyro.value = event.values.clone()

                // Integrate gyroscope data to get pitch angle
                if (_isTracking.value && !_isCalibrating.value) {
                    val currentTime = event.timestamp

                    if (lastGyroTimestamp != 0L) {

                        // Calculate time difference in seconds
                        val dt = (currentTime - lastGyroTimestamp) / 1_000_000_000f

                        // Get angular velocity around X-axis (pitch rotation)
                        val angularVelocityX = event.values[0] // rad/s

                        // Convert to degrees and calculate angle change
                        val angleChange = Math.toDegrees(angularVelocityX.toDouble()).toFloat() * dt

                        // Integrate to get total pitch angle
                        integratedPitch += angleChange

                        //set the current depth
                        _currentDepth.value = abs(integratedPitch - baselineAngle)
                        lastGyroTimestamp = currentTime

                        println("Integrated pitch: $integratedPitch, angular velocity: $angularVelocityX rad/s")

                        processRepData(integratedPitch)
                    }

                    lastGyroTimestamp = currentTime
                }
            }
        }
    }

This method reads gyroscope updates, converts angular velocity into an actual pitch angle, and calculates how far the user has moved from their calibrated starting position. This pitch angle is then used to detect phases of each rep and records rep data such as depth and duration.

The function for handling states:

fun processRepData(pitch: Float) {
        when (_squatState.value) {
            SquatState.AWAITING -> {}
            SquatState.CALIBRATING -> {}
            SquatState.READY -> detectDescent(pitch)
            SquatState.DESCENDING -> detectBottom(pitch)
            SquatState.AT_BOTTOM -> checkAscent(pitch)
            SquatState.ASCENDING -> checkTop(pitch)
        }
    }

The following functions are essential for handling data for the start and end of tracking process:

Initial calibration method, set values to zero and switch to ready state after setup delay:

private fun startCalibration() {
        println("in startCalibration")

        _isCalibrating.value = true
        _squatState.value = SquatState.CALIBRATING

        integratedPitch = 0f
        lastGyroTimestamp = 0L
        _averageDepth.value = 0f
        _averageRepTime.value = 0f

        // wait 5 seconds (to put phone in pocket, set up under barbell, get into position, etc)
        viewModelScope.launch {
            delay(5000)

            baselineAngle = 0f //should always be ~0
            println("BASELINE PITCH: $baselineAngle")

            _isCalibrating.value = false
            _squatState.value = SquatState.READY
        }
    }

Method to start the tracking:

fun startTracking() {
        println("in startTracking")

        _isTracking.value = true
        currentReps.clear()
        currentRepNumber = 0
        _repCount.value = 0
        depthReached = false

        integratedPitch = 0f
        lastGyroTimestamp = 0L

        startCalibration()
    }

Method to stop tracking, and save or discard info depending on user choice:

fun stopTracking(save: Boolean) {
        println("Final reps: $currentReps")

        _isTracking.value = false
        lastGyroTimestamp = 0L
        squatState = SquatState.AWAITING

        val totalReps = currentReps.size
        val totalDuration = currentReps.sumOf { it.totalDuration }
        val avgSpeed =
            if (totalDuration > 0) totalReps / (totalDuration / 1000f)
            else 0f
        val lowestDepth = currentReps.minOfOrNull { it.maxDepth } ?: 0f

        if (save){
            addWorkoutSet(totalReps, totalDuration.toFloat(), avgSpeed, lowestDepth)
        }
    }

The following methods are all directly related to the different phases of the squat (top -> descending -> bottom -> ascending -> top):

fun detectDescent(pitch: Float) {
        val pitchChange = abs(pitch - baselineAngle)
        println("detectDescent: pitch=$pitch, baseline=$baselineAngle, pitchChange=$pitchChange")

        if (pitchChange > 5f) {
            println("DESCENDING")
            _squatState.value = SquatState.DESCENDING

            currentRepStartTime = System.currentTimeMillis()
            currentRepMaxDepth = pitchChange
        }
    }
    fun detectBottom(pitch: Float) {
        val pitchChange = abs(pitch - baselineAngle)
        println("detectBottom: pitchChange=$pitchChange, maxDepth=$currentRepMaxDepth")

        // track max depth
        if (pitchChange > currentRepMaxDepth) {
            currentRepMaxDepth = pitchChange
        }

        // Check if minimum depth reached (parallel = 90, 5 degree threshold)
        if (pitchChange >= minRequiredDepth - 5 && !depthReached) {
            println("AT_BOTTOM (parallel reached at $pitchChange°)")
            depthReached = true
            _squatState.value = SquatState.AT_BOTTOM
        }
    }
    fun checkAscent(pitch: Float) {
        val pitchChange = abs(pitch - baselineAngle)
        println("checkAscent: pitchChange=$pitchChange, maxDepth=$currentRepMaxDepth")

        // Continue tracking max depth in case they go deeper
        if (pitchChange > currentRepMaxDepth) {
            currentRepMaxDepth = pitchChange
        }

        // Detect when starting to come back up (10 degree threshold)
        if (depthReached && pitchChange < currentRepMaxDepth - 10f) {
            println("ASCENDING")
            _squatState.value = SquatState.ASCENDING
        }
    }
    fun checkTop(pitch: Float) {

        val pitchChange = abs(pitch - baselineAngle)
        println("checkTop: pitchChange=$pitchChange")

        // Back to starting position (within 5 degrees of baseline)
        if (pitchChange <= 5f) {

            val duration = System.currentTimeMillis() - currentRepStartTime

            val completedRep = SquatRep(
                repNumber = currentRepNumber,
                totalDuration = duration,
                maxDepth = currentRepMaxDepth
            )

            currentReps.add(completedRep)
            currentRepNumber++
            _repCount.value = currentRepNumber
            println("REP COMPLETED: #$currentRepNumber, depth=${currentRepMaxDepth}°, duration=${duration}ms")
            currentRepMaxDepth = 0f
            depthReached = false
            _squatState.value = SquatState.READY

            //Update average depth
            val totalDepth = currentReps.sumOf { it.maxDepth.toDouble() }
            _averageDepth.value = (totalDepth / currentReps.size).toFloat()

            //update average rep time
            val totalTime = currentReps.sumOf { it.totalDuration.toDouble() }
            _averageRepTime.value = (totalTime / currentReps.size).toFloat()
        }
    }

Combined, these code segments will provide the base functionality for handling the gyroscope and detecting squat states. By using this code, a developer can integrate squat tracking within their own app, and add on additional features, such as specific stats to track and other features we did not include.

Alternatively, a developer may decide to go with different methods of tracking, such as by using a camera or an integrated accelerometer, which could fit different use cases and constraints. A camera based one may be more difficult to implement, but could give more useful data, such as form detection and rating.

See also: Android motion sensor documentation

Clone this wiki locally