Orbit
Orbit

Reputation: 2375

NetworkOnMainThreadException while launching coroutines via `Dispatchers.IO`

I'm attempting to make a basic network call using the View/ViewModel/UseCase/Repository pattern. The main async call is performed via Coroutines, which are both launched using Dispatchers.IO.

To start, here is the relevant code:

ViewModel:

class ContactHistoryViewModel @Inject constructor(private val useCase: GetContactHistory) : BaseViewModel() {
    // ...
    fun getContactHistory(userId: Long, contactId: Long) {
        useCase(GetContactHistory.Params(userId, contactId)) { it.either(::onFailure, ::onSuccess) }
    }
}

GetContactHistory UseCase:

class GetContactHistory @Inject constructor(private val repository: ContactRepository) : UseCase<ContactHistory, GetContactHistory.Params>() {

    override suspend fun run(params: Params) = repository.getContactHistory(params.userId, params.contactId)
    data class Params(val userId: Long, val contactId: Long)
}

Base UseCase class used above:

abstract class UseCase<out Type, in Params> where Type : Any {

    abstract suspend fun run(params: Params): Either<Failure, Type>

    operator fun invoke(params: Params, onResult: (Either<Failure, Type>) -> Unit = {}) {
        val job = GlobalScope.async(Dispatchers.IO) { run(params) }
        GlobalScope.launch(Dispatchers.IO) { onResult(job.await()) }
    }
}

Finally, the Repository:

class ContactDataRepository(...) : SyncableDataRepository<ContactDetailDomainModel>(cloudStore.get(), localStore),
        ContactRepository {

    override fun getContactHistory(userId: Long, contactId: Long): Either<Failure, ContactHistory> {
        return request(cloudStore.get().getContactHistory(userId, contactId), {it}, ContactHistory(null, null))
    }

    /**
     * Executes the request.
     * @param call the API call to execute.
     * @param transform a function to transform the response.
     * @param default the value returned by default.
     */
    private fun <T, R> request(call: Call<T>, transform: (T) -> R, default: T): Either<Failure, R> {
        return try {
            val response = call.execute()
            when (response.isSuccessful) {
                true -> Either.Right(transform((response.body() ?: default)))
                false -> Either.Left(Failure.GenericFailure())
            }
        } catch (exception: Throwable) {
            Either.Left(Failure.GenericFailure())
        }
    }
}

Summary: Placing a debug breakpoint in that catch{} block in the repository (seen directly above) shows that a android.os.NetworkOnMainThreadException is being thrown. This is strange, given that both coroutines are launched with a context of Dispatchers.IO, not Dispatchers.Main (Android's main UI thread).

Question: Why is the aforementioned exception being thrown, and how can this code be corrected?

Upvotes: 4

Views: 2331

Answers (3)

Dissident Dev
Dissident Dev

Reputation: 776

You should not be using GlobalScope

https://elizarov.medium.com/the-reason-to-avoid-globalscope-835337445abc

Launch the coroutine from the ViewModel with launch() and make all the repo functions suspend, and change context in the repo function with withContext(Dispatchers.IO)

Upvotes: 0

The problem is is that you're not creating a coroutine anywhere. You can use the higher order function suspendCoroutine for this. A simple example would be like so:

private suspend fun <T, R> request(call: Call<T>, transform: (T) -> R, default: T): Either<Failure, R> {
        return suspendCoroutine { continuation ->
            continuation.resume(try {
                val response = call.execute()
                when (response.isSuccessful) {
                    true -> Either.Right(transform((response.body() ?: default)))
                    false -> Either.Left(Failure.GenericFailure())
                }
            } catch (exception: Throwable) {
                Either.Left(Failure.GenericFailure())
            })
        }
    }

There's a lot of ways you can go with this, too. Note this function will never throw an exception. I think that's intended, but if you want to propagate it upwards to be able to wrap it in a try-catch block for example, you could use continuation.resumeWithException(...).

Since this function returns an actual coroutine, your withContext(Dispatchers.IO) should work as intended. Hope that helps!

Upvotes: 0

Francesc
Francesc

Reputation: 29260

Marking a function suspend does not make it suspendable, you have to ensure the work actually happens in a background thread.

You have this

override suspend fun run(params: Params) = repository.getContactHistory(params.userId, params.contactId)

which calls this

override fun getContactHistory(userId: Long, contactId: Long): Either<Failure, ContactHistory> {
    return request(cloudStore.get().getContactHistory(userId, contactId), {it}, ContactHistory(null, null))
}

These are all synchronous, your suspend modifier is not doing anything here.

A quick fix would be to change your repository like this

override suspend fun getContactHistory(userId: Long, contactId: Long): Either<Failure, ContactHistory> {
return withContext(Dispatchers.IO) {
    request(cloudStore.get().getContactHistory(userId, contactId), {it}, ContactHistory(null, null))
    }
}

But a much better solution would be to use the Coroutine adapter for Retrofit.

Upvotes: 1

Related Questions