Repository navigation
Motion Sensor Tutorial
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
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