Optimizing battery usage for Mapbox Navigation SDK in Android - android

I'm working in a project that need to show a NavigationView inside a fragment. It's working fine but consume a lot of battery (and depending of the device in some increase temperature considerably).
I checked the profiler in AS to try to identify the issue,
let's see...
As I can understand, Mapbox Navigation ask for location in High Accuracy every one second.
The question is, there is any way to configure the priority or the interval to reduce battery comsumption?
I followed the official docs to implement a custom LocationEngine, and works right for MapView, but not for NavigationView.
Anyone had this kind of issues in terms of performance with Mapbox Navigation? I've already test it in new and old devices and it's the same every time.
I'm using:
implementation "com.mapbox.navigation:ui:1.4.0"
implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:9.6.0'
And here are some part of my implementation,
private fun initLocationEngine() {
locationEngine = LocationEngineProvider.getBestLocationEngine(requireContext())
// i don't know if is necessary to remove location updates before configure a new location engine
locationEngine?.removeLocationUpdates(this)
val request =
LocationEngineRequest.Builder(30000)
.setPriority(LocationEngineRequest.PRIORITY_BALANCED_POWER_ACCURACY)
.setMaxWaitTime(10000).build()
locationEngine?.requestLocationUpdates(request, this, getMainLooper())
}
override fun onMapReady(mapboxMap: MapboxMap) {
mapView = mapboxMap
mapView.setStyle(Style.TRAFFIC_DAY) {
initLocationEngine()
initCamera(mapboxMap)
}
}
private fun setupNavigationOptions(directionsRoute: DirectionsRoute): NavigationViewOptions {
val options = NavigationViewOptions.builder(requireContext())
options.directionsRoute(directionsRoute)
.navigationListener(this)
.feedbackListener(this)
.locationObserver(this)
.locationEngine(locationEngine)
return options.build()
}
private fun getNavigationRoute(origin: Point, destination: Point) {
val navigation = MapboxNavigation.defaultNavigationOptionsBuilder(getCurrentContext(), Mapbox.getAccessToken())
mapboxNavigation = MapboxNavigation(navigation.build())
val routeOptions = RouteOptions.builder()
.applyDefaultParams()
.accessToken(Mapbox.getAccessToken()!!)
.coordinates(coordinates)
.geometries(RouteUrl.GEOMETRY_POLYLINE6)
.profile(DirectionsCriteria.PROFILE_DRIVING)
.alternatives(false)
.voiceUnits(DirectionsCriteria.METRIC)
.build()
mapboxNavigation.requestRoutes(routeOptions, object : RoutesRequestCallback {
override fun onRoutesReady(routes: List<DirectionsRoute>) {
if (routes.isNotEmpty() && isAdded) {
val currentRoute = routes.first()
navigationView.startNavigation(setupNavigationOptions(currentRoute))
showNavigationMode()
}
}
override fun onRoutesRequestFailure(throwable: Throwable, routeOptions: RouteOptions) {
Timber.e("route request failure %s", throwable.toString())
}
override fun onRoutesRequestCanceled(routeOptions: RouteOptions) {
Timber.d("route request canceled")
}
})
}
// these methods are from LocationObserver callback
override fun onEnhancedLocationChanged(enhancedLocation: Location, keyPoints: List<Location>) {
// this method called every second, so, LocationEngine it's configured fine but the criteria and interval configuration does'nt work
}
override fun onRawLocationChanged(rawLocation: Location) {
}
// EDIT
After Yoshikage Ochi comment I made some changes to my setupNavigationOptions method:
private fun setupNavigationOptions(directionsRoute: DirectionsRoute): NavigationViewOptions {
val navViewOptions = NavigationViewOptions.builder(requireContext())
val navOpt = MapboxNavigation.defaultNavigationOptionsBuilder(requireContext(), Mapbox.getAccessToken())
val request =
LocationEngineRequest.Builder(30000)
.setPriority(LocationEngineRequest.PRIORITY_BALANCED_POWER_ACCURACY).build()
navOpt.locationEngineRequest(request)
navViewOptions.navigationOptions(navOpt.build())
navViewOptions.directionsRoute(directionsRoute)
.navigationListener(this)
.feedbackListener(this)
.locationObserver(this)
return options.build()
}
But unfortunatly it does'nt work. The period and the priority is the same (maybe the default), I've receiving updates every second and in HIGH_PRIORITY.

