package com.userleap.reactnative import android.app.Activity import android.util.Log import androidx.fragment.app.FragmentActivity import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Callback import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableMap import com.userleap.EventListener import com.userleap.EventName import com.userleap.EventPayload import com.userleap.SprigEvent import com.userleap.SprigSurveyResult import com.userleap.SurveyState import com.userleap.UserLeap import com.userleap.SprigUserInterfaceMode import org.json.JSONObject class UserLeapModule(reactContext: ReactApplicationContext) : NativeUserLeapBindingsSpec(reactContext) { private val reactContext: ReactApplicationContext = reactContext // listenerLock guards activeListeners and `alive` so init-time registration and invalidate() cannot race. private val listenerLock = Any() private val activeListeners: MutableMap = HashMap() @Volatile private var alive = true init { synchronized(listenerLock) { registerAllNativeListeners() } } // Tear down SDK subscriptions here so a late callback can't hit a dead module ("emit after invalidation"). override fun invalidate() { synchronized(listenerLock) { alive = false unregisterAllNativeListeners() } super.invalidate() } private fun toCamelCase(upperSnakeCase: String): String { val parts = upperSnakeCase.lowercase().split("_") val sb = StringBuilder(parts[0]) for (i in 1 until parts.size) { sb.append(parts[i].replaceFirstChar { it.uppercase() }) } return sb.toString() } private fun surveyResultToWritableMap(result: SprigSurveyResult): WritableMap { val map = Arguments.createMap() map.putString("surveyState", result.surveyState.name) map.putInt("surveyId", result.surveyId) return map } private fun wrapResultCallback(callback: Callback?): ((SprigSurveyResult) -> Unit)? { if (callback == null) return null return { result -> callback.invoke(surveyResultToWritableMap(result)) } } private fun stringifyMap(map: ReadableMap?): Map { if (map == null) return emptyMap() return map.toHashMap() .entries .filter { it.key != null && it.value != null } .associate { it.key!! to it.value.toString() } } private fun getFragmentActivity(): FragmentActivity? { val activity: Activity? = currentActivity return activity as? FragmentActivity } override fun visitorIdentifier(): Double { return UserLeap.visitorIdentifier?.toDouble() ?: 0.0 } override fun visitorIdentifierString(): String { return UserLeap.visitorIdentifierString ?: "" } override fun getSdkVersion(): String { return UserLeap.sdkVersion } override fun configure(environmentId: String, configuration: ReadableMap) { UserLeap.configure( reactContext, environmentId, stringifyMap(configuration), getFragmentActivity() ) } override fun setPreviewKey(previewKey: String) { UserLeap.setPreviewKey(previewKey) } override fun setUserIdentifier(identifier: String) { UserLeap.setUserIdentifier(identifier) } override fun setEmailAddress(emailAddress: String) { UserLeap.setEmailAddress(emailAddress) } override fun logout() { UserLeap.logout() } override fun setVisitorAttribute(key: String, value: String) { UserLeap.setVisitorAttribute(key, value) } override fun setVisitorAttributes(attributes: ReadableMap) { UserLeap.setVisitorAttributes(stringifyMap(attributes)) } override fun removeVisitorAttributes(keys: ReadableArray) { val list = keys.toArrayList().mapNotNull { it?.toString() } UserLeap.removeVisitorAttributes(list) } override fun setVisitorAttributesAndIdentify( attributes: ReadableMap, userId: String?, partnerAnonymousId: String? ) { UserLeap.setVisitorAttributes( stringifyMap(attributes), userId, partnerAnonymousId ) } override fun presentSurvey() { UserLeap.presentSurvey(null) } override fun dismissActiveSurvey() { UserLeap.dismissActiveSurvey() } override fun pauseDisplayingSurveys() { UserLeap.pauseDisplayingSurveys() } override fun unpauseDisplayingSurveys() { UserLeap.unpauseDisplayingSurveys() } override fun trackAndPresent(eventName: String) { // The SDK requires a FragmentActivity; bail out gracefully if the current activity isn't one. val activity = getFragmentActivity() if (activity == null) { Log.w(TAG, "trackAndPresent('$eventName') skipped: current activity is not a FragmentActivity") return } UserLeap.trackAndPresent(eventName, activity) } override fun trackIdentifyAndPresent( eventName: String, userId: String?, partnerAnonymousId: String? ) { val activity = getFragmentActivity() if (activity == null) { Log.w(TAG, "trackIdentifyAndPresent('$eventName') skipped: current activity is not a FragmentActivity") return } UserLeap.trackAndPresent(eventName, userId, partnerAnonymousId, activity) } override fun overrideUserInterfaceMode(mode: Double) { val modeInt = mode.toInt() SprigUserInterfaceMode.values().firstOrNull { it.value == modeInt }?.let { UserLeap.overrideUserInterfaceMode(it) } } override fun trackEvent(eventName: String, surveyResultCallback: Callback) { trackEventAndIdentify(eventName, null, null, surveyResultCallback) } override fun trackEventWithProperties( eventName: String, userId: String?, partnerAnonymousId: String?, properties: ReadableMap, surveyResultCallback: Callback ) { val payload = EventPayload( eventName, userId, partnerAnonymousId, stringifyMap(properties), wrapResultCallback(surveyResultCallback), null, // shouldShowSurveyCallback null // deprecated callback ) UserLeap.track(payload) } override fun trackEventAndIdentify( eventName: String, userId: String?, partnerAnonymousId: String?, surveyResultCallback: Callback ) { val payload = EventPayload( eventName, userId, partnerAnonymousId, null, // properties wrapResultCallback(surveyResultCallback), null, // shouldShowSurveyCallback null // deprecated callback ) UserLeap.track(payload) } // Callback is `Callback?`: the RN bridge can pass null at runtime, so guard before invoking it. override fun track(eventName: String, surveyStateCallback: Callback?) { trackAndIdentify(eventName, null, null, surveyStateCallback) } override fun trackWithProperties( eventName: String, userId: String?, partnerAnonymousId: String?, properties: ReadableMap, surveyStateCallback: Callback? ) { UserLeap.track( eventName, userId, partnerAnonymousId, stringifyMap(properties) ) { state: SurveyState -> surveyStateCallback?.invoke(state.name) } } override fun trackAndIdentify( eventName: String, userId: String?, partnerAnonymousId: String?, surveyStateCallback: Callback? ) { UserLeap.track(eventName, userId, partnerAnonymousId) { state: SurveyState -> surveyStateCallback?.invoke(state.name) } } override fun getSupportedEventNames(): com.facebook.react.bridge.WritableArray { val array = Arguments.createArray() for (eventName in EventName.values()) { if (eventName == EventName.REPLAY_EVENTS_UPLOADED_COMPLETED) continue array.pushString(toCamelCase(eventName.name)) } return array } // Caller must hold listenerLock. private fun registerAllNativeListeners() { if (!alive) return for (eventName in EventName.values()) { // Skip the deprecated alias - it would double-fire alongside REPLAY_EVENTS_UPLOAD_COMPLETED. if (eventName == EventName.REPLAY_EVENTS_UPLOADED_COMPLETED) continue if (activeListeners.containsKey(eventName)) continue val listener = EventListener { event -> emitEventToJS(event) } activeListeners[eventName] = listener try { UserLeap.addEventListener(eventName, listener) } catch (t: Throwable) { Log.w(TAG, "Failed to register native listener for $eventName", t) activeListeners.remove(eventName) } } } // Caller must hold listenerLock. private fun unregisterAllNativeListeners() { for ((eventName, listener) in activeListeners) { try { UserLeap.removeEventListener(eventName, listener) } catch (t: Throwable) { Log.w(TAG, "Failed to remove native listener for $eventName", t) } } activeListeners.clear() } private fun emitEventToJS(event: SprigEvent) { if (!alive) return val jsName = toCamelCase(event.name.name) val json = buildEventJson(event) val envelope = Arguments.createMap().apply { putString("name", jsName) putString("json", json) } try { emitOnSprigEvent(envelope) } catch (t: Throwable) { Log.w(TAG, "Failed to emit '$jsName' to JS", t) } } private fun buildEventJson(event: SprigEvent): String { return try { if (event.name == EventName.LOGGING_EVENT) { JSONObject() .put("message", event.logMessage ?: "") .put("level", event.logLevel.toString().lowercase()) .toString() } else { event.data?.toString() ?: "{}" } } catch (t: Throwable) { Log.w(TAG, "Failed to build JSON payload for ${event.name}", t) "{}" } } companion object { private const val TAG = "UserLeapModule" } }