Search code examples
androidkotlingpscoroutine

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?


Solution

  • 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.