In your implementation, your setting will be overwritten by the default setting when the trip session starts as Google doc explains.
NavigationOptions has an option named locationEngineRequest that is used to configure LocationEngine (example).
Following code demonstrates how NavigationOptions can be used in NavigationViewOptions that is a parameter of NavigationView#startNavigation
val optionsBuilder = NavigationViewOptions.builder(this#MainActivity)
optionsBuilder.navigationOptions(MapboxNavigation
.defaultNavigationOptionsBuilder(this#MainActivity, Mapbox.getAccessToken())
.locationEngineRequest(LocationEngineRequest.Builder(5)
.build())
.build())
In the meantime, the location update energy consumption is way smaller than CPU energy consumption according to your performance result.
In this case, how about using FPS throttle mechanism that would reduce CPU power consumption? This setting clips the max FPS when the device is running on battery and meets some conditions. The default value is 20.
navigationView.retrieveNavigationMapboxMap()?.updateMapFpsThrottle(5);

Related

Kotlin withTimeout coroutine cancellation

I'm trying for the first time the coroutine function withTimeout. I'm trying to fetch the current location from the device GPS in Android, and add a timeout in case no location is available. I'm not controlling the process of fetching a location so I cannot make it cancellable easily.
Update: I ended up with a custom timeout logic as the android native api is not cancellable:
suspend fun LocationManager.listenLocationUpdate(): Location? =
withTimeoutOrNull(TIMEOUT) {
locationManager.listenLocationUpdate("gps")
}
private suspend fun LocationManager.listenLocationUpdate(provider: String) =
suspendCoroutine<Location?> { continuation ->
requestLocationUpdates(provider, 1000, 0f, object: TimeoutLocationListener{
override fun onLocationChanged(location: Location?) {
continuation.resume(location)
this#listenLocationUpdate.removeUpdates(this)
}
})
}
So the process of requesting a location belongs to the sdk and I cannot make it cancellale easily. Any suggestion?
For withTimeout[OrNull] to work, you need a cooperative cancellable coroutine. If the function you call is blocking, it will not work as expected. The calling coroutine will not even resume at all, let alone stop the processing of the blocking method. You can check this playground code to confirm this.
You have to have a cancellable API in the first place if you want to build coroutine-based APIs that are cancellable. It's hard to answer your question without knowing the exact function you're calling, though.
With Android's LocationManager, you can for instance wrap getCurrentLocation into a cancellable suspending function (this function is only available in API level 30+):
#RequiresApi(Build.VERSION_CODES.R)
#RequiresPermission(anyOf = [permission.ACCESS_COARSE_LOCATION, permission.ACCESS_FINE_LOCATION])
suspend fun LocationManager.getCurrentLocation(provider: String, executor: Executor): Location? = suspendCancellableCoroutine { cont ->
val signal = CancellationSignal()
getCurrentLocation(provider, signal, executor) { location: Location? ->
cont.resume(location)
}
cont.invokeOnCancellation {
signal.cancel()
}
}
Otherwise you could also use callbackFlow to turn the listener-based API into a cancellable Flow-based API which unsubscribes upon cancellation (by removing the listener):
#OptIn(ExperimentalCoroutinesApi::class)
#RequiresPermission(anyOf = [permission.ACCESS_COARSE_LOCATION, permission.ACCESS_FINE_LOCATION])
fun LocationManager.locationUpdates(provider: String, minTimeMs: Long, minDistance: Float = 0f): Flow<Location> =
callbackFlow {
val listener = LocationListener { location -> sendBlocking(location) }
requestLocationUpdates(provider, minTimeMs, minDistance, listener)
awaitClose {
removeUpdates(listener)
}
}
You can use first() on the returned flow if you just want one update, and this will automatically support cancellation:
suspend fun LocationManager.listenLocationUpdate(): Location? =
withTimeoutOrNull(TIMEOUT) {
locationManager.locationUpdates("gps", 1000).first()
}
If you use numUpdates = 1 in your location request, you should also be able to wrap the listener-based API into a single-shot suspending function too. Cancellation here could be done by just removing the listener.

Using CompanionDeviceManager to read the devices information

I am using BluetoothLeScanner to scan for BLE devices and get a list of objects representing the devices to show inside my app (not connecting to any of them).
I am interested in doing the same, but using the CompanionDeviceManager now. Its callback CompanionDeviceManager.Callback.onDeviceFound(chooserLauncher: IntentSender?) unfortunately does not return any human readable form of found devices... the closest it gets is the IntentSender.writeToParcel method, but I am not sure how to use it in this situation.
I am not constrained to use the CompanionDeviceManager but I wanted to follow the OS version specific guidelines, we are supposed to use CompanionDeviceManager for Bluetooth devices scanning starting from API 26, but it seems useless in my case... so is there any way to get devices data from that callback, or should I just ditch it and stay with BluetoothLeScanner for all OS versions?
Late answer but it might help someone else. You can create a bluetooth device picker in combination with ActivityResultContracts.StartIntentSenderForResult() in order to get the BluetoothDevice. From there you will have access to all the device info that you need. Recent changes added some Android 12 permissions like android.permission.BLUETOOTH_CONNECT. Your mileage may vary.
val context = LocalContext.current
// Get the device manager instance
val deviceManager: CompanionDeviceManager by lazy {
ContextCompat.getSystemService(
context,
CompanionDeviceManager::class.java
) as CompanionDeviceManager
}
// Create a filter of your choice. Here I just look for specific device names
val deviceFilter: BluetoothDeviceFilter by lazy {
BluetoothDeviceFilter.Builder()
.setNamePattern(Pattern.compile(supportedDevices))
.build()
}
// Create a pairing request with your filter from the last step
val pairingRequest: AssociationRequest = AssociationRequest.Builder()
.addDeviceFilter(deviceFilter)
.build()
// Create a picker for discovered bluetooth devices
val bluetoothDevicePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult(),
onResult = {
val device: BluetoothDevice? =
it.data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)
try {
// Now that you have the desired device, do what you need to with it
device?.apply {
when {
name?.matches(Regex(firstDevicePattern)) == true -> {
Log.i(TAG, "${this.name} connected")
onFirstDeviceDiscovered(device)
}
name?.matches(Regex(secondDevicePattern)) == true -> {
Log.i(TAG, "${this.name} connected")
onSecondDeviceDiscovered(device)
}
}
}
} catch (e: SecurityException) {
e.printStackTrace()
//TODO: handle the security exception (this is possibly a bug)
// https://issuetracker.google.com/issues/198986283
}
}
)
// A utility function to centralize calling associate (optional)
val associateDevice: (AssociationRequest) -> Unit = { request ->
// Attempt to associate device(s)
deviceManager.associate(
request,
object : CompanionDeviceManager.Callback() {
override fun onDeviceFound(chooserLauncher: IntentSender) {
val sender = IntentSenderRequest.Builder(chooserLauncher)
.build()
bluetoothDevicePicker.launch(sender)
}
override fun onFailure(error: CharSequence?) {
//TODO: handle association failure
}
}, null
)
}

