fix(android-auto): run server connection check off the main thread (#1920)

Android Auto browsing could freeze and crash the app (phone app "closes",
Android Auto shows a generic connection error) whenever the Capacitor
webview had not yet established the server connection.

Root cause: onLoadChildren runs on the media browser service main thread.
It calls loadAndroidAutoItems -> checkSetValidServerConnectionConfig, which
used runBlocking to ping every saved server config and authorize the user.
That blocked the main thread on network I/O. Android Auto (plus Assistant)
re-request the browse root every ~2s, and every onGetRoot flags a reload
while disconnected, so the blocking work was triggered repeatedly and
re-entrantly, producing an ANR.

Changes:
- checkSetValidServerConnectionConfig now runs on a dedicated
  Dispatchers.IO scope instead of runBlocking, so pings/authorize never
  block the caller. The callback contract is unchanged; downstream Android
  Auto callbacks already ran off the main thread.
- loadAndroidAutoItems coalesces overlapping calls into a single in-flight
  load and fires all queued callbacks on completion, so the browse-root
  polling storm can no longer spawn concurrent loads racing shared state.
- Fix latent bug in checkResetServerItems: server config id was compared
  with !== (reference identity) instead of != (value), causing spurious
  cache resets that fed the reload storm.

Co-authored-by: databoy2k <18686442+databoy2k@users.noreply.github.com>
This commit is contained in:
databoy2k
2026-08-04 16:26:09 -05:00
committed by GitHub
co-authored by databoy2k
parent 9664f79b1f
commit 9820db0ef6
@@ -9,8 +9,11 @@ import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import com.getcapacitor.JSObject
import java.util.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
import kotlin.coroutines.resume
@@ -46,6 +49,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
var userSettingsPlaybackRate:Float? = null
// Android Auto browses on the media browser service main thread, so the server pings
// and authorize call must not run there or they block it and trigger an ANR
private val androidAutoIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Android Auto re-requests the browse root every ~2s and multiple clients do so at once,
// so overlapping loads are coalesced into one to avoid racing on the shared server state
private var isLoadingAndroidAutoItems = false
private val pendingAndroidAutoLoadCallbacks = mutableListOf<() -> Unit>()
private val androidAutoLoadLock = Any()
fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null
}
@@ -136,7 +149,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
// and reset any server data already set
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id != serverConfigIdUsed) {
podcastEpisodeLibraryItemMap = mutableMapOf()
serverLibraries = listOf()
serverLibraryItems = mutableListOf()
@@ -730,7 +743,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
return mediaProgress
}
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
// Runs on [androidAutoIOScope] rather than blocking the caller: this is reached from
// onLoadChildren on the media browser service main thread, and the pings/authorize below
// are network calls, so runBlocking here caused ANRs while browsing in Android Auto
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = androidAutoIOScope.launch {
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
coroutineScope {
@@ -835,6 +851,26 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
fun loadAndroidAutoItems(cb: () -> Unit) {
Log.d(tag, "Load android auto items")
// Coalesce overlapping calls into a single load, every caller still gets its callback
synchronized(androidAutoLoadLock) {
pendingAndroidAutoLoadCallbacks.add(cb)
if (isLoadingAndroidAutoItems) {
Log.d(tag, "loadAndroidAutoItems: Load already in progress, queued callback")
return
}
isLoadingAndroidAutoItems = true
}
val onLoadFinished = {
val callbacks: List<() -> Unit>
synchronized(androidAutoLoadLock) {
isLoadingAndroidAutoItems = false
callbacks = pendingAndroidAutoLoadCallbacks.toList()
pendingAndroidAutoLoadCallbacks.clear()
}
callbacks.forEach { it() }
}
// Check if any valid server connection if not use locally downloaded books
checkSetValidServerConnectionConfig { isConnected ->
if (isConnected) {
@@ -844,14 +880,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
loadLibraries { libraries ->
if (libraries.isEmpty()) {
Log.w(tag, "No libraries returned from server request")
cb()
onLoadFinished()
} else {
cb() // Fully loaded
onLoadFinished() // Fully loaded
}
}
} else { // Not connected to server
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
cb()
onLoadFinished()
}
}
}