package app.pulse.updates import com.facebook.react.bridge.* import com.facebook.react.modules.core.DeviceEventManagerModule /** * PulseUpdates React Native Module * Bridges PulseController to React Native */ class PulseUpdatesModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { private const val TAG = "PulseUpdates" const val NAME = "PulseUpdates" /** * Get the bundle file path for React Native initialization * This is called before JS starts, so we need to set up context first */ fun getBundleFile(context: android.content.Context): java.io.File? { val controller = PulseController.getInstance() if (!controller.isStarted) { // Initialize context before starting (needed for database, embedded manifest, etc.) controller.initializeContext(context) controller.initializeWithoutStarting() } return controller.getBundleFile() } } override fun getName(): String = NAME override fun getConstants(): MutableMap? { return buildState().toHashMap() as? MutableMap } // MARK: - Configuration @ReactMethod fun configure( enabled: Boolean, updateUrl: String, runtimeVersion: String, checkOnLaunch: String, launchWaitMs: Int, channel: String?, signingKeyId: String?, signingPublicKey: String? ) { val config = PulseUpdatesConfig( enabled = enabled, updateUrl = updateUrl, runtimeVersion = runtimeVersion.ifEmpty { nativeRuntimeVersion() }, checkOnLaunch = checkOnLaunch, launchWaitMs = launchWaitMs, channel = channel, signingKeyId = signingKeyId, signingPublicKey = signingPublicKey ) PulseController.getInstance().configure(reactContext.applicationContext, config) PulseController.getInstance().start() pulseLog(TAG, "Configured enabled=$enabled url=$updateUrl runtime=${config.runtimeVersion}") } // MARK: - Get State @ReactMethod fun getStateAsync(promise: Promise) { promise.resolve(buildState()) } private fun buildState(): WritableMap { val controller = PulseController.getInstance() val state = Arguments.createMap() // Match expo-updates constants structure state.putBoolean("isEnabled", controller.config?.enabled ?: false) state.putBoolean("isEmbeddedLaunch", controller.isEmbeddedLaunch) // expo-updates calls this isUsingEmbeddedAssets state.putBoolean("isUsingEmbeddedAssets", controller.isEmbeddedLaunch) state.putString("runtimeVersion", controller.config?.runtimeVersion ?: nativeRuntimeVersion()) controller.launchedUpdate?.updateId?.let { state.putString("updateId", it) } ?: state.putNull("updateId") state.putString("channel", controller.config?.channel) // Return manifest JSON string for metadata (build number, etc.) controller.launchedManifestJson?.let { state.putString("manifestString", it) } ?: state.putNull("manifestString") controller.launchedUpdate?.commitTime?.let { state.putDouble("commitTime", it.toDouble()) } ?: state.putNull("commitTime") // Local assets map for expo-asset compatibility // Key: asset key from manifest, Value: local file:// URL val localAssetsMap = Arguments.createMap() controller.localAssets.forEach { (key, value) -> localAssetsMap.putString(key, value) } state.putMap("localAssets", localAssetsMap) return state } // MARK: - Check for Update @ReactMethod fun checkForUpdateAsync(promise: Promise) { PulseController.getInstance().checkForUpdate { result -> result.fold( onSuccess = { checkResult -> val response = Arguments.createMap() response.putBoolean("isAvailable", checkResult.isAvailable) checkResult.manifest?.let { manifest -> try { val json = org.json.JSONObject().apply { put("updateId", manifest.updateId) put("runtimeVersion", manifest.runtimeVersion) put("createdAt", manifest.createdAt) } response.putString("manifestString", json.toString()) } catch (e: Exception) { // Ignore serialization errors } } if (checkResult.isRollback) { response.putBoolean("isRollback", true) response.putString("failedReason", checkResult.failedReason ?: "Unknown") } promise.resolve(response) }, onFailure = { error -> promise.reject("E_CHECK_UPDATE", error.message, error) } ) } } // MARK: - Fetch Update @ReactMethod fun fetchUpdateAsync(promise: Promise) { PulseController.getInstance().fetchUpdate { result -> result.fold( onSuccess = { fetchResult -> val response = Arguments.createMap() response.putBoolean("isNew", fetchResult.isNew) fetchResult.manifest?.let { manifest -> try { val json = org.json.JSONObject().apply { put("updateId", manifest.updateId) put("runtimeVersion", manifest.runtimeVersion) put("createdAt", manifest.createdAt) } response.putString("manifestString", json.toString()) } catch (e: Exception) { // Ignore serialization errors } } promise.resolve(response) }, onFailure = { error -> promise.reject("E_FETCH_UPDATE", error.message, error) } ) } } // MARK: - Reload @ReactMethod fun reloadAsync(promise: Promise) { // Re-select the best update before reloading PulseController.getInstance().reload { success -> val newBundlePath = PulseController.getInstance().launchAssetFile?.absolutePath pulseLog(TAG, "reloadAsync: controller reload success=$success launchAssetFile=$newBundlePath") UiThreadUtil.runOnUiThread { try { val application = reactContext.currentActivity?.application ?: reactContext.applicationContext as? android.app.Application val reactApplication = application as? com.facebook.react.ReactApplication if (reactApplication == null) { pulseLogError(TAG, "reloadAsync: ReactApplication is null") promise.reject("E_RELOAD", "Could not get ReactApplication", null) return@runOnUiThread } // Try new architecture first (ReactHost) - like expo-updates does try { val reactHost = reactApplication.javaClass.getMethod("getReactHost").invoke(reactApplication) if (reactHost != null) { pulseLog(TAG, "reloadAsync: using ReactHost.reload()") // Check lifecycle state and resume if needed (like expo does) val currentActivity = reactContext.currentActivity try { val lifecycleState = reactHost.javaClass.getMethod("getLifecycleState").invoke(reactHost) val resumedState = Class.forName("com.facebook.react.common.LifecycleState") .getField("RESUMED").get(null) if (lifecycleState != resumedState && currentActivity != null) { reactHost.javaClass.getMethod("onHostResume", android.app.Activity::class.java) .invoke(reactHost, currentActivity) } } catch (e: Exception) { pulseLogWarn(TAG, "reloadAsync: could not check/resume lifecycle: ${e.message}") } reactHost.javaClass.getMethod("reload", String::class.java) .invoke(reactHost, "PulseUpdates reload") promise.resolve(null) return@runOnUiThread } } catch (e: Exception) { pulseLog(TAG, "reloadAsync: ReactHost not available: ${e.message}") } // Fall back to old architecture (ReactNativeHost) val reactNativeHost = reactApplication.reactNativeHost if (reactNativeHost != null) { pulseLog(TAG, "reloadAsync: using ReactNativeHost.recreateReactContextInBackground()") // Update the bundle loader via reflection (needed for bridge mode) if (newBundlePath != null) { try { val instanceManager = reactNativeHost.reactInstanceManager if (instanceManager != null) { val jsBundleLoaderField = instanceManager.javaClass.getDeclaredField("mBundleLoader") jsBundleLoaderField.isAccessible = true jsBundleLoaderField[instanceManager] = com.facebook.react.bridge.JSBundleLoader.createFileLoader(newBundlePath) pulseLog(TAG, "reloadAsync: Updated mBundleLoader via reflection") } } catch (e: Exception) { pulseLogWarn(TAG, "reloadAsync: Could not update mBundleLoader: ${e.message}") } } reactNativeHost.reactInstanceManager?.recreateReactContextInBackground() promise.resolve(null) } else { pulseLogError(TAG, "reloadAsync: ReactNativeHost is null") promise.reject("E_RELOAD", "ReactNativeHost is null", null) } } catch (e: Exception) { pulseLogError(TAG, "reloadAsync error: ${e.message}") e.printStackTrace() promise.reject("E_RELOAD", e.message, e) } } } } // MARK: - Health @ReactMethod fun markAppReady(promise: Promise) { PulseController.getInstance().markAppReady() promise.resolve(null) } @ReactMethod fun reportLaunchFailure(reason: String, promise: Promise) { PulseController.getInstance().reportLaunchFailure(reason) promise.resolve(null) } // MARK: - Event Emitter Support @ReactMethod fun addListener(eventName: String) { // Required for RN event emitter } @ReactMethod fun removeListeners(count: Int) { // Required for RN event emitter } private fun sendEvent(eventName: String, params: WritableMap?) { reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit(eventName, params) } // MARK: - Helpers private fun nativeRuntimeVersion(): String { return try { val packageInfo = reactContext.packageManager.getPackageInfo(reactContext.packageName, 0) packageInfo.versionName ?: "unknown" } catch (e: Exception) { "unknown" } } } private object UiThreadUtil { fun runOnUiThread(runnable: Runnable) { android.os.Handler(android.os.Looper.getMainLooper()).post(runnable) } }