How to complete a Kotlin Flow in Android Worker

I'm investigating the use of Kotlin Flow within my current Android application
My application retrieves its data from a remote server via Retrofit API calls.
Some of these API's return 50,000 data items in 500 item pages.
Each API response contains an HTTP Link header containing the Next pages complete URL.
These calls can take up to 2 seconds to complete.
In an attempt to reduce the elapsed time I have employed a Kotlin Flow to concurrently process each page
of data while also making the next page API call.
My flow is defined as follows:
private val persistenceThreadPool = Executors.newFixedThreadPool(3).asCoroutineDispatcher()
private val internalWorkWorkState = MutableStateFlow<Response<List<MyPage>>?>(null)
private val workWorkState = internalWorkWorkState.asStateFlow()
private val myJob: Job
init {
myJob = GlobalScope.launch(persistenceThreadPool) {
workWorkState.collect { page ->
if (page == null) {
} else managePage(page!!)
}
}
}
My Recursive function is defined as follows that fetches all pages:-
private suspend fun managePages(accessToken: String, response: Response<List<MyPage>>) {
when {
result != null -> return
response.isSuccessful -> internalWorkWorkState.emit(response)
else -> {
manageError(response.errorBody())
result = Result.failure()
return
}
}
response.headers().filter { it.first == HTTP_HEADER_LINK && it.second.contains(REL_NEXT) }.forEach {
val parts = it.second.split(OPEN_ANGLE, CLOSE_ANGLE)
if (parts.size >= 2) {
managePages(accessToken, service.myApiCall(accessToken, parts[1]))
}
}
}
private suspend fun managePage(response: Response<List<MyPage>>) {
val pages = response.body()
pages?.let {
persistResponse(it)
}
}
private suspend fun persistResponse(myPage: List<MyPage>) {
val myPageDOs = ArrayList<MyPageDO>()
myPage.forEach { page ->
myPageDOs.add(page.mapDO())
}
database.myPageDAO().insertAsync(myPageDOs)
}
My numerous issues are
This code does not insert all data items that I retrieve
How do complete the flow when all data items have been retrieved
How do I complete the GlobalScope job once all the data items have been retrieved and persisted
UPDATE
By making the following changes I have managed to insert all the data
private val persistenceThreadPool = Executors.newFixedThreadPool(3).asCoroutineDispatcher()
private val completed = CompletableDeferred<Int>()
private val channel = Channel<Response<List<MyPage>>?>(UNLIMITED)
private val channelFlow = channel.consumeAsFlow().flowOn(persistenceThreadPool)
private val frank: Job
init {
frank = GlobalScope.launch(persistenceThreadPool) {
channelFlow.collect { page ->
if (page == null) {
completed.complete(totalItems)
} else managePage(page!!)
}
}
}
...
...
...
channel.send(null)
completed.await()
return result ?: Result.success(outputData)
I do not like having to rely on a CompletableDeferred, is there a better approach than this to know when the Flow has completed everything?
You are looking for the flow builder and Flow.buffer():
suspend fun getData(): Flow<Data> = flow {
var pageData: List<Data>
var pageUrl: String? = "bla"
while (pageUrl != null) {
TODO("fetch pageData from pageUrl and change pageUrl to the next page")
emitAll(pageData)
}
}
.flowOn(Dispatchers.IO /* no need for a thread pool executor, IO does it automatically */)
.buffer(3)
You can use it just like a normal Flow, iterate, etc. If you want to know the total length of the output, you should calculate it on the consumer with a mutable closure variable. Note you shouldn't need to use GlobalScope anywhere (ideally ever).
There are a few ways to achieve the desired behaviour. I would suggest to use coroutineScope which is designed specifically for parallel decomposition. It also provides good cancellation and error handling behaviour out of the box. In conjunction with Channel.close behaviour it makes the implementation pretty simple. Conceptually the implementation may look like this:
suspend fun fetchAllPages() {
coroutineScope {
val channel = Channel<MyPage>(Channel.UNLIMITED)
launch(Dispatchers.IO){ loadData(channel) }
launch(Dispatchers.IO){ processData(channel) }
}
}
suspend fun loadData(sendChannel: SendChannel<MyPage>){
while(hasMoreData()){
sendChannel.send(loadPage())
}
sendChannel.close()
}
suspend fun processData(channel: ReceiveChannel<MyPage>){
for(page in channel){
// process page
}
}
It works in the following way:
coroutineScope suspends until all children are finished. So you don't need CompletableDeferred anymore.
loadData() loads pages in cycle and posts them into the channel. It closes the channel as soon as all pages have been loaded.
processData fetches items from the channel one by one and process them. The cycle will finish as soon as all the items have been processed (and the channel has been closed).
In this implementation the producer coroutine works independently, with no back-pressure, so it can take a lot of memory if the processing is slow. Limit the buffer capacity to have the producer coroutine suspend when the buffer is full.
It might be also a good idea to use channels fan-out behaviour to launch multiple processors to speed up the computation.

