Tomáš Repčík - 15. 4. 2024

Update your compass - new Android Orientation API

Android provides a new Fused Orientation API from multiple sensors

Android now fuses more sensors, so you do not have to do calculations on your own.

It gives you the bearing and orientation of your device.

In specific, the new Orientation device fuses the accelerometer, gyroscope and magnetometer to bring more precise and consistent measurements of the device’s orientation. It compensates for sensor issues on the lower levels and timing of the sensors.

Implementation

Dependencies

Firstly, you need to update Google Play location services to the version 21.2.0:

Version catalogue:

[versions]
playServicesLocation = "21.2.0"

[libraries]
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }

gradle.kts:

dependencies {
    implementation('com.google.android.gms:play-services-location:21.2.0')
    // Or with toml:
    implementation(libs.play.services.location)
}

No registration for Google Services API is required as for other sensors.

Implementation

Registration follows a similar workflow as any other sensor from the API. The fused API is presented with one provider client and one listener.

The client can be obtained by no activity context as follows:

private val fusedOrientationProviderClient: FusedOrientationProviderClient =
    LocationServices.getFusedOrientationProviderClient(context)

If you want to start listening to the changes:

  1. Create a request with your desired latency
  2. create or pass Executor. It is a new Thread, so if you have other similar jobs around, they should share it.
  3. Pass the listener, executor and request.
  4. Listen to success or failure.
// 1. 
val request = 
    DeviceOrientationRequest.Builder(DeviceOrientationRequest.OUTPUT_PERIOD_DEFAULT).build()
// 2. 
val executor = Executors.newSingleThreadExecutor()
// 3.
fusedOrientationProviderClient.requestOrientationUpdates(request, executor, listener)
    // 4.
    .addOnSuccessListener {}
    .addOnFailureListener { e: Exception? -> ...}

When you want to stop the sensor, you remove the registered listener:

fusedOrientationProviderClient.removeOrientationUpdates(listener)

For listening, you have to implement the interface you could add lambda:

{ sample: DeviceOrientation -> ...}

Or you can have any other class to implement the DeviceOrientationListener, which provided the needed method to listen.

Couple of notes:

Android system does not provide samples in a way you might expect. The 5/10/20ms means, that system should supply the sample by that time. Not that the system will give you sample every 20ms. Every device can be a bit different.

Output of the listener - DeviceOrientation

Rotation matrix

Previously, you would need measurements from the accelerometer and gyroscope, to get the rotation matrix and afterwards orientation angles:

val rotationMatrix = FloatArray(9)
SensorManager.getRotationMatrix(rotationMatrix, null, accelerometerReading, magnetometerReading)

val orientationAngles = FloatArray(3)
SensorManager.getOrientation(rotationMatrix, orientationAngles)

Now, you can calculate the rotation matrix directly with the help of the DeviceOrienation attitude:

val rotationMatrix = FloatArray(9)
SensorManager.getRotationMatrixFromVector(rotationMatrix, deviceOrientation.getAttitude())

Repo example

If you are looking for some quick and simple sample code to implement the fused orientation API, here a small sample:

// limitation for one listener, but it could be extended with array of listeners
class OrientationRepo(context: Context) {

    private var listener: DeviceOrientationListener? = null
    private val fusedOrientationProviderClient: FusedOrientationProviderClient =
        LocationServices.getFusedOrientationProviderClient(context)

    fun addListener(
        listener: DeviceOrientationListener,
        executor: ExecutorService = Executors.newSingleThreadExecutor()
    ) {
        // if we register second listener, we replace the first one
        removeListenerIfExists()
        this.listener = listener
        
        // feel free to extract period as parameter
        val request =
            DeviceOrientationRequest.Builder(DeviceOrientationRequest.OUTPUT_PERIOD_DEFAULT).build()
        
        // success and failure listeners could be added as input parameters for callback logic
        fusedOrientationProviderClient.requestOrientationUpdates(request, executor, listener)
            .addOnSuccessListener {
                Log.i(TAG, "Successfully added new orientation listener")
            }.addOnFailureListener { e: Exception? ->
                Log.e(TAG, "Failed to add new orientation listener", e)
            }
    }

    fun removeListenerIfExists() = listener?.let {
        Log.i(TAG, "Removing active orientation listener")
        fusedOrientationProviderClient.removeOrientationUpdates(it)
        listener = null
    }

    companion object {
        const val TAG = "OrientationRepo"
    }
}

Orient the map with a compass

Here is a small example of rotating the map with the help of Jetpack Compose, hilt and repository above.

This is not a tutorial for the implementation of GoogleMap or Hilt, you can visit the documentation on how to implement GoogleMap here and my article about Hilt here.

Jetpack Compose version of GoogleMap UI is here.

So let’s introduce the main screen, where we will tap into a single ViewModel. The model emits the CameraPositionState, with our new bearing from the repo.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MapViewFusedOrientationApiExampleTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
                ) {
                    val orientationViewModel: OrientationViewModel = hiltViewModel()
                    OrientationUi(state = orientationViewModel.cameraPositionState.collectAsState().value,
                        onStart = { orientationViewModel.start() },
                        onStop = { orientationViewModel.stop() })
                }
            }
        }
    }
}

OrientationViewModel contains our Orientation repo and it starts based on events coming from the UI.

The samples will be emitted on different thread then the Main thread. That is why, it is required to switch context to update UI. Otherwise, you hit error.

@HiltViewModel
class OrientationViewModel @Inject constructor(@ApplicationContext context: Context) : ViewModel(),
    DeviceOrientationListener { // implements the orientation listener

    // repo from the implementation
    private val orientationRepo = OrientationRepo(context)

    private val _cameraPositionState: MutableStateFlow<CameraPositionState> = MutableStateFlow(
        CameraPositionState()
    )
    val cameraPositionState = _cameraPositionState.asStateFlow()

    // to start and stop the samples
    fun start() = orientationRepo.addListener(this)

    fun stop() = orientationRepo.removeListenerIfExists()

    override fun onDeviceOrientationChanged(orientation: DeviceOrientation) {
        // launcher coroutine on the main thread and our sample
        viewModelScope.launch(Dispatchers.Main) {
            _cameraPositionState.value = CameraPositionState(CameraPosition.builder().apply {
                target(LatLng(49.06144, 20.29798)) // could be extended with GPS implementation
                bearing(orientation.headingDegrees) // getting our heading / bearing as compass
                zoom(10f)
            }.build())
        }
    }
}

Here is the implementation for the GoogleMap UI with the side effects. We need to use LaunchedEffect to start the listener when the UI appears. Afterwards, LifecycleOwner takes care of turning it off and on based on the lifecycle of the app (Orientation API works only in the foreground). DisposableEffect cleans everything up.

@Composable
fun OrientationUi(
    state: CameraPositionState,
    onStart: () -> Unit,
    onStop: () -> Unit,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) {
    // first start
    LaunchedEffect(lifecycleOwner) {
        onStart()
    }

    DisposableEffect(lifecycleOwner) {
        // response to the lifecycle changes
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                onStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                onStop()
            }
        }
        // clean up
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = state, // our state 
        uiSettings = MapUiSettings(compassEnabled = false)
    )
}

The full example repository is here

Thanks for reading and do not forget to follow for more!

Resources:

Subscribe for more
LinkedIn GitHub Mail Medium