package com.noibu.mobile.reactnative import android.os.Handler import android.os.Looper import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.modules.network.OkHttpClientProvider import com.noibu.mobile.core.CoreSDK import com.noibu.mobile.core.events.Network import com.noibu.mobile.core.shared.getCurrentTimeMillis import com.noibu.mobile.sessionreplay.SessionReplaySDK import com.noibu.mobile.sessionreplay.api.installNetworkInstrumentation /** * Shared implementation behind both the legacy and new-architecture module classes — a thin * adapter over [SessionReplaySDK] (the same native module every Android app uses; all capture — * replay, touch, keyboard, errors — is native). */ internal object NoibuModuleImpl { const val NAME = "NoibuReactNative" /** Runs on the main thread (lifecycle observers demand it — RN calls in on the JS thread); * reports completion through [onDone] (null = success) for the module's promise. */ fun initialize(reactContext: ReactApplicationContext, config: ReadableMap, onDone: (Throwable?) -> Unit) { val sdkConfig = try { config.toSessionReplayConfig() } catch (t: Throwable) { onDone(t); return } Handler(Looper.getMainLooper()).post { try { SessionReplaySDK.initialize(reactContext.applicationContext, sdkConfig) // RN paints backgrounds/radii with its own drawables — the walker consults this // typed resolver for them (see DrawableStyleResolver seam). SessionReplaySDK.registerDrawableStyleResolver(ReactDrawableStyleResolver) // RN Android networking rides OkHttp: injecting Noibu's interceptor captures JS // fetch/XHR AND native requests with no JS patching. Skipped when the JS fallback // monitor is on (double capture) or network tracking is off. Clients RN creates // after this point pick it up; apps installing their own factory should add // installNetworkInstrumentation() to it. if (sdkConfig.trackNetwork && !config.boolOr("captureJsNetwork", false)) { OkHttpClientProvider.setOkHttpClientFactory { OkHttpClientProvider.createClientBuilder(reactContext.applicationContext) .installNetworkInstrumentation() .build() } } // JS init runs after the Activity is already resumed — the monitors' lifecycle // callbacks missed it. Attach it so capture starts on the current screen. reactContext.currentActivity?.let { SessionReplaySDK.notifyActivityResumed(it) } onDone(null) } catch (t: Throwable) { onDone(t) } } } /** * [NoibuWebView] (JS): resolve the react view for [viewTag] and enable Noibu tracking on the * WebView inside it (react-native-webview nests the real WebView in a FrameLayout wrapper). * The JS wrapper withholds `source` until this resolves, so the doc-start bridge exists in * the first real document — same before-content contract as native `WebViewTracking.enable`. */ fun enableWebViewTracking(reactContext: ReactApplicationContext, viewTag: Int, onDone: (Throwable?) -> Unit) { val handler = Handler(Looper.getMainLooper()) // Fabric commits native mounting on the UI thread AFTER the JS effect that calls us — // resolveView can miss by a frame. Retry briefly before failing. var attempts = 0 lateinit var attempt: Runnable attempt = Runnable { val webView = runCatching { com.facebook.react.uimanager.UIManagerHelper .getUIManagerForReactTag(reactContext, viewTag)?.resolveView(viewTag) ?.let { findWebView(it) } }.getOrNull() when { webView != null -> { runCatching { com.noibu.mobile.sessionreplay.api.WebViewTracking.enable(webView) } .fold({ onDone(null) }, { onDone(it) }) } attempts++ < 20 -> handler.postDelayed(attempt, 50) else -> onDone(IllegalStateException("no WebView under react view $viewTag")) } } handler.post(attempt) } private fun findWebView(view: android.view.View): android.webkit.WebView? { if (view is android.webkit.WebView) return view if (view !is android.view.ViewGroup) return null for (i in 0 until view.childCount) { findWebView(view.getChildAt(i))?.let { return it } } return null } fun trackNavigation(name: String?) = SessionReplaySDK.trackNavigation(name) fun addCustomAttribute(name: String, value: String) = SessionReplaySDK.addCustomAttribute(name, value) fun addError(message: String, stack: String?) = SessionReplaySDK.addError(message, stack) /** JS fallback network monitor (`captureJsNetwork: true`) → core Network event. */ fun recordNetwork(event: ReadableMap) { val durationMs = event.longOrNull("durationMs") ?: 0L CoreSDK.enqueue( Network( method = event.stringOrNull("method") ?: "GET", url = event.stringOrNull("url") ?: return, statusCode = event.intOr("statusCode", -1), durationMs = durationMs, requestBytes = event.longOrNull("requestBytes"), responseBytes = event.longOrNull("responseBytes"), errorMessage = event.stringOrNull("errorMessage"), firstParty = event.boolOr("firstParty", true), // Request start, minted on the NTP-corrected clock (JS Date.now isn't on the // replay timeline). timestampMillis = getCurrentTimeMillis() - durationMs, requestPayload = event.stringOrNull("requestPayload"), responsePayload = event.stringOrNull("responsePayload"), requestHeaders = event.stringMapOrNull("requestHeaders"), responseHeaders = event.stringMapOrNull("responseHeaders"), ) ) } } private fun ReadableMap.stringMapOrNull(key: String): Map? { if (!hasKey(key) || isNull(key)) return null val map = getMap(key) ?: return null return buildMap { val it = map.keySetIterator() while (it.hasNextKey()) { val k = it.nextKey() map.getString(k)?.let { v -> put(k, v) } } } }