Improving performance when using RxJava flowable to handle Android Room queries

My app is collecting sensor values from the accelerometer with the highest possible sample rate (~200 Hz on my device) and saves the values inside a Room database. I also want to frequently update some graphs with the latest measurements, lets say a refresh rate of 5 times per second. Ever since the app also collects the linear acceleration (without g) also with ~200 Hz (so two sensors each with roughly 200Hz inserting values into the database) I noticed a strong decrease in the apps performance and I have a lag of a few seconds between collected acceleration values and them showing up in the plot.
From the profiler my guess is that the RxComputationThread is the bottleneck since it is active almost all the time due to the Flowables.
I use sample() to limit the receiver updates since my graphs do not need to update super often. This led to an acceptable performance, when I just collected one sensor. I saw that RxJava provides an interval() method to limit the emit frequency from an emitter side, but that does not seem to available to me ? (Unresolved reference).
Maybe someone has an idea how to improve the performance? I like the concepts of RxJava and Room in general and would like to stick with them, but I am pretty much stuck at this point.
Here is the code I use to observe the Room SQL table and update the graphs:
// Observe changes to the datasource and create a new subscription if necessary
sharedViewModel.dataSource.observe(viewLifecycleOwner, Observer { source ->
Log.d("TAG", "Change observed!")
when (source) {
"acc" -> {
val disposableDataSource =
sharedViewModel.lastSecondsAccelerations
.sample(200, TimeUnit.MILLISECONDS)
.onBackpressureDrop()
.subscribeOn(Schedulers.io())
.subscribe { lastMeasurements ->
Log.d("TAG", Thread.currentThread().name)
if (sharedViewModel.isReset.value == true && lastMeasurements.isNotEmpty()) {
val t =
lastMeasurements.map { (it.time.toDouble() * 1e-9) - (lastMeasurements.last().time.toDouble() * 1e-9) }
val accX = lastMeasurements.map { it.accX.toDouble() }
val accY = lastMeasurements.map { it.accY.toDouble() }
val accZ = lastMeasurements.map { it.accZ.toDouble() }
// Update plots
updatePlots(t, accX, accY, accZ)
}
}
compositeDisposable.clear()
compositeDisposable.add(disposableDataSource)
}
"lin_acc" -> {
val disposableDataSource =
sharedViewModel.lastSecondsLinAccelerations
.sample(200, TimeUnit.MILLISECONDS)
.onBackpressureDrop()
.subscribeOn(Schedulers.io())
.subscribe { lastMeasurements ->
Log.d("TAG", Thread.currentThread().name)
if (sharedViewModel.isReset.value == true && lastMeasurements.isNotEmpty()) {
val t =
lastMeasurements.map { (it.time.toDouble() * 1e-9) - (lastMeasurements.last().time.toDouble() * 1e-9) }
val accX = lastMeasurements.map { it.accX.toDouble() }
val accY = lastMeasurements.map { it.accY.toDouble() }
val accZ = lastMeasurements.map { it.accZ.toDouble() }
// Update plots
updatePlots(t, accX, accY, accZ)
}
}
compositeDisposable.clear()
compositeDisposable.add(disposableDataSource)
}
}
})
The query for getting the last 10 seconds of measurements
#Query("SELECT * FROM acc_measurements_table WHERE time > ((SELECT MAX(time) from acc_measurements_table)- 1e10)")
fun getLastAccelerations(): Flowable<List<AccMeasurement>>
Thanks for your comments, I figured out now, what the bottleneck was. The issue was the huge amount of insertion calls, not too surprising. But it is possible to improve the performance by using some kind of buffer to insert multiple rows at a time.
This is what I added, in case someone runs in the same situation:
class InsertHelper(private val repository: Repository){
var compositeDisposable = CompositeDisposable()
private val measurementListAcc: FlowableList<AccMeasurement> = FlowableList()
private val measurementListLinAcc: FlowableList<LinAccMeasurement> = FlowableList()
fun insertAcc(measurement: AccMeasurement) {
measurementListAcc.add(measurement)
}
fun insertLinAcc(measurement: LinAccMeasurement) {
measurementListLinAcc.add(measurement)
}
init {
val disposableAcc = measurementListAcc.subject
.buffer(50)
.subscribe {measurements ->
GlobalScope.launch {
repository.insertAcc(measurements)
}
measurementListAcc.remove(measurements as ArrayList<AccMeasurement>)
}
val disposableLinAcc = measurementListLinAcc.subject
.buffer(50)
.subscribe {measurements ->
GlobalScope.launch {
repository.insertLinAcc(measurements)
}
measurementListLinAcc.remove(measurements as ArrayList<LinAccMeasurement>)
}
compositeDisposable.add(disposableAcc)
compositeDisposable.add(disposableLinAcc)
}
}
// Dynamic list that can be subscribed on
class FlowableList<T> {
private val list: MutableList<T> = ArrayList()
val subject = PublishSubject.create<T>()
fun add(value: T) {
list.add(value)
subject.onNext(value)
}
fun remove(value: ArrayList<T>) {
list.removeAll(value)
}
}
I basically use a dynamic list to buffer a few dozens measurement samples, then insert them as whole in the Room Database and remove them from the dynamic list. Here is also some information why batch insertion is faster: https://hackernoon.com/squeezing-performance-from-sqlite-insertions-with-room-d769512f8330
Im still quite new to Android Development, so if you see some mistakes or have suggestions, I appreciate every comment :)

