// The Freshdesk Android SDK publishes its unread-count and user-state events // through androidx LocalBroadcastManager.sendBroadcast (verified in // freshdesk-2.2.4.aar). LocalBroadcastManager is deprecated, but this wrapper // has to consume events on the same channel the native SDK emits them on, so the // deprecation is suppressed file-wide until the native SDK exposes a listener API. @file:Suppress("DEPRECATION") package com.freshworks.freshdesk.reactnative import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.facebook.react.bridge.* import com.facebook.react.modules.core.DeviceEventManagerModule import com.freshworks.sdk.freshdesk.FreshdeskSDK import com.freshworks.sdk.freshdesk.HostPlatform import com.freshworks.sdk.freshdesk.data.SDKConfig import com.freshworks.sdk.freshdesk.events.SDKEventID import com.freshworks.sdk.freshdesk.handlers.FreshDeskSDKLinkHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean /** * React Native module for Freshdesk SDK. * * Extends [FreshdeskModuleSpec], whose concrete definition is chosen by a Gradle * source-set switch (src/newarch vs src/oldarch, see android/build.gradle): * on the New Architecture it is the @react-native/codegen-generated * `NativeFreshdeskSpec`; on the Old Architecture it is a hand-written * `ReactContextBaseJavaModule` base. Method bodies are identical for both. */ class FreshdeskModule(reactContext: ReactApplicationContext) : FreshdeskModuleSpec(reactContext) { private val initCoordinator = FreshdeskInitCoordinator() private var cachedUnreadCount = 0 private var unreadCountReceiver: BroadcastReceiver? = null private var userStateReceiver: BroadcastReceiver? = null private val mainScope = CoroutineScope(Dispatchers.Main) override fun getName(): String = NAME override fun invalidate() { super.invalidate() unregisterReceivers() } // No onCatalystInstanceDestroy() override: it is deprecated (RN 0.74) and // removed on the New Architecture. invalidate() above is the supported // teardown hook on the RN 0.75+ range this package targets and already // unregisters the receivers. private fun unregisterReceivers() { unreadCountReceiver?.let { LocalBroadcastManager.getInstance(reactApplicationContext).unregisterReceiver(it) } userStateReceiver?.let { LocalBroadcastManager.getInstance(reactApplicationContext).unregisterReceiver(it) } unreadCountReceiver = null userStateReceiver = null } //region SDK Methods @ReactMethod override fun initialize(config: ReadableMap, promise: Promise) { mainScope.launch { if (initCoordinator.isInitialized) { promise.resolve(null) return@launch } val settled = AtomicBoolean(false) try { val token = config.getString("token") ?: throw IllegalArgumentException("Missing required parameter: token") val rawHost = config.getString("host") ?: throw IllegalArgumentException("Missing required parameter: host") val sdkId = config.getString("sdkId") ?: throw IllegalArgumentException("Missing required parameter: sdkId") // `rawHost` is already normalized by src/utils/normalizeHost.ts before // it crosses the bridge (initialize() in src/index.ts) — this method is // reachable only via that JS entry point, so do not re-normalize here. // lastConfiguredHost/lastNormalizedHost stay distinct fields for // diagnostics in case a caller ever bypasses the TS wrapper. val host = rawHost lastConfiguredHost = rawHost lastNormalizedHost = host val locale = config.getString("locale") ?: "en" val jwt = config.getString("jwt")?.takeIf { it.isNotBlank() } val debugMode = if (config.hasKey("debugMode")) config.getBoolean("debugMode") else false // Native init may have run outside the bridge (e.g. MainApplication.onCreate). // Re-initializing with HostPlatform.REACT_NATIVE causes conflicting SDK state. if (initCoordinator.probeNativeSdkReady() == true) { detectedExternalNativeInit = true completeInitialization(settled, promise, fromCallback = false) return@launch } val sdkConfig = SDKConfig( token = token, host = host, sdkID = sdkId, locale = locale, jwt = jwt, debugMode = debugMode, hostPlatform = HostPlatform.REACT_NATIVE, ) FreshdeskSDK.initialize( reactApplicationContext, sdkConfig, ) { mainScope.launch { completeInitialization(settled, promise, fromCallback = true) } } // Some Android SDK builds reach Ready without invoking the callback. // Only mark success when native readiness is confirmed. launch { delay(FreshdeskInitCoordinator.READINESS_RECHECK_DELAY_MS) if (initCoordinator.probeNativeSdkReady() == true) { completeInitialization(settled, promise, fromCallback = false) } } // Hard timeout for genuine init failures. launch { delay(FreshdeskInitCoordinator.INIT_HARD_TIMEOUT_MS) if (settled.compareAndSet(false, true)) { promise.reject( "FRESHDESK_INIT_TIMEOUT", "Freshdesk SDK initialization timed out. Verify token, host, sdkId, network access, and JWT settings in the Freshdesk portal.", ) } } } catch (e: Exception) { if (settled.compareAndSet(false, true)) { promise.reject("FRESHDESK_INIT_ERROR", e.message, e) } } } } private fun completeInitialization( settled: AtomicBoolean, promise: Promise, fromCallback: Boolean, ) { if (!settled.compareAndSet(false, true)) { return } if (fromCallback) { initCoordinator.markInitCallbackReceived() } else { initCoordinator.markInitialized() } setupBroadcastReceivers() promise.resolve(null) } @ReactMethod override fun openSupport(promise: Promise) { mainScope.launch { if (!initCoordinator.ensureReady(promise)) { return@launch } val activity = currentActivity if (activity == null) { promise.reject("FRESHDESK_NO_ACTIVITY", "No current activity available") return@launch } try { FreshdeskSDK.openSupport(activity) promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_OPEN_ERROR", e.message, e) } } } @ReactMethod override fun openKnowledgeBase(promise: Promise) { mainScope.launch { if (!initCoordinator.ensureReady(promise)) { return@launch } val activity = currentActivity if (activity == null) { promise.reject("FRESHDESK_NO_ACTIVITY", "No current activity available") return@launch } try { FreshdeskSDK.openKnowledgeBase(activity) promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_OPEN_ERROR", e.message, e) } } } @ReactMethod override fun openTopic(topicName: String, topicId: String, promise: Promise) { mainScope.launch { if (!initCoordinator.ensureReady(promise)) { return@launch } val activity = currentActivity if (activity == null) { promise.reject("FRESHDESK_NO_ACTIVITY", "No current activity available") return@launch } try { FreshdeskSDK.openTopic( activity, topicName, topicId.ifBlank { null } ) promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_OPEN_ERROR", e.message, e) } } } @ReactMethod override fun getUnreadCount(promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } // Android SDK emits unread counts via broadcast; return the latest cached value. promise.resolve(cachedUnreadCount) } @ReactMethod override fun trackEvent(name: String, properties: ReadableMap, promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } try { FreshdeskSDK.trackEvent(name, properties.toNonNullMap()) promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_TRACK_ERROR", e.message, e) } } @ReactMethod override fun setUserProperties(properties: ReadableMap, promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } try { FreshdeskSDK.setUserProperties(properties.toNonNullMap()) promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_USER_PROPERTIES_ERROR", e.message, e) } } @ReactMethod override fun setTicketProperties(properties: ReadableMap, promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } try { FreshdeskSDK.setTicketProperties(properties.toNonNullMap()) promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_TICKET_PROPERTIES_ERROR", e.message, e) } } @ReactMethod override fun authenticateAndUpdate(jwt: String, promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } try { FreshdeskSDK.authenticateAndUpdate(jwt) promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_AUTH_ERROR", e.message, e) } } @ReactMethod // resetUser's callback params are non-null in freshdesk 2.2.4 but typed // nullable in other native-SDK versions; the `?: ""` guards keep this // compiling against both, so the "useless elvis" warning is expected here. @Suppress("USELESS_ELVIS") override fun resetUser(promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } FreshdeskSDK.resetUser( onSuccess = { message -> promise.resolve(Arguments.createMap().apply { putBoolean("success", true) putString("message", message ?: "") putString("error", "") }) }, onFailure = { errorMessage -> promise.resolve(Arguments.createMap().apply { putBoolean("success", false) putString("message", "") putString("error", errorMessage ?: "") }) } ) } @ReactMethod override fun dismiss(promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } try { FreshdeskSDK.dismissFreshdeskViews() promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_DISMISS_ERROR", e.message, e) } } @ReactMethod override fun setLinkHandlerEnabled(enabled: Boolean, promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } if (enabled) { val linkHandler = object : FreshDeskSDKLinkHandler { override fun handleLink(url: String?) { if (url.isNullOrEmpty()) return val params = Arguments.createMap().apply { putString("url", url) } sendEvent("onLinkPressed", params) } } FreshdeskSDK.setLinkHandler(linkHandler) } promise.resolve(null) } @ReactMethod override fun getSDKVersion(promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } promise.resolve(FreshdeskSDK.getSDKVersionName()) } @ReactMethod override fun getUser(promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } FreshdeskSDK.getUser( { error -> promise.reject("FRESHDESK_USER_ERROR", error.message, error) }, { user -> promise.resolve(FreshdeskJsonUtils.toJson(user)) } ) } @ReactMethod override fun setContentConfiguration(configJson: String, promise: Promise) { if (!initCoordinator.isInitialized) { promise.reject("FRESHDESK_NOT_INITIALIZED", "SDK not initialized") return } try { val config = FreshdeskJsonUtils.parseContentConfiguration(configJson) FreshdeskSDK.setContentConfiguration(config) promise.resolve(null) } catch (e: Exception) { promise.reject("FRESHDESK_CONFIG_PARSE_ERROR", e.message, e) } } @ReactMethod override fun enableDebugLogs(enabled: Boolean, promise: Promise) { // Android debug logging is controlled via SDKConfig.debugMode at initialize(). // Re-initialize is not attempted here; callers should pass debugMode: true on init. if (enabled && !initCoordinator.isInitialized) { promise.reject( "FRESHDESK_NOT_INITIALIZED", "Initialize FreshdeskSDK with debugMode: true to enable Android debug logs", ) return } promise.resolve(null) } @ReactMethod override fun runDiagnostics(promise: Promise) { promise.resolve( FreshdeskDiagnostics.buildFallbackReport( context = reactApplicationContext, isInitialized = initCoordinator.isInitialized, initCallbackReceived = initCoordinator.initCallbackReceived, configuredHost = lastConfiguredHost, normalizedHost = lastNormalizedHost, detectedExternalNativeInit = detectedExternalNativeInit, nativeSdkReady = initCoordinator.isNativeSdkReady(), ), ) } @ReactMethod override fun addListener(eventName: String) { // Required by NativeEventEmitter / generated spec. Events are pushed via // RCTDeviceEventEmitter in setupBroadcastReceivers(); no per-listener // bookkeeping needed. The cached unread count is re-emitted on the next // broadcast. } @ReactMethod override fun removeListeners(count: Double) { // No-op counterpart to addListener. } //endregion //region Private Methods private fun setupBroadcastReceivers() { // Unread count receiver unreadCountReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == SDKEventID.UNREAD_COUNT) { val count = intent.getIntExtra(SDKEventID.UNREAD_COUNT, 0) cachedUnreadCount = count val params = Arguments.createMap().apply { putInt("count", count) } sendEvent("unreadCountChanged", params) } } } // User state receiver userStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == SDKEventID.USER_STATE_CHANGE) { val userState = intent.getStringExtra(SDKEventID.USER_STATE_CHANGE) val params = Arguments.createMap().apply { putString("state", userState) } sendEvent("userStateChanged", params) } } } val localBroadcastManager = LocalBroadcastManager.getInstance(reactApplicationContext) unreadCountReceiver?.let { localBroadcastManager.registerReceiver(it, IntentFilter(SDKEventID.UNREAD_COUNT)) } userStateReceiver?.let { localBroadcastManager.registerReceiver(it, IntentFilter(SDKEventID.USER_STATE_CHANGE)) } } // ReadableMap.toHashMap() is typed HashMap on newer React Native // versions (RN >= 0.79); the Android SDK expects Map. Drop // null-valued entries so the call site type-checks under Kotlin's strict // nullability. On RN 0.75 the values are non-null Any, which makes the safe // call redundant there — hence the suppress. @Suppress("UNNECESSARY_SAFE_CALL") private fun ReadableMap.toNonNullMap(): Map = toHashMap().mapNotNull { (key, value) -> value?.let { key to it } }.toMap() private fun sendEvent(eventName: String, params: WritableMap?) { reactApplicationContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) ?.emit(eventName, params) } //endregion companion object { const val NAME = "FreshdeskReactNative" @Volatile private var lastConfiguredHost: String? = null @Volatile private var lastNormalizedHost: String? = null @Volatile private var detectedExternalNativeInit = false } }