package com.namiml.reactnative import android.os.Handler import android.os.Looper import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.turbomodule.core.interfaces.TurboModule import com.namiml.flow.NamiFlowManager import com.namiml.flow.NamiHandoffComplete import com.namiml.flow.NamiHandoffOutcome import com.namiml.flow.NamiLoginOutcome import com.namiml.flow.NamiLoginSuccess import com.namiml.flow.NamiPermissionOutcome import com.namiml.flow.NamiPurchaseOutcome import com.namiml.paywall.NamiSKU import com.namiml.paywall.model.NamiPurchaseSuccess import java.util.UUID import java.util.concurrent.ConcurrentHashMap @ReactModule(name = NamiFlowManagerBridgeModule.NAME) class NamiFlowManagerBridgeModule internal constructor( reactContext: ReactApplicationContext, ) : ReactContextBaseJavaModule(reactContext), TurboModule { companion object { const val NAME = "RNNamiFlowManager" } override fun getName(): String = NAME @Volatile private var eventHandlerActive: Boolean = false @Volatile private var stepHandoffActive: Boolean = false /** Pending handoff tokens keyed by handoffId. Thread-safe for concurrent complete() calls. */ private val pendingHandoffs = ConcurrentHashMap() @ReactMethod fun registerStepHandoff() { stepHandoffActive = true NamiFlowManager.registerStepHandoff { handoffTag, handoffData, complete -> if (!stepHandoffActive) return@registerStepHandoff val handoffId = UUID.randomUUID().toString() pendingHandoffs[handoffId] = complete val payload = Arguments.createMap().apply { putString("handoffId", handoffId) putString("handoffTag", handoffTag) if (handoffData != null) { try { putMap("handoffData", Arguments.makeNativeMap(handoffData)) } catch (e: Exception) { Log.d(NAME, "Failed to convert handoffData to NativeMap: ${e.localizedMessage}") } } } reactApplicationContext.runOnUiQueueThread { sendEvent("Handoff", payload) } } } /** * Called from JS to complete a pending handoff with a typed outcome. * * The [outcome] map must include a "kind" key with one of: "done", "permission", "purchase", * "login". Unknown kinds or malformed payloads emit a loud dev error and leave the token * unconsumed (caller should call resume() to fall back to the legacy degrade path). * * A missing [handoffId] (e.g. after a Metro reload) logs a warning and returns — no crash. */ @ReactMethod fun completeHandoff( handoffId: String, outcome: ReadableMap, ) { val complete = pendingHandoffs.remove(handoffId) if (complete == null) { Log.w(NAME, "completeHandoff: no pending token for handoffId=$handoffId — ignoring (Metro reload?)") return } val decoded = decodeOutcome(outcome) ?: return complete(decoded) } @ReactMethod fun unregisterStepHandoff() { stepHandoffActive = false } @ReactMethod fun registerEventHandler() { eventHandlerActive = true NamiFlowManager.registerEventHandler { data -> if (!eventHandlerActive) return@registerEventHandler val payload = Arguments.makeNativeMap(data) sendEvent("FlowEvent", payload) } } @ReactMethod fun unregisterEventHandler() { eventHandlerActive = false } @ReactMethod fun resume() { Handler(Looper.getMainLooper()).postDelayed({ if (reactApplicationContext.hasCurrentActivity()) { NamiFlowManager.resume(reactApplicationContext.currentActivity) } else { NamiFlowManager.resume() } }, 100L) } @ReactMethod fun pause() { Handler(Looper.getMainLooper()).postDelayed({ NamiFlowManager.pause() }, 100L) } @ReactMethod fun finish() { Handler(Looper.getMainLooper()).postDelayed({ NamiFlowManager.finish() }, 100L) } @ReactMethod fun isFlowOpen(promise: Promise) { promise.resolve(NamiFlowManager.isFlowOpen()) } /** * Decodes a JS outcome dict into the typed [NamiHandoffOutcome] sealed class. * * Returns null and emits a [Log.e] dev error on any validation failure; the caller must * NOT invoke the completion token in that case. */ private fun decodeOutcome(outcome: ReadableMap): NamiHandoffOutcome? { val kind = outcome.getString("kind") return when (kind) { "done" -> NamiHandoffOutcome.Done "permission" -> { val permissionStr = outcome.getString("permission") if (permissionStr == null) { Log.e(NAME, "completeHandoff: 'permission' field is required for kind='permission'") return null } val permissionType = resolvePermissionOutcome(permissionStr) ?: return null val granted = if (outcome.hasKey("granted")) { outcome.getBoolean("granted") } else { Log.e(NAME, "completeHandoff: 'granted' field is required for kind='permission'") return null } val detail = if (outcome.hasKey("detail")) outcome.getString("detail") else null NamiHandoffOutcome.Permission(permissionType, granted, detail) } "purchase" -> { val result = outcome.getString("result") when (result) { "success" -> { val detailsMap = if (outcome.hasKey("details")) outcome.getMap("details") else null if (detailsMap == null) { Log.e(NAME, "completeHandoff: 'details' map is required for kind='purchase', result='success'") return null } val purchaseSuccess = decodePurchaseSuccess(detailsMap) ?: return null NamiHandoffOutcome.Purchase(NamiPurchaseOutcome.Success(purchaseSuccess)) } "cancelled" -> NamiHandoffOutcome.Purchase(NamiPurchaseOutcome.Cancelled) "failed" -> NamiHandoffOutcome.Purchase(NamiPurchaseOutcome.Failed()) else -> { Log.e(NAME, "completeHandoff: unknown purchase result='$result'; expected 'success', 'cancelled', or 'failed'") null } } } "login" -> { val result = outcome.getString("result") when (result) { "success" -> { val detailsMap = if (outcome.hasKey("details")) outcome.getMap("details") else null if (detailsMap == null) { Log.e(NAME, "completeHandoff: 'details' map is required for kind='login', result='success'") return null } val loginSuccess = decodeLoginSuccess(detailsMap) ?: return null NamiHandoffOutcome.Login(NamiLoginOutcome.Success(loginSuccess)) } "cancelled" -> NamiHandoffOutcome.Login(NamiLoginOutcome.Cancelled) "failed" -> NamiHandoffOutcome.Login(NamiLoginOutcome.Failed()) else -> { Log.e(NAME, "completeHandoff: unknown login result='$result'; expected 'success', 'cancelled', or 'failed'") null } } } else -> { Log.e(NAME, "completeHandoff: unknown kind='$kind'; expected 'done', 'permission', 'purchase', or 'login'") null } } } /** * Maps a wire permission string (case-insensitive) to [NamiPermissionOutcome]. * Unknown values emit a [Log.e] and return null. */ private fun resolvePermissionOutcome(wire: String): NamiPermissionOutcome? { // Case-insensitive match against enum entry names (Push, Location, Tracking, …) return NamiPermissionOutcome.entries.firstOrNull { it.name.equals(wire, ignoreCase = true) } ?: run { val valid = NamiPermissionOutcome.entries.joinToString { it.name.lowercase() } Log.e(NAME, "completeHandoff: unknown permission='$wire'; valid values: $valid") null } } /** * Decodes the `details` sub-map for a purchase/success outcome into [NamiPurchaseSuccess]. * * Mirrors the field layout already used by [NamiPaywallManagerBridgeModule.buySkuComplete]: * - `product.id` → skuId (the Nami reference ID) * - `product.skuId` → skuRefId (the store product ID) * - `storeType` → "GooglePlay" or "Amazon" * - GooglePlay: `orderId`, `purchaseToken` * - Amazon: `receiptId`, `localizedPrice`, `userId`, `marketplace` */ private fun decodePurchaseSuccess(details: ReadableMap): NamiPurchaseSuccess? { val productMap = if (details.hasKey("product")) details.getMap("product") else null val productId = productMap?.getString("id") val skuRefId = productMap?.getString("skuId") if (productId == null || skuRefId == null) { // TODO(NAM-1560): construct NamiPurchaseSuccess from map — product.id and product.skuId // are required but missing or null. The JS caller must supply a NamiSKU-shaped product // map with at least {id, skuId} fields (matching the buySkuComplete wire format). Log.e(NAME, "completeHandoff: purchase/success 'details.product' must include 'id' and 'skuId'") return null } val namiSku = NamiSKU.create( skuId = productId, skuRefId = skuRefId, ) val storeType = details.getString("storeType") ?: "GooglePlay" return when (storeType) { "GooglePlay" -> { val orderId = details.getString("orderId") val purchaseToken = details.getString("purchaseToken") if (orderId == null || purchaseToken == null) { Log.e(NAME, "completeHandoff: purchase/success GooglePlay requires 'orderId' and 'purchaseToken'") return null } NamiPurchaseSuccess.GooglePlay( product = namiSku, orderId = orderId, purchaseToken = purchaseToken, ) } "Amazon" -> { val receiptId = details.getString("receiptId") val localizedPrice = details.getString("localizedPrice") val userId = details.getString("userId") val marketplace = details.getString("marketplace") if (receiptId == null || localizedPrice == null || userId == null || marketplace == null) { Log.e( NAME, "completeHandoff: purchase/success Amazon requires 'receiptId', 'localizedPrice', 'userId', and 'marketplace'", ) return null } NamiPurchaseSuccess.Amazon( product = namiSku, receiptId = receiptId, localizedPrice = localizedPrice, userId = userId, marketplace = marketplace, ) } else -> { Log.e(NAME, "completeHandoff: unknown storeType='$storeType'; expected 'GooglePlay' or 'Amazon'") null } } } /** * Decodes the `details` sub-map for a login/success outcome into [NamiLoginSuccess]. * * The `attributes` map must contain only primitive values (String, Number, Boolean). * Non-primitive values emit a [Log.e] and abort the decode — [NamiLoginSuccess.init] * would throw on them anyway; we surface a clean dev error instead. */ private fun decodeLoginSuccess(details: ReadableMap): NamiLoginSuccess? { val loginId = details.getString("loginId") if (loginId.isNullOrEmpty()) { Log.e(NAME, "completeHandoff: login/success 'details.loginId' is required and must be non-empty") return null } val cdpId = if (details.hasKey("cdpId")) details.getString("cdpId") else null val attributes: Map? = if (details.hasKey("attributes")) { val attrsMap = details.getMap("attributes") if (attrsMap != null) { val raw = attrsMap.toHashMap() // Validate that all values are primitives before handing to NamiLoginSuccess. val invalid = raw.entries.firstOrNull { (_, v) -> v !is String && v !is Number && v !is Boolean } if (invalid != null) { Log.e( NAME, "completeHandoff: login/success attributes['${invalid.key}'] must be a primitive " + "(String/Number/Boolean); got ${invalid.value?.let { it::class.simpleName } ?: "null"}", ) return null } @Suppress("UNCHECKED_CAST") raw as Map } else { null } } else { null } return try { NamiLoginSuccess( loginId = loginId, cdpId = cdpId, attributes = attributes, ) } catch (e: IllegalArgumentException) { // NamiLoginSuccess.init validates attribute primitives — surface the message clearly. Log.e(NAME, "completeHandoff: NamiLoginSuccess rejected attributes — ${e.message}") null } } private fun sendEvent( eventName: String, params: WritableMap?, ) { reactApplicationContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit(eventName, params) } // Required for RN EventEmitter support @ReactMethod fun addListener(eventName: String?) {} @ReactMethod fun removeListeners(count: Int?) {} }