fix(android): recover from dead DownloadManager HandlerThread after service restart - #53
Conversation
…ervice restart
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.
There was a problem hiding this comment.
Code Review
This pull request implements a workaround for a bug in the TPStreamsAndroidPlayer where the DownloadManager's internal thread becomes dead after a service restart. The fix involves using reflection to detect the dead thread, re-initializing the DownloadManager while reusing existing cache components, and resetting the DownloadClient singleton. Feedback suggests addressing a potential loss of listener connectivity in the download module after a singleton reset, replacing a magic number with a constant for parallel downloads, and simplifying the reflection logic by casting the internal handler to access its looper directly.
| private val downloadClient: DownloadClient | ||
| get() = DownloadClient.getInstance(reactContext) |
There was a problem hiding this comment.
When ensureDownloadManagerHealthy() in TPStreamsRNPlayerView resets the DownloadClient singleton, this module (which is a listener) will lose its connection to the new instance. Since isListening remains true, the React Native side may not call addDownloadProgressListener again, resulting in no further download updates being sent to JS. Consider implementing a mechanism to re-attach the listener if the DownloadClient instance has changed while isListening is true.
| appCtx, dbProvider, cache, | ||
| DefaultDataSource.Factory(appCtx, httpDsf), | ||
| executor | ||
| ).apply { maxParallelDownloads = 3 } |
| 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 |
There was a problem hiding this comment.
The reflection logic to find the mLooper field is unnecessary. Since the internalHandler extends android.os.Handler, you can cast it and access the public looper property directly. This is safer and more efficient.
| 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 | |
| val handlerField = dm.javaClass.getDeclaredField("internalHandler").apply { isAccessible = true } | |
| val handler = handlerField.get(dm) as? android.os.Handler ?: return false | |
| handler.looper.thread.isAlive |
Problem
After a download completes (or fails),
TPSDownloadService.onDestroy()callsDownloadManager.release(), which stops the internalHandlerThread. However,DownloadController.isInitializedis never reset tofalse, so the next call togetDownloadManager()returns the released (dead) instance.The next download attempt tries to post a message to the dead handler and throws:
This makes downloads unreliable after the first session — the download notification appears but no progress is made and the download never completes.
Root cause
DownloadController(Kotlinobjectsingleton) setsisInitialized = trueon first use but never clears it afterDownloadManager.release(). TheDownloadManager's internalHandlerThreadis dead, but the controller keeps returning the stale instance.Fix
TPStreamsRNPlayerView.ktAdded
ensureDownloadManagerHealthy(), called at the top oftryCreatePlayer()before each player creation:DownloadController.downloadManagerand walk to itsinternalHandler's Looper thread.DownloadManagerreusing the existingSimpleCache,DatabaseProvider,DataSource.Factory, andExecutorService. TheSimpleCachemust be reused — a file lock prevents opening the same cache directory twice.DownloadController.downloadManagerwith the fresh instance.resetDownloadClientSingleton()to null outDownloadClient.instanceso the nextgetInstance()call binds to the newDownloadManager.TPStreamsDownloadModule.ktdownloadClientfrom alazy valto a computed property (get() = DownloadClient.getInstance(...)), so it always returns the current singleton rather than the one captured at first access.isListeningguard inaddDownloadProgressListenerso the listener is always registered with the currentDownloadClientinstance after a singleton reset.DownloadClient.listenersis aSet, so adding the same listener to the same instance is a no-op.Verification
Tested on OnePlus 6T (Android 10, Media3 1.7.1):
IllegalStateException.Notes
react-native-tpstreams). The root cause lives inTPStreamsAndroidPlayer'sDownloadController— ideallyisInitializedwould be reset inDownloadService.onDestroy()in the native SDK, which would make this bridge-layer workaround unnecessary.DownloadController.INSTANCE,isInitialized,downloadManager,databaseProvider,downloadCache,httpDataSourceFactory,downloadExecutor,DownloadClient.instance,DownloadManager.internalHandler) matchTPStreamsAndroidPlayerv1.1.8. If field names change in future SDK versions,ensureDownloadManagerHealthycatchesNoSuchFieldExceptionand exits cleanly.