How to disable Spring Animation for Espresso Tests?

I'm using the Android spring animation in my project (see here). However, these animations are getting in the way of my espresso tests.
I already tried to disable these animations using the developer options in the phone, but they seem to not be affected by these settings.
Is there any way how I can disable them just for tests?
After struggling with a flaky test due to SpringAnimations I came up with three solutions:
Solution 1: Add a function that wraps creating your SpringAnimations
This is the most invasive in terms of changing existing code, but least complex method to follow:
You can check if animations are disabled at runtime:
fun animationsDisabled() =
Settings.Global.getFloat(
contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f,
) == 0.0f
Then selectively return a dummy animation that immediately finishes while also setting the value to it's final state:
fun <K : View?> createAnimation(
target: K,
property: FloatPropertyCompat<K>,
finalValue: Float
) = if (animationsDisabled() == false) {
SpringAnimation(target, property, finalValue).apply {
spring.dampingRatio = dampingRatio
spring.stiffness = stiffness
}
} else {
property.setValue(target, finalValue)
SpringAnimation(FloatValueHolder(0f)).apply{
spring = SpringForce(100f)
spring.dampingRatio = dampingRatio
spring.stiffness = stiffness
addUpdateListener { _, _, _ -> skipToEnd() }
}
}
}
Solution 2: Create an IdlingResource that tells Espresso if a DynamicAnimation is running
SpringAnimation and FlingAnimation both extend from DynamicAnimation, the class which is ignoring the system Animation Scale and causing issues here.
This solution isn't the prettiest as it uses reflection, but the implementation details it relies on haven't changed since DynamicAnimation was introduced.
Based on DataBindingIdlingResource:
import android.view.View
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.test.espresso.IdlingResource
import androidx.test.ext.junit.rules.ActivityScenarioRule
import java.util.UUID
// An espresso idling resource implementation that reports idle status for all DynamicAnimation instances
class DynamicAnimationIdlingResource(private val activityScenarioRule: ActivityScenarioRule<*>) :
IdlingResource {
// list of registered callbacks
private val idlingCallbacks = mutableListOf<IdlingResource.ResourceCallback>()
// give it a unique id to workaround an espresso bug where you cannot register/unregister
// an idling resource w/ the same name.
private val id = UUID.randomUUID().toString()
// holds whether isIdle is called and the result was false. We track this to avoid calling
// onTransitionToIdle callbacks if Espresso never thought we were idle in the first place.
private var wasNotIdle = false
override fun getName() = "DynamicAnimation $id"
override fun isIdleNow(): Boolean {
val idle = !getDynamicAnimations().any { it.isRunning }
#Suppress("LiftReturnOrAssignment")
if (idle) {
if (wasNotIdle) {
// notify observers to avoid espresso race detector
idlingCallbacks.forEach { it.onTransitionToIdle() }
}
wasNotIdle = false
} else {
wasNotIdle = true
activityScenarioRule.scenario.onActivity {
it.findViewById<View>(android.R.id.content)
.postDelayed({ isIdleNow }, 16)
}
}
return idle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
idlingCallbacks.add(callback)
}
/**
* Find all binding classes in all currently available fragments.
*/
private fun getDynamicAnimations(): List<DynamicAnimation<*>> {
val dynamicAnimations = mutableListOf<DynamicAnimation<*>>()
val animationHandlerClass = Class
.forName("androidx.dynamicanimation.animation.AnimationHandler")
val animationHandler =
animationHandlerClass
.getDeclaredMethod("getInstance")
.invoke(null)
val animationCallbacksField =
animationHandlerClass
.getDeclaredField("mAnimationCallbacks").apply {
isAccessible = true
}
val animationCallbacks =
animationCallbacksField.get(animationHandler) as ArrayList<*>
animationCallbacks.forEach {
if (it is DynamicAnimation<*>) {
dynamicAnimations.add(it)
}
}
return dynamicAnimations
}
}
For convenience a matching test rule:
/**
* A JUnit rule that registers an idling resource for all animations that use DynamicAnimations.
*/
class DynamicAnimationIdlingResourceRule(activityScenarioRule: ActivityScenarioRule<*>) : TestWatcher() {
private val idlingResource = DynamicAnimationIdlingResource(activityScenarioRule)
override fun finished(description: Description?) {
IdlingRegistry.getInstance().unregister(idlingResource)
super.finished(description)
}
override fun starting(description: Description?) {
IdlingRegistry.getInstance().register(idlingResource)
super.starting(description)
}
}
This isn't the perfect solution since it will still cause your tests to wait for animations despite changing the animation scale globally
If you have infinite animations based on SpringAnimations (by setting Damping to zero), this won't work as it'll always report to Espresso that an animation is running. You could work around that by casting the DynamicAnimation to a SpringAnimation and checking if Damping was set, but I felt like that's a rare enough case to not complicate things.
Solution 3: Force all SpringAnimations to skip to their last frame
Another reflection based solution, but this one completely disables the SpringAnimations. The trade-off is that theoretically Espresso can still try to interact in the 1 frame window between a SpringAnimation being asked to end, and it actually ending.
In practice I had to rerun the test hundreds of times in a row to get this to happen, at which point the animation may not even be the source of flakiness. So the trade-off is probably worth it if the animations are dragging down how long your tests take to complete:
private fun disableSpringAnimations() {
val animationHandlerClass = Class
.forName("androidx.dynamicanimation.animation.AnimationHandler")
val animationHandler =
animationHandlerClass
.getDeclaredMethod("getInstance")
.invoke(null)
val animationCallbacksField =
animationHandlerClass
.getDeclaredField("mAnimationCallbacks").apply {
isAccessible = true
}
CoroutineScope(Dispatchers.IO).launch {
while (true) {
withContext(Dispatchers.Main) {
val animationCallbacks =
animationCallbacksField.get(animationHandler) as ArrayList<*>
animationCallbacks.forEach {
val animation = it as? SpringAnimation
if (animation?.isRunning == true && animation.canSkipToEnd()) {
animation.skipToEnd()
animation.doAnimationFrame(100000L)
}
}
}
delay(16L)
}
}
}
Call this method in your #Before annotated function to have it run before each test.
In the SpringAnimation implementation, skipToEnd sets a flag that is not checked until the next call to doAnimationFrame, hence the animation.doAnimationFrame(100000L) call.

Categories

Resources