Skip to content

fix(android): recover from dead DownloadManager HandlerThread after service restart - #53

Open
rzr1331 wants to merge 1 commit into
testpress:mainfrom
rzr1331:fix/download-manager-dead-handler-thread
Open

fix(android): recover from dead DownloadManager HandlerThread after service restart#53
rzr1331 wants to merge 1 commit into
testpress:mainfrom
rzr1331:fix/download-manager-dead-handler-thread

Conversation

@rzr1331

@rzr1331 rzr1331 commented May 24, 2026

Copy link
Copy Markdown

Problem

After a download completes (or fails), TPSDownloadService.onDestroy() calls DownloadManager.release(), which stops the internal HandlerThread. However, DownloadController.isInitialized is never reset to false, so the next call to getDownloadManager() returns the released (dead) instance.

The next download attempt tries to post a message to the dead handler and throws:

java.lang.IllegalStateException: Handler {e650204} sending message to a Handler on a dead thread
    at DownloadManager$InternalHandler.putDownload(DownloadManager.java:1237)

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 (Kotlin object singleton) sets isInitialized = true on first use but never clears it after DownloadManager.release(). The DownloadManager's internal HandlerThread is dead, but the controller keeps returning the stale instance.

Fix

TPStreamsRNPlayerView.kt

Added ensureDownloadManagerHealthy(), called at the top of tryCreatePlayer() before each player creation:

  • Uses reflection to access DownloadController.downloadManager and walk to its internalHandler's Looper thread.
  • If the thread is dead: creates a fresh DownloadManager reusing the existing SimpleCache, DatabaseProvider, DataSource.Factory, and ExecutorService. The SimpleCache must be reused — a file lock prevents opening the same cache directory twice.
  • Replaces DownloadController.downloadManager with the fresh instance.
  • Calls resetDownloadClientSingleton() to null out DownloadClient.instance so the next getInstance() call binds to the new DownloadManager.
  • If any reflection field is not found (e.g. renamed in a future Media3 version), the method catches the exception and returns without reinitialising — safe, conservative fallback.

TPStreamsDownloadModule.kt

  • Changed downloadClient from a lazy val to a computed property (get() = DownloadClient.getInstance(...)), so it always returns the current singleton rather than the one captured at first access.
  • Removed the isListening guard in addDownloadProgressListener so the listener is always registered with the current DownloadClient instance after a singleton reset. DownloadClient.listeners is a Set, so adding the same listener to the same instance is a no-op.

Verification

Tested on OnePlus 6T (Android 10, Media3 1.7.1):

  1. Tapped download on a Widevine-protected DASH stream.
  2. Download completed successfully.
  3. Killed the app, reopened, tapped download on a different video — completed without the IllegalStateException.
  4. Disabled network on device — offline DRM playback confirmed.
  5. Player settings showed "Downloaded" badge.

Notes

  • This fix is in the React Native bridge layer (react-native-tpstreams). The root cause lives in TPStreamsAndroidPlayer's DownloadController — ideally isInitialized would be reset in DownloadService.onDestroy() in the native SDK, which would make this bridge-layer workaround unnecessary.
  • The reflection targets (DownloadController.INSTANCE, isInitialized, downloadManager, databaseProvider, downloadCache, httpDataSourceFactory, downloadExecutor, DownloadClient.instance, DownloadManager.internalHandler) match TPStreamsAndroidPlayer v1.1.8. If field names change in future SDK versions, ensureDownloadManagerHealthy catches NoSuchFieldException and exits cleanly.

…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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +22 to +23
private val downloadClient: DownloadClient
get() = DownloadClient.getInstance(reactContext)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The value 3 for maxParallelDownloads is a magic number. It should be defined as a constant (e.g., in the companion object) to improve maintainability and clarity.

Comment on lines +336 to +345
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant