Skip to content

Potential main thread blocking: runBlocking blocks Activity/ViewModel main-thread paths #153

@venkyqz

Description

@venkyqz

Hi OpenCloud Android Team,

I’m a PhD student researching Android performance issues. My research group recently ran a static analysis scan for thread affinity and main-thread blocking bugs, and our prototype flagged a potential issue in OpenCloud.

Checked target

  • Source-level callers:
    • eu.opencloud.android.MainApp.shouldShowDialog(Activity)
    • eu.opencloud.android.presentation.accounts.ManageAccountsViewModel.checkUserLight(String)
    • eu.opencloud.android.presentation.capabilities.CapabilityViewModel.checkMultiPersonal()
  • Detected API/pattern: kotlinx.coroutines.runBlocking(...)
  • Observed context: Activity lifecycle / ViewModel methods that may be called from the main/UI thread
  • Expected context: avoid blocking the UI thread; expose suspending APIs, cached state, LiveData/Flow, or launch work from a coroutine scope instead

What I found

The current source still contains several synchronous runBlocking bridges.

In MainApp, the Activity lifecycle callback calls shouldShowDialog(activity) before showing an AlertDialog. That helper uses runBlocking(CoroutinesDispatcherProvider().io) and performs several stored-data lookups before returning a Boolean:

if (!dontShowAgainDialogPref && shouldShowDialog(activity)) {
    val checkboxDialog = activity.layoutInflater.inflate(R.layout.checkbox_dialog, null)
    ...
    val alertDialog = builder.create()
    alertDialog.show()
}

private fun shouldShowDialog(activity: Activity) =
    runBlocking(CoroutinesDispatcherProvider().io) {
        if (activity !is FileDisplayActivity) return@runBlocking false

        val account = AccountUtils.getCurrentOpenCloudAccount(appContext) ?: return@runBlocking false
        val capabilities = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
            getStoredCapabilitiesUseCase(...)
        }
        ...
        val quota = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
            getStoredQuotaUseCase(...)
        }

        spacesAllowed && personalSpace == null && !isLightUser
    }

The same pattern also appears in ViewModel methods:

fun checkUserLight(accountName: String): Boolean = runBlocking(CoroutinesDispatcherProvider().io) {
    val quota = withContext(CoroutinesDispatcherProvider().io) {
        getStoredQuotaUseCase(GetStoredQuotaUseCase.Params(accountName))
    }
    quota.getDataOrNull()?.available == -4L
}

and:

fun checkMultiPersonal(): Boolean = runBlocking(CoroutinesDispatcherProvider().io) {
    val capabilities = withContext(CoroutinesDispatcherProvider().io) {
        getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName))
    }
    capabilities?.spaces?.hasMultiplePersonalSpaces == true
}

The important detail is that passing an IO dispatcher to runBlocking does not make the caller non-blocking. Kotlin’s documentation says runBlocking blocks the current thread until the coroutine completes. So if these helpers are called from the Activity lifecycle or UI/ViewModel path, the UI thread waits synchronously for the stored-data work to finish.

Verified bug trace

Activity lifecycle / UI path
  -> MainApp.onActivityCreated(...)
  -> shouldShowDialog(activity)
  -> runBlocking(CoroutinesDispatcherProvider().io)
  -> stored capabilities / personal-space / quota lookups
  -> caller thread is blocked until the coroutine completes
  -> possible startup/dialog delay, jank, or ANR risk

A second similar trace is:

UI / ViewModel consumer
  -> ManageAccountsViewModel.checkUserLight(accountName)
     or CapabilityViewModel.checkMultiPersonal()
  -> runBlocking(CoroutinesDispatcherProvider().io)
  -> stored quota/capability lookup
  -> caller thread waits synchronously
  -> possible UI stall if called from Compose/Fragment/Activity rendering or event handling

I did not dynamically reproduce an ANR, so this should be treated as a source-confirmed main-thread blocking risk. The pattern is still present in the current source, and the risk depends on how often these helpers are called and how long the stored-data lookups take on a given device/account state.

Why this matters

Android’s main/UI thread is responsible for dispatching UI events and drawing. Blocking it during Activity startup or UI rendering can make the app appear frozen, especially if the underlying storage/database/account lookup is slow.

This is easy to miss because the coroutine body itself is dispatched to IO, but runBlocking still blocks the original calling thread while waiting for the result.

Possible fix

Avoid using runBlocking from Activity/ViewModel paths. A few possible approaches:

  1. Convert the Boolean helpers into suspending functions and call them from lifecycleScope / viewModelScope.
  2. Expose the quota/capability state as Flow, StateFlow, or LiveData, then render the dialog/UI once the value is available.
  3. Cache the needed state in the ViewModel or repository and avoid synchronous waits during Activity lifecycle callbacks.

For example, the Activity path could be restructured as:

lifecycleScope.launch {
    val shouldShow = withContext(dispatchers.io) {
        computeShouldShowDialog(activity)
    }

    if (shouldShow) {
        showServerAccountWarningDialog(activity)
    }
}

For ViewModels:

fun refreshUserLightState(accountName: String) {
    viewModelScope.launch(coroutinesDispatcherProvider.io) {
        val quota = getStoredQuotaUseCase(GetStoredQuotaUseCase.Params(accountName))
        val isLightUser = quota.getDataOrNull()?.available == -4L

        withContext(coroutinesDispatcherProvider.main) {
            _isLightUser.value = isLightUser
        }
    }
}

Reference

Kotlin API: kotlinx.coroutines.runBlocking

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html

Relevant Kotlin documentation wording:

Runs the given block in-place in a new coroutine based on context, blocking the current thread until its completion, and then returning its result.

Android threading guide:

https://developer.android.com/guide/components/processes-and-threads

Relevant Android guidance:

Don't block the UI thread.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions