package com.amplitude.pluginengagementreactnative import android.content.Intent import android.util.Log import androidx.core.net.toUri import com.amplitude.android.engagement.AmplitudeBootOptions import com.amplitude.android.engagement.AmplitudeEngagement import com.amplitude.android.engagement.__ReactNative__AESDK import com.amplitude.android.engagement.AmplitudeInitOptions import com.amplitude.android.engagement.ui.theme.ThemeMode import com.amplitude.core.events.BaseEvent import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableArray import com.facebook.react.module.annotations.ReactModule import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.ConcurrentHashMap import com.amplitude.android.engagement.AmplitudeServerZone import com.amplitude.android.engagement.AmplitudeLogLevel @ReactModule(name = PluginEngagementReactNativeModule.NAME) class PluginEngagementReactNativeModule(val reactContext: ReactApplicationContext) : NativePluginEngagementReactNativeSpec(reactContext) { private data class InstanceInfo(val apiKey: String, val instance: AmplitudeEngagement) private var instances = ConcurrentHashMap() private var nextId: Double = 0.0 // Background scope for async init so the JS thread is never blocked. private val scope = CoroutineScope(Dispatchers.Default) override fun getName(): String { return NAME } override fun newInstance( apiKey: String, options: ReadableMap?, promise: Promise, ) { val existingId = instances.entries.firstOrNull { it.value.apiKey == apiKey }?.key if (existingId != null) { promise.resolve(existingId) return } // Read options before going async, then create off the JS thread so the // blocking version fetch doesn't stall launch. val initOptions = buildInitOptions(options) val platformVersion = options?.getString("platformVersion") ?: "" scope.launch { try { promise.resolve(createInstance(apiKey, initOptions, platformVersion)) } catch (t: Throwable) { Log.e("PluginEngagementReactNativeModule", "newInstance failed", t) promise.reject("newInstance_error", t) } } } private fun buildInitOptions(options: ReadableMap?): AmplitudeInitOptions { val serverZone = options?.getString("serverZone")?.let { runCatching { AmplitudeServerZone.valueOf(it) }.getOrNull() } ?: AmplitudeServerZone.US val logLevel = options?.getString("logLevel")?.let { level -> runCatching { AmplitudeLogLevel.valueOf(level.uppercase()) }.getOrNull() } ?: AmplitudeLogLevel.WARN val locale = if (options?.hasKey("locale") == true) options.getString("locale") else null val ignoreAnalyticsAutomaticScreenTracking = if (options?.hasKey("ignoreAnalyticsAutomaticScreenTracking") == true) { options.getBoolean( "ignoreAnalyticsAutomaticScreenTracking", ) } else { null } val serverUrl = if (options?.hasKey("serverUrl") == true) options.getString("serverUrl") else null val cdnUrl = if (options?.hasKey("cdnUrl") == true) options.getString("cdnUrl") else null val mediaUrl = if (options?.hasKey("mediaUrl") == true) options.getString("mediaUrl") else null val autoRefreshInterval = if (options?.hasKey("autoRefreshInterval") == true) options.getInt("autoRefreshInterval") else null var initOptions = AmplitudeInitOptions( serverZone = serverZone, serverUrl = serverUrl, cdnUrl = cdnUrl, mediaUrl = mediaUrl, logLevel = logLevel, ) if (locale != null) { // this way, we get the default value for `locale` from AmplitudeInitOptions // UNLESS a locale is specified initOptions = initOptions.copy(locale = locale) } if (ignoreAnalyticsAutomaticScreenTracking != null) { // this way, we get the default value for `ignoreAnalyticsAutomaticScreenTracking` // from AmplitudeInitOptions UNLESS a value is specified initOptions = initOptions.copy(ignoreAnalyticsAutomaticScreenTracking = ignoreAnalyticsAutomaticScreenTracking) } if (autoRefreshInterval != null) { initOptions = initOptions.copy(autoRefreshInterval = autoRefreshInterval) } return initOptions } // Runs off the JS and main threads (the SDK's blocking version fetch happens here). // Only the lifecycle wiring below needs the main thread. private fun createInstance( apiKey: String, initOptions: AmplitudeInitOptions, platformVersion: String, ): Double { return synchronized(this) { Log.d("PluginEngagementReactNativeModule", "newInstance apiKey=$apiKey, initOptions=$initOptions") val amplitudeEngagement = __ReactNative__AESDK(reactContext, apiKey, initOptions, platformVersion = platformVersion) // Track the current activity via a listener (RN's own lifecycle). Main thread only. runBlocking(Dispatchers.Main) { amplitudeEngagement.setCurrentActivity(reactContext.currentActivity) val lifecycleEventListener: LifecycleEventListener = object : LifecycleEventListener { override fun onHostResume() { amplitudeEngagement.setCurrentActivity(reactContext.currentActivity) } override fun onHostPause() { } override fun onHostDestroy() { } } reactContext.addLifecycleEventListener(lifecycleEventListener) } nextId++ val id = nextId Log.d("PluginEngagementReactNativeModule", "newInstance id=$id") instances[id] = InstanceInfo( apiKey, amplitudeEngagement, ) id } } override fun boot( id: Double, userId: String?, deviceId: String?, userProperties: ReadableMap?, ) { val instance = instances[id]?.instance ?: return val self = this runBlocking(Dispatchers.Main) { Log.d("PluginEngagementReactNativeModule", "boot: $userId, $deviceId, $userProperties") val options = AmplitudeBootOptions( userId = userId, deviceId = deviceId, userProperties = userProperties?.toHashMap() ?: emptyMap(), integrations = arrayOf( { event: BaseEvent -> try { val serializedEvent = mapOf( "event_type" to event.eventType, "event_properties" to event.eventProperties, ).toWritableMap() self.emitOnTrackEvent(serializedEvent) } catch (e: Exception) { Log.e("PluginEngagementReactNativeModule", "Error tracking Event", e) } }, ), ) instance.boot(options) } } override fun enable(id: Double) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.enable() } } override fun disable(id: Double) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.disable() } } override fun shutdown(id: Double) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.shutdown() } } override fun setThemeMode( id: Double, themeMode: String?, ) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { if (themeMode != null) { instance.setThemeMode(ThemeMode.valueOf(themeMode)) } } } override fun reset( id: Double, key: String, stepIndex: Double, ) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.reset(key, stepIndex.toInt()) } } override fun list(id: Double): WritableArray { val writableArray = Arguments.createArray() val instance = instances[id]?.instance ?: return writableArray runBlocking(Dispatchers.Main) { val guidesAndSurveysList = instance.list() guidesAndSurveysList.forEach { guide -> Log.d("PluginEngagementReactNativeModule", "list: $guide") val map = Arguments.createMap() map.putInt("id", guide.id) map.putString("title", guide.title) map.putString("status", guide.status) map.putInt("step", guide.step) writableArray.pushMap(map) } } return writableArray } override fun show( id: Double, key: String, stepIndex: Double, ) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.show(key, stepIndex.toInt()) } } override fun screen( id: Double, screenName: String, ) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.screen(screenName) } } override fun closeAll(id: Double) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.closeAll() } } override fun forwardEvent( id: Double, event: ReadableMap, ) { val instance = instances[id]?.instance ?: return val baseEvent = readableMapToBaseEvent(event) ?: return Log.d("PluginEngagementReactNativeModule", "forwardEvent: $baseEvent") runBlocking(Dispatchers.Main) { instance.forwardEvent(baseEvent) } } override fun addCallback( id: Double, key: String, ) { val instance = instances[id]?.instance ?: return val self = this runBlocking(Dispatchers.Main) { instance.addCallback(key) { val serializedEvent = mapOf( "id" to id, "key" to key, ).toWritableMap() self.emitOnInvokeCallback(serializedEvent) } } } override fun setRouter(id: Double) { val instance = instances[id]?.instance ?: return val self = this runBlocking(Dispatchers.Main) { instance.setRouter { url -> val serializedEvent = mapOf( "id" to id, "url" to url, ).toWritableMap() self.emitOnInvokeRouter(serializedEvent) } } } override fun unsetRouter(id: Double) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.unsetRouter() } } override fun updateLanguage( id: Double, locale: String, ) { val instance = instances[id]?.instance ?: return runBlocking(Dispatchers.Main) { instance.updateLanguage(locale) } } override fun handleURL( id: Double, url: String, ): Boolean { val instance = instances[id]?.instance ?: return false val uri = url.toUri() val intent = Intent(Intent.ACTION_VIEW, uri) return runBlocking(Dispatchers.Main) { instance.handlePreviewLinkIntent(intent) } } companion object { const val NAME = "PluginEngagementReactNative" } }