From efe3ec6003fd4cfc81b95b9f838d67ceeeaf93a4 Mon Sep 17 00:00:00 2001 From: rzr1331 Date: Sun, 24 May 2026 11:14:33 +0530 Subject: [PATCH] fix(android): recover from dead DownloadManager HandlerThread after service restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DownloadController.isInitialized is never reset to false even after TPSDownloadService.onDestroy() calls DownloadManager.release(), which kills the internal HandlerThread. The next download attempt reuses the stale DownloadManager and throws: IllegalStateException: Handler {…} sending message to a Handler on a dead thread Changes: - TPStreamsRNPlayerView: add ensureDownloadManagerHealthy() called before every TPStreamsPlayer.create(). It detects the dead thread via reflection and replaces the DownloadManager in DownloadController with a fresh instance, reusing the existing SimpleCache (cannot recreate it — a file lock prevents opening the same directory twice). Resets the DownloadClient singleton so it binds to the new manager. - TPStreamsDownloadModule: change downloadClient from a lazy val to a computed property so it always returns the current singleton after a reset. Also remove the isListening guard in addDownloadProgressListener so the listener is re-registered with the fresh DownloadClient instance. Verified end-to-end on OnePlus 6T (Android 10): - Download completes successfully after the service has been stopped and restarted across multiple sessions. - Offline DRM playback confirmed with network disabled. --- .../com/tpstreams/TPStreamsDownloadModule.kt | 18 ++-- .../com/tpstreams/TPStreamsRNPlayerView.kt | 89 +++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/tpstreams/TPStreamsDownloadModule.kt b/android/src/main/java/com/tpstreams/TPStreamsDownloadModule.kt index f4679c6..6438d4e 100644 --- a/android/src/main/java/com/tpstreams/TPStreamsDownloadModule.kt +++ b/android/src/main/java/com/tpstreams/TPStreamsDownloadModule.kt @@ -17,9 +17,10 @@ import com.tpstreams.player.download.DownloadItem @OptIn(UnstableApi::class) class TPStreamsDownloadModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), DownloadClient.Listener { - private val downloadClient: DownloadClient by lazy { - DownloadClient.getInstance(reactContext) - } + // Computed property — always returns the current singleton, which may be replaced + // by ensureDownloadManagerHealthy() in TPStreamsRNPlayerView after a service restart. + private val downloadClient: DownloadClient + get() = DownloadClient.getInstance(reactContext) private var isListening = false @@ -61,11 +62,12 @@ class TPStreamsDownloadModule(private val reactContext: ReactApplicationContext) @ReactMethod fun addDownloadProgressListener(promise: Promise) { try { - if (!isListening) { - downloadClient.addListener(this) - isListening = true - Log.d(TAG, "Started listening for download progress") - } + // Always call addListener — DownloadClient.listeners is a Set so duplicates on + // the same instance are ignored. After a DownloadClient singleton reset (caused + // by ensureDownloadManagerHealthy), the new instance needs this listener added. + downloadClient.addListener(this) + isListening = true + Log.d(TAG, "Started listening for download progress") promise.resolve(null) } catch (e: Exception) { Log.e(TAG, "Error starting progress listener: ${e.message}", e) diff --git a/android/src/main/java/com/tpstreams/TPStreamsRNPlayerView.kt b/android/src/main/java/com/tpstreams/TPStreamsRNPlayerView.kt index 9c9059d..8b8666e 100644 --- a/android/src/main/java/com/tpstreams/TPStreamsRNPlayerView.kt +++ b/android/src/main/java/com/tpstreams/TPStreamsRNPlayerView.kt @@ -16,6 +16,12 @@ import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import android.media.MediaCodec import android.view.View.MeasureSpec +import androidx.media3.database.DatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.exoplayer.offline.DownloadManager +import java.util.concurrent.ExecutorService @OptIn(UnstableApi::class) class TPStreamsRNPlayerView(context: ThemedReactContext) : FrameLayout(context) { @@ -140,6 +146,8 @@ class TPStreamsRNPlayerView(context: ThemedReactContext) : FrameLayout(context) if (videoId.isNullOrEmpty() || accessToken.isNullOrEmpty()) return if (player != null) return + ensureDownloadManagerHealthy() + try { player = TPStreamsPlayer.create( context, @@ -272,6 +280,87 @@ class TPStreamsRNPlayerView(context: ThemedReactContext) : FrameLayout(context) player?.pause() } + /** + * Workaround for a bug in TPStreamsAndroidPlayer: DownloadController.isInitialized is never + * reset to false even after TPSDownloadService.onDestroy() calls DownloadManager.release(), + * which kills its internal HandlerThread. On the next download attempt, the stale + * DownloadManager is reused and sending a message to its dead handler throws: + * IllegalStateException: Handler {…} sending message to a Handler on a dead thread + * + * Fix: detect the dead HandlerThread via reflection. If dead, replace the DownloadManager + * inside DownloadController with a fresh instance that reuses the existing SimpleCache + * (we cannot recreate SimpleCache — a file lock prevents opening the same directory twice). + * Then reset the DownloadClient singleton so it binds to the new DownloadManager. + */ + @OptIn(UnstableApi::class) + private fun ensureDownloadManagerHealthy() { + try { + val ctrlClass = Class.forName("com.tpstreams.player.download.DownloadController") + val instance = ctrlClass.getDeclaredField("INSTANCE").apply { isAccessible = true }.get(null) ?: return + + val isInitField = ctrlClass.getDeclaredField("isInitialized").apply { isAccessible = true } + if (!(isInitField.get(instance) as Boolean)) return // not yet initialized, TPStreamsPlayer.create will init it + + val dmField = ctrlClass.getDeclaredField("downloadManager").apply { isAccessible = true } + val dm = dmField.get(instance) ?: return + + if (isDownloadManagerAlive(dm)) return // healthy, nothing to do + + Log.w("TPStreamsRNPlayerView", "DownloadManager HandlerThread is dead — reinitializing with fresh instance") + + // Reuse existing components; SimpleCache must not be recreated (file lock) + val dbProvider = ctrlClass.getDeclaredField("databaseProvider").apply { isAccessible = true }.get(instance) as? DatabaseProvider ?: return + val cache = ctrlClass.getDeclaredField("downloadCache").apply { isAccessible = true }.get(instance) as? Cache ?: return + val httpDsf = ctrlClass.getDeclaredField("httpDataSourceFactory").apply { isAccessible = true }.get(instance) as? DataSource.Factory ?: return + val executor = ctrlClass.getDeclaredField("downloadExecutor").apply { isAccessible = true }.get(instance) as? ExecutorService ?: return + + val appCtx = context.applicationContext + val newDm = DownloadManager( + appCtx, dbProvider, cache, + DefaultDataSource.Factory(appCtx, httpDsf), + executor + ).apply { maxParallelDownloads = 3 } + + dmField.set(instance, newDm) + Log.d("TPStreamsRNPlayerView", "DownloadController: fresh DownloadManager installed") + + resetDownloadClientSingleton() + + } catch (e: Exception) { + Log.w("TPStreamsRNPlayerView", "ensureDownloadManagerHealthy: ${e.message}") + } + } + + private fun isDownloadManagerAlive(dm: Any): Boolean { + return try { + val handlerField = dm.javaClass.getDeclaredField("internalHandler").apply { isAccessible = true } + val handler = handlerField.get(dm) ?: return false + var clz: Class<*>? = handler.javaClass + var mLooperField: java.lang.reflect.Field? = null + while (clz != null && mLooperField == null) { + try { mLooperField = clz.getDeclaredField("mLooper").apply { isAccessible = true } } + catch (_: NoSuchFieldException) { clz = clz.superclass } + } + val looper = mLooperField?.get(handler) as? android.os.Looper + looper?.thread?.isAlive == true + } catch (e: Exception) { + // If we can't determine liveness, assume alive — conservative, avoids spurious reinit + Log.w("TPStreamsRNPlayerView", "isDownloadManagerAlive: can't determine, assuming alive: ${e.message}") + true + } + } + + private fun resetDownloadClientSingleton() { + try { + val clientClass = Class.forName("com.tpstreams.player.download.DownloadClient") + val instanceField = clientClass.getDeclaredField("instance").apply { isAccessible = true } + instanceField.set(null, null) + Log.d("TPStreamsRNPlayerView", "DownloadClient: singleton reset for fresh binding") + } catch (e: Exception) { + Log.w("TPStreamsRNPlayerView", "resetDownloadClientSingleton: ${e.message}") + } + } + fun releasePlayer() { try { player?.release()