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:
- Create a request with your desired latency
- create or pass Executor. It is a new Thread, so if you have other similar jobs around, they should share it.
- Pass the listener, executor and request.
- 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:
- It does not need any permission to run - max frequency is capped at 200Hz (Android S and higher)
- Higher frequencies require permission -
android.permissions.HIGH_SAMPLING_RATE_SENSORS
- Higher frequencies require permission -
- Frequency can be modulated by the builder parameter
DeviceOrientationRequest.OUTPUT_PERIOD_DEFAULT
- 50Hz / 20ms period - recommended for map or compassDeviceOrientationRequest.OUTPUT_PERIOD_MEDIUM
- 100Hz / 10ms period - recommended gesture detectionDeviceOrientationRequest.OUTPUT_PERIOD_FAST
- 200Hz / 5ms period - recommended for AR apps
- Service provides samples only if the app is in the foreground.
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
- getAttitude() - represents the 3D orientation of the phone relative to the east-north-up coordinate frame
- getConservativeHeadingErrorDegrees() - heading error in degrees - [0, 180] - calculated from more samples
- hasConservativeHeadingErrorDegrees() - if the error above is available
- getHeading() - heading of the device or “bearing of the compass”
- getHeadingErrorDegrees() - heading error in degrees - [0, 180]
- getElapsedRealtimeNs() - nanoseconds from the boot of the device
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!