From 9820db0ef67825b21395ae9c6c235fa01b399dbe Mon Sep 17 00:00:00 2001 From: databoy2k Date: Tue, 4 Aug 2026 15:26:09 -0600 Subject: [PATCH] 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> --- .../audiobookshelf/app/media/MediaManager.kt | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index db3ee4ad..ea5fbdf0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -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() } } }