package com.storytellersdkgam import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.bridge.Arguments import com.storyteller.Storyteller import com.storyteller.domain.ads.entities.StorytellerCustomNativeTemplateIds import com.storyteller.domain.ads.entities.StorytellerAdRequestInfo import com.storyteller.domain.ads.ports.StorytellerModule import com.storyteller.modules.ads.StorytellerAdMobModule import com.storyteller.modules.ads.StorytellerGamModule import com.storytellersdkgam.toAdRequestInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.delay import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import android.util.Log @ReactModule(name = StorytellerSdkGamModule.NAME) class StorytellerSdkGamModule(reactContext: ReactApplicationContext) : NativeStorytellerSdkGamSpec(reactContext) { private data class PendingRequest( val callback: (String) -> Unit, val timestamp: Long = System.currentTimeMillis() ) private val pendingRequests = ConcurrentHashMap() private val pendingRequestsLock = Any() private var pendingRequestGeneration = 0 private var acceptsPendingRequests = true private val coroutineScope = CoroutineScope(Dispatchers.Main + Job()) private val REQUEST_TIMEOUT_MS = 1000L override fun getName(): String { return NAME } override fun invalidate() { suspendPendingRequests() super.invalidate() coroutineScope.cancel() } override fun disableGoogleAdsModule() { suspendPendingRequests() Storyteller.modules = removingGoogleAdsModules(Storyteller.modules) } override fun setupGAMModule(configuration: ReadableMap) { val requestGeneration = suspendPendingRequests() val customNativeTemplateId = configuration.getMap("customNativeTemplateId") val storiesTemplateId = customNativeTemplateId?.getString("stories") val clipsTemplateId = customNativeTemplateId?.getString("clips") val bottomBannerAdUnit = configuration.optionalString("bottomBannerAdUnit") val customKvps = configuration.getMap("customKvps")?.toHashMap()?.mapValues { it.value.toString() } ?: emptyMap() val templateIds = StorytellerCustomNativeTemplateIds( stories = storiesTemplateId.orEmpty(), clips = clipsTemplateId.orEmpty() ) val enricher = adRequestEnricher val gamModule = StorytellerGamModule.getInstance( applicationContext = reactApplicationContext, ).apply { if (enricher == null) { init( adUnit = ::getAdUnit, templateIds = templateIds, keyValuePairs = { customKvps }, bottomBannerAdUnit = staticAdUnitProvider(bottomBannerAdUnit), ) } else { init( adUnit = ::getAdUnit, templateIds = templateIds, keyValuePairs = { customKvps }, bottomBannerAdUnit = staticAdUnitProvider(bottomBannerAdUnit), configureAdRequestBuilder = { requestInfo, builder, completion -> enricher.enrich(requestInfo, builder, completion) }, ) } } Storyteller.modules = replacingGoogleAdsModule(Storyteller.modules, gamModule) resumePendingRequests(requestGeneration) } override fun setupAdMobModule(configuration: ReadableMap) { val requestGeneration = suspendPendingRequests() val customKvps = configuration.getMap("customKvps")?.toHashMap()?.mapValues { it.value.toString() } ?: emptyMap() val nativeAdUnit = configuration.optionalString("nativeAdUnit").orEmpty() val enableBannerAdPriority = if (configuration.hasKey("enableBannerAdPriority")) configuration.getBoolean("enableBannerAdPriority") else false val adMobModule = StorytellerAdMobModule.getInstance( applicationContext = reactApplicationContext, ).apply { init( nativeAdUnit = { nativeAdUnit }, bannerAdUnit = staticAdUnitProvider(configuration.optionalString("bannerAdUnit")), bottomBannerAdUnit = staticAdUnitProvider(configuration.optionalString("bottomBannerAdUnit")), enableBannerAdPriority = enableBannerAdPriority, keyValuePairs = { customKvps } ) } Storyteller.modules = replacingGoogleAdsModule(Storyteller.modules, adMobModule) resumePendingRequests(requestGeneration) } private fun ReadableMap.optionalString(key: String): String? = if (hasKey(key)) getString(key)?.takeIf { it.isNotBlank() } else null private fun staticAdUnitProvider(adUnit: String?): ((StorytellerAdRequestInfo) -> String?)? = adUnit?.let { { _ -> it } } private fun getAdUnit(adRequest: StorytellerAdRequestInfo): String { val requestId = "${System.currentTimeMillis()}_${System.nanoTime()}" val adRequestInfo = adRequest.toAdRequestInfo() var adUnitResult = "" val resultLatch = java.util.concurrent.CountDownLatch(1) val pendingRequest = PendingRequest( callback = { adUnit -> adUnitResult = adUnit resultLatch.countDown() } ) if (!storePendingRequest(requestId, pendingRequest)) { return "" } val eventData = Arguments.createMap().apply { putString("requestId", requestId) putMap("adRequest", adRequestInfo) } emitGetAdUnitForRequest(eventData) // Set up timeout handling for this specific request coroutineScope.launch { delay(REQUEST_TIMEOUT_MS) val request = pendingRequests.remove(requestId) if (request != null) { Log.w(TAG, "Request timeout for requestId: $requestId - providing empty string fallback") request.callback("") } } if (pendingRequests.size == 1) { scheduleCleanup() } // Wait for the result (will be unblocked by callback or timeout) try { val completed = resultLatch.await(REQUEST_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS) if (!completed) { Log.w(TAG, "Latch timeout for requestId: $requestId") } else { Log.d(TAG, "Latch completed for requestId: $requestId") } } catch (e: InterruptedException) { Log.e(TAG, "Thread interrupted while waiting for ad unit result: $requestId", e) pendingRequests.remove(requestId) } return adUnitResult } override fun setAdUnitResult(configuration: ReadableMap) { val requestId = configuration.getString("requestId") val adUnitId = configuration.getString("adUnitId") ?: "" if (requestId != null) { val pendingRequest = pendingRequests.remove(requestId) if (pendingRequest != null) { pendingRequest.callback.invoke(adUnitId) } else { Log.w(TAG, "No pending request found for requestId: $requestId") } } else { Log.e(TAG, "setAdUnitResult called with null requestId") } } // Clean up expired callbacks private fun cleanupExpiredRequests() { val now = System.currentTimeMillis() val expiredRequests = pendingRequests.filter { (_, request) -> now - request.timestamp > REQUEST_TIMEOUT_MS } if (expiredRequests.isNotEmpty()) { Log.d(TAG, "Cleaning up ${expiredRequests.size} expired requests") } expiredRequests.forEach { (requestId, _) -> val removedRequest = pendingRequests.remove(requestId) ?: return@forEach Log.w(TAG, "Expired request cleaned up - requestId: $requestId") // Invoke callback with empty string for expired requests removedRequest.callback("") } } private fun storePendingRequest(requestId: String, request: PendingRequest): Boolean = synchronized(pendingRequestsLock) { if (!acceptsPendingRequests) { false } else { pendingRequests[requestId] = request true } } private fun suspendPendingRequests(): Int { val (requestGeneration, requests) = synchronized(pendingRequestsLock) { pendingRequestGeneration += 1 acceptsPendingRequests = false val requests = pendingRequests.entries.toList().mapNotNull { (requestId, request) -> if (pendingRequests.remove(requestId, request)) request else null } pendingRequestGeneration to requests } requests.forEach { it.callback("") } return requestGeneration } private fun resumePendingRequests(requestGeneration: Int) { synchronized(pendingRequestsLock) { if (pendingRequestGeneration == requestGeneration) { acceptsPendingRequests = true } } } // Schedule cleanup only when needed private fun scheduleCleanup() { coroutineScope.launch { delay(REQUEST_TIMEOUT_MS + 1000) // Wait a bit longer than timeout if (pendingRequests.isNotEmpty()) { Log.d(TAG, "Running scheduled cleanup, ${pendingRequests.size} requests to check") cleanupExpiredRequests() // If there are still requests after cleanup, schedule again if (pendingRequests.isNotEmpty()) { scheduleCleanup() } } } } companion object { // Must match the JS/TurboModule name used in TurboModuleRegistry.getEnforcing(...) const val NAME = "StorytellerSdkGam" private const val TAG = "StorytellerGamSdk" @Volatile private var adRequestEnricher: StorytellerGamAdRequestEnricher? = null /** Register an Android-native GAM request enricher before JavaScript calls setupGAMModule. */ @JvmStatic fun registerAdRequestEnricher(enricher: StorytellerGamAdRequestEnricher) { adRequestEnricher = enricher } /** Remove the Android-native GAM request enricher used by subsequent setupGAMModule calls. */ @JvmStatic fun clearAdRequestEnricher() { adRequestEnricher = null } } } internal fun replacingGoogleAdsModule( modules: List, googleAdsModule: StorytellerModule, ): List = removingGoogleAdsModules(modules) + googleAdsModule internal fun removingGoogleAdsModules(modules: List): List = modules.filterNot { it is StorytellerGamModule || it is StorytellerAdMobModule }