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:
- Convert the Boolean helpers into suspending functions and call them from
lifecycleScope / viewModelScope.
- Expose the quota/capability state as
Flow, StateFlow, or LiveData, then render the dialog/UI once the value is available.
- 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.
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
eu.opencloud.android.MainApp.shouldShowDialog(Activity)eu.opencloud.android.presentation.accounts.ManageAccountsViewModel.checkUserLight(String)eu.opencloud.android.presentation.capabilities.CapabilityViewModel.checkMultiPersonal()kotlinx.coroutines.runBlocking(...)LiveData/Flow, or launch work from a coroutine scope insteadWhat I found
The current source still contains several synchronous
runBlockingbridges.In
MainApp, the Activity lifecycle callback callsshouldShowDialog(activity)before showing anAlertDialog. That helper usesrunBlocking(CoroutinesDispatcherProvider().io)and performs several stored-data lookups before returning a Boolean:The same pattern also appears in ViewModel methods:
and:
The important detail is that passing an IO dispatcher to
runBlockingdoes not make the caller non-blocking. Kotlin’s documentation saysrunBlockingblocks 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
A second similar trace is:
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
runBlockingstill blocks the original calling thread while waiting for the result.Possible fix
Avoid using
runBlockingfrom Activity/ViewModel paths. A few possible approaches:lifecycleScope/viewModelScope.Flow,StateFlow, orLiveData, then render the dialog/UI once the value is available.For example, the Activity path could be restructured as:
lifecycleScope.launch { val shouldShow = withContext(dispatchers.io) { computeShouldShowDialog(activity) } if (shouldShow) { showServerAccountWarningDialog(activity) } }For ViewModels:
Reference
Kotlin API:
kotlinx.coroutines.runBlockinghttps://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html
Relevant Kotlin documentation wording:
Android threading guide:
https://developer.android.com/guide/components/processes-and-threads
Relevant Android guidance: