package app.pulse.updates.errorrecovery import android.os.Handler import android.os.HandlerThread import com.facebook.react.bridge.ReactMarker import com.facebook.react.bridge.ReactMarker.MarkerListener import com.facebook.react.bridge.ReactMarkerConstants import app.pulse.updates.pulseLog import app.pulse.updates.pulseLogError /** * Entry point for the error recovery flow. Responsible for initializing the error recovery handler * and handler thread, and for registering (and unregistering) listeners to lifecycle events so that * the appropriate error recovery flows will be triggered. * * The error recovery flow is intended to be lightweight and is *not* a full safety net whose * purpose is to avoid crashes at all costs. Rather, its primary purpose is to prevent bad updates * from "bricking" an app by causing crashes before there is ever a chance to download a fix. * * Notably, the error listener will be unregistered 10 seconds after content has appeared; we assume * that by this point, pulse-updates has had enough time to download a new update if there is one, * and so there is no more need to trigger the error recovery pipeline. */ class PulseErrorRecovery { internal val handlerThread = HandlerThread("pulse-updates-error-recovery") internal lateinit var handler: Handler private var delegate: PulseErrorRecoveryDelegate? = null private var shouldHandleException = false // Previous JVM uncaught-exception handler, so we can chain/restore it (Android analog of // iOS RCTGet/SetFatalHandler). Captured when we install ours in startMonitoring(). private var previousUncaughtHandler: Thread.UncaughtExceptionHandler? = null private var pulseUncaughtHandler: Thread.UncaughtExceptionHandler? = null fun initialize(delegate: PulseErrorRecoveryDelegate) { this.delegate = delegate if (!::handler.isInitialized) { handlerThread.start() handler = PulseErrorRecoveryHandler(handlerThread.looper, delegate) } } fun startMonitoring() { registerContentAppearedListener() shouldHandleException = true installUncaughtExceptionHandler() } /** * Install a JVM-level uncaught exception handler that routes crashes into the error recovery * pipeline before delegating to the previously installed handler. This is the Android analog of * iOS setRCTErrorHandlers(): without it, PulseErrorRecoveryHandler has no entry point and bad * updates can brick the app. */ private fun installUncaughtExceptionHandler() { if (pulseUncaughtHandler != null) return previousUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler() val handler = Thread.UncaughtExceptionHandler { thread, throwable -> try { if (shouldHandleException) { handleException(throwable as? Exception ?: RuntimeException(throwable)) } } catch (e: Exception) { pulseLogError(TAG, "ErrorRecovery: failed to handle uncaught exception: ${e.message}") } finally { previousUncaughtHandler?.uncaughtException(thread, throwable) } } pulseUncaughtHandler = handler Thread.setDefaultUncaughtExceptionHandler(handler) } private fun uninstallUncaughtExceptionHandler() { // Only restore if our handler is still the active one (avoid clobbering a handler // installed after us). if (pulseUncaughtHandler != null && Thread.getDefaultUncaughtExceptionHandler() === pulseUncaughtHandler) { Thread.setDefaultUncaughtExceptionHandler(previousUncaughtHandler) } pulseUncaughtHandler = null previousUncaughtHandler = null } /** * Handle an exception that occurred during app execution */ fun handleException(exception: Exception) { if (shouldHandleException) { pulseLogError(TAG, "ErrorRecovery: exception encountered: ${exception.localizedMessage}") handler.sendMessage(handler.obtainMessage(PulseErrorRecoveryHandler.MessageType.EXCEPTION_ENCOUNTERED, exception)) } } fun notifyNewRemoteLoadStatus(newStatus: PulseErrorRecoveryDelegate.RemoteLoadStatus) { pulseLog(TAG, "ErrorRecovery: remote load status changed: $newStatus") handler.sendMessage(handler.obtainMessage(PulseErrorRecoveryHandler.MessageType.REMOTE_LOAD_STATUS_CHANGED, newStatus)) } internal fun handleContentAppeared() { handler.sendMessage(handler.obtainMessage(PulseErrorRecoveryHandler.MessageType.CONTENT_APPEARED)) unregisterContentAppearedListener() // wait 10s before unsetting error handlers handler.postDelayed({ shouldHandleException = false uninstallUncaughtExceptionHandler() handler.postDelayed({ handlerThread.quitSafely() }, 10000) }, 10000) } private val contentAppearedListener = MarkerListener { name, _, _ -> if (name == ReactMarkerConstants.CONTENT_APPEARED) { handleContentAppeared() } } private fun registerContentAppearedListener() { ReactMarker.addListener(contentAppearedListener) } private fun unregisterContentAppearedListener() { ReactMarker.removeListener(contentAppearedListener) } companion object { private const val TAG = "PulseErrorRecovery" } }