// Copyright (C) 2025 Acoustic, L.P. All rights reserved. // // NOTICE: This file contains material that is confidential and proprietary to // Acoustic, L.P. and/or other developers. No license is granted under any intellectual or // industrial property rights of Acoustic, L.P. except as may be provided in an agreement with // Acoustic, L.P. Any unauthorized copying or distribution of content from this file is // prohibited. // // // Created by Omar Hernandez on 5/9/25. // package com.acousticconnectrn import android.app.Activity import android.app.AlertDialog import android.app.Application import android.app.Dialog import android.content.Context import android.content.ContextWrapper import android.os.Build import android.os.Handler import android.os.Looper import android.text.TextUtils import android.util.Log import android.view.View import android.view.View.OnFocusChangeListener import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.TextView import androidx.fragment.app.DialogFragment import com.acoustic.connect.android.connectmod.Connect import com.acoustic.connect.android.connectmod.Connect.TLF_ON_FOCUS_CHANGE_IN import com.acoustic.connect.android.connectmod.Connect.TLF_ON_FOCUS_CHANGE_OUT import com.acoustic.connect.android.connectmod.Connect.TLF_UI_KEYBOARD_DID_HIDE_NOTIFICATION import com.acoustic.connect.android.connectmod.Connect.TLF_UI_KEYBOARD_DID_SHOW_NOTIFICATION import com.acoustic.connect.android.connectmod.Connect.enable import com.acoustic.connect.android.connectmod.Connect.getApplication import com.acoustic.connect.android.connectmod.Connect.init import com.acoustic.connect.android.connectmod.Connect.isEnabled import com.acoustic.connect.android.connectmod.Connect.logEvent import com.acoustic.connect.android.connectmod.Connect.logGeolocation import com.acoustic.connect.android.connectmod.Connect.logLocationUpdateEventWithLatitude import com.acoustic.connect.android.connectmod.Connect.logScreenLayout import com.acoustic.connect.android.connectmod.Connect.logScreenview import com.acoustic.connect.android.connectmod.Connect.onResume import com.acoustic.connect.android.connectmod.Connect.registerFormField import com.acoustic.connect.android.connectmod.Connect.resumeConnect import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.UIManagerHelper import com.ibm.eo.EOCore import com.ibm.eo.model.EOMonitoringLevel import com.margelo.nitro.NitroModules.Companion.applicationContext import com.margelo.nitro.acousticconnectrn.HybridAcousticConnectRNSpec import com.margelo.nitro.acousticconnectrn.Variant_Boolean_String_Double import com.margelo.nitro.acousticconnectrn.Variant_NullType_String import com.tl.uic.Tealeaf import com.tl.uic.model.ScreenviewType import com.tl.uic.util.DialogUtil import com.tl.uic.util.LayoutUtil import com.tl.uic.util.keyboardview.KeyboardView import java.util.Objects class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(), LifecycleEventListener { // The Nitro C++ factory (`AcousticConnectRNOnLoad.cpp`) instantiates this // class with `getConstructor<()>()` — i.e. it expects a zero-arg // constructor. The React context can therefore not be passed in at // construction time; we resolve it lazily via `NitroModules.applicationContext` // (which is nullable until the React context attaches), guard every // access, and retry the lifecycle-listener registration on each // `enable()` call until it succeeds. // Touched only on the main looper. All read/write goes through // `runOnMain { ... }` (called from `init`, `enable()`, etc.), so the // unsynchronised access pattern is safe — the field's mutating thread // and reading thread are the same. private var lifecycleListenerRegistered = false init { Log.v(TAG, "[bridge] HybridAcousticConnectRN constructed") runOnMain { tryRegisterLifecycleListenerOnMain() // Cold-start auto-init: mirrors the iOS bridge's behaviour, where // `load()` runs inside the constructor's `Task { @MainActor in … }` // and initialises the SDK without waiting for an explicit JS // call. Without this, an Android cold start where the first // activity `onResume` fires before the lifecycle listener was // registered (slow JS bundle, RAM-bundle apps, dev hot reload) // would leave the SDK uninitialised until the next resume. maybeAutoInitOnMain() } } /** * Posts to the main looper. Used by every operation that touches * `lifecycleListenerRegistered`, registers a `LifecycleEventListener`, * or calls into `Connect.init`/`Connect.enable`/`Connect.disable` — so * all of those operations execute single-threaded on main, eliminating * read-then-write races on the registration flag. */ private fun runOnMain(block: () -> Unit) { Handler(Looper.getMainLooper()).post(block) } /** * Idempotent: registers this hybrid as a React lifecycle listener the * first time `NitroModules.applicationContext` is non-null, then no-ops. * * MUST be called on the main looper. Callers funnel through * [runOnMain]; the read-then-write of `lifecycleListenerRegistered` is * therefore single-threaded by construction. */ private fun tryRegisterLifecycleListenerOnMain() { if (lifecycleListenerRegistered) return val ctx = applicationContext if (ctx == null) { Log.v(TAG, "[bridge] React context not ready; lifecycle listener registration deferred") return } ctx.addLifecycleEventListener(this) lifecycleListenerRegistered = true Log.v(TAG, "[bridge] Lifecycle listener registered") } /** * Cold-start auto-init helper. Calls `Connect.init` + `Connect.enable` * if the SDK isn't already running and the React context is attached. * MUST be called on the main looper. * * Idempotent at every level: * - If the SDK is already enabled, returns immediately. * - If the context isn't ready, returns without acting (the subsequent * `onHostResume` lifecycle callback or an explicit `enable()` from JS * will retry). * - `Connect.init` and `Connect.enable` are themselves idempotent in * the native SDK, so this co-existing with a later JS-driven * `enable()` is safe. */ private fun maybeAutoInitOnMain() { if (Connect.isEnabled()) return val app = resolveApplication() ?: return if (Connect.getApplication() == null) { Connect.init(app) } Connect.enable() Log.i(TAG, "[bridge] SDK auto-initialised at bridge construction") } /** * Resolves the host app's `Application` from * `NitroModules.applicationContext`. Returns `null` (with a logged * warning) when the React context isn't attached yet — callers must * bail rather than NPE. Replaces the previous direct deref through a * constructor-injected `ReactApplicationContext` field, which was unsafe * because Nitro's factory passes a null JNI handle through Kotlin's * non-null platform type. */ private fun resolveApplication(): Application? { val app = applicationContext?.applicationContext as? Application if (app == null) { Log.w(TAG, "[bridge] Application not yet available (NitroModules.applicationContext is null or its applicationContext is not an Application)") } return app } // region Gate-keeper API (CA-137696) /** * Re-enables the Connect SDK after a prior `disable()`. All configuration * comes from `ConnectConfig.json` at the consumer's project root, which * `config.gradle` bakes into `ConnectBasicConfig.properties` / * `TealeafBasicConfig.properties` at build time. * * Idempotency is owned by the native SDK. `Connect.init` is safe to call * multiple times, and `Connect.enable` short-circuits once the SDK is * running. The bridge does not track its own enable signature — subsequent * JS calls just post another runnable that the native side will treat as * a no-op. * * Push wiring on Android is gated at build time by `Connect.PushEnabled` * in `ConnectConfig.json`, which `android/build.gradle` consults to * include the `connect-push-fcm` artifact. Token forwarding to Connect * lives in CA-137698 and runs through the host app's * `FirebaseMessagingService`. * * @return true on accepted dispatch, false if no application context. */ override fun enable(): Boolean { Log.i(TAG, "[bridge] enable() called from JS") logResolvedPushAvailability() // All work happens on the main looper so the lifecycle-listener // registration flag is touched single-threaded and we don't race // with the `init { runOnMain { … } }` registration path. Returning // `true` reflects "accepted dispatch", not "succeeded"; the actual // work logs its own success or failure on main. runOnMain { tryRegisterLifecycleListenerOnMain() val application = resolveApplication() ?: run { Log.w(TAG, "[bridge] enable() bailed — Application still null on main thread") return@runOnMain } Connect.init(application) Connect.enable() Log.i(TAG, "[bridge] SDK initialised") } return true } /** * Disables the Connect SDK. Idempotent — the underlying `Connect.disable()` * is safe to call repeatedly on the native side; this override always * returns `true` for an accepted dispatch. */ override fun disable(): Boolean { Log.i(TAG, "[bridge] disable() called from JS") runOnMain { Connect.disable() Log.i(TAG, "[bridge] SDK disabled") } return true } /** * Checks whether the `connect-push-fcm` artifact is on the classpath and * logs the result. The artifact is gated by `Connect.PushEnabled` in * `ConnectConfig.json` via `android/build.gradle`'s conditional * `implementation` clause. A missing artifact when `PushEnabled` was set * to true points at a build-pipeline issue (didn't run `config.gradle` * or the conditional didn't fire). Once CA-137698 lands the actual * Connect push API on Android, this method also gates the wire-up. */ private fun logResolvedPushAvailability() { val pushAvailable = isConnectPushFcmAvailable() Log.i(TAG, "[config] connect-push-fcm on classpath: $pushAvailable") if (!pushAvailable) { Log.i(TAG, "[config] Push is not active on Android in this build. To enable, set Connect.PushEnabled=true in ConnectConfig.json and re-sync Gradle to include connect-push-fcm. Token forwarding wires in CA-137698.") } } private fun isConnectPushFcmAvailable(): Boolean { // Probe a known class shipped by the connect-push-fcm artifact. The // exact class lives in the Connect Android SDK's push module and is // finalised under CA-137698; this Class.forName probe is robust to // package-name changes because it falls through silently when the // class is missing (which is the default in a no-push build). return try { Class.forName(CONNECT_PUSH_FCM_PROBE_CLASS) true } catch (e: ClassNotFoundException) { false } } // endregion /** * Sets the module's boolean configuration item from AdvancedConfig.json or BasicConfig.properties that matches the specified key. * * @param key Map Key. * @param value Boolean Value. * @param moduleName The class name of the module's EOLifecycleObject for which the configuration item is referencing. * @return True if the operation was successful, false otherwise. */ override fun setBooleanConfigItemForKey( key: String, value: Boolean, moduleName: String ): Boolean { val result: Boolean = EOCore.updateConfig(key, value.toString(), EOCore.getLifecycleObject(moduleName)) return result } /** * Sets the module's string configuration item from AdvancedConfig.json or BasicConfig.properties that matches the specified key. * * @param key Map Key. * @param value String Value. * @param moduleName The class name of the module's EOLifecycleObject for which the configuration item is referencing. * @return True if the operation was successful, false otherwise. */ override fun setStringItemForKey( key: String, value: String, moduleName: String ): Boolean { val result = EOCore.updateConfig(key, value, EOCore.getLifecycleObject(moduleName)) return result } /** * Sets the module's number configuration item from AdvancedConfig.json or BasicConfig.properties that matches the specified key. * * @param key Map Key. * @param value Number Value. * @param moduleName The class name of the module's EOLifecycleObject for which the configuration item is referencing. * @return True if the operation was successful, false otherwise. */ override fun setNumberItemForKey( key: String, value: Double, moduleName: String ): Boolean { val result = EOCore.updateConfig(key, value.toString(), EOCore.getLifecycleObject(moduleName)) return result } /** * Sets the module's configuration item from AdvancedConfig.json or BasicConfig.properties that matches the specified key. * * @param key Map Key. * @param value Map Value. * @param moduleName The class name of the module's EOLifecycleObject for which the configuration item is referencing. * @param promise Javascript Promise interface. */ override fun setConfigItemForKey( key: String, value: Variant_Boolean_String_Double, moduleName: String ): Boolean { val result = EOCore.updateConfig(key, value.toString(), EOCore.getLifecycleObject(moduleName)) return result } /** * Gets the module's configuration item from AdvancedConfig.json or BasicConfig.properties that matches the specified key as a BOOL value. * * @param theDefault In case no value if found, use this value as default. * @param key Key value. * @param moduleName The class name of the module's EOLifecycleObject for which the configuration item is referencing. * @return True if the operation was successful, false otherwise. */ override fun getBooleanConfigItemForKey( theDefault: Boolean, key: String, moduleName: String ): Boolean { val result = EOCore.getConfigItemBoolean(key, EOCore.getLifecycleObject(moduleName)) if (result == false) { return theDefault } return result } /** * Gets the module's configuration item from AdvancedConfig.json or BasicConfig.properties that matches the specified key as a String value. * * @param theDefault In case no value if found, use this value as default. * @param key Key value. * @param moduleName The class name of the module's EOLifecycleObject for which the configuration item is referencing. * @return String value if the operation was successful, null otherwise. */ override fun getStringItemForKey(theDefault: String, key: String, moduleName: String): Variant_NullType_String? { var result = EOCore.getConfigItemString(key, EOCore.getLifecycleObject(moduleName)) if (TextUtils.isEmpty(result)) { result = theDefault } return if (result != null) Variant_NullType_String.create(result) else null } /** * Gets the module's configuration item from AdvancedConfig.json or BasicConfig.properties that matches the specified key as a double value. * * @param theDefault In case no value if found, use this value as default. * @param key Key value. * @param moduleName The class name of the module's EOLifecycleObject for which the configuration item is referencing. * @return Double value if the operation was successful, 0.0 otherwise. */ override fun getNumberItemForKey(theDefault: Double, key: String, moduleName: String): Double { var result = EOCore.getConfigItemDouble(key, EOCore.getLifecycleObject(moduleName)) if (result == -1.0) { result = theDefault } return result } /** * Logs a custom event with the specified name and values. * * @param eventName The name of the event to be logged this will appear in the posted json. * @param values A map of values associated with the event. * @param level Set a custom log level to the event. This will override the configured log level for that event. * @return True if the operation was successful, false otherwise. */ override fun logCustomEvent( eventName: String, values: Map, level: Double ): Boolean { val result = Connect.logCustomEvent(eventName, convertToMap(values), level.toInt()) return result } /** * Logs a signal with the specified values. * * @param values A map of values associated with the signal. * @param level Set a custom log level to the event. This will override the configured log level for that event. * @return True if the operation was successful, false otherwise. */ override fun logSignal( values: Map, level: Double ): Boolean { val result = Connect.logSignal(convertToMapAny(values), level.toInt()) return result } /** * Logs an exception event with the specified message and stack information. * * @param message The message associated with the exception. * @param stackInfo The stack information associated with the exception. * @param unhandled Indicates whether the exception is unhandled. * @return True if the operation was successful, false otherwise. */ override fun logExceptionEvent(message: String, stackInfo: String, unhandled: Boolean): Boolean { val result = Connect.logExceptionEvent("React Plugin", message, stackInfo, unhandled) return result } /** * Logs the current location. * * @return True if the operation was successful, false otherwise. */ override fun logLocation(): Boolean { val result = logGeolocation(EOMonitoringLevel.kEOMonitoringLevelInfo.value) return result } /** * Logs the current location with the specified latitude and longitude. * * @param latitude The latitude of the location. * @param longitude The longitude of the location. * @param level Set a custom log level to the event. This will override the configured log level for that event. * @return True if the operation was successful, false otherwise. */ override fun logLocationWithLatitudeLongitude( latitude: Double, longitude: Double, level: Double ): Boolean { val result = logLocationUpdateEventWithLatitude(latitude, longitude, level.toInt()) return result } /** * Log click events on react native control. * * @param target Target id of the control. * @param controlId Accessibility ID(virtual id). * @return True if the operation was successful, false otherwise. */ override fun logClickEvent(target: Double, controlId: String): Boolean { val viewTag = target.toInt() return try { val ctx = applicationContext ?: return false val uiManager = UIManagerHelper.getUIManagerForReactTag(ctx, viewTag) ?: return false val view = uiManager.resolveView(viewTag) ?: return false Handler(Looper.getMainLooper()).post { val activity = getCurrentActivity() ?: return@post if (view is EditText) { addFocusAndRegister(view, null, activity) } else if (TextUtils.isEmpty(controlId)) { logEvent(view, "click") } else { logEvent(view, "click", controlId) } } true } catch (e: Exception) { Log.v(TAG, "logClickEvent error: ${e.message}", e) false } } /** * Log click events on react native control. * * @param target Target id of the control. * @return True if the operation was successful, false otherwise. */ fun logClickEvent(target: Double): Boolean { val viewTag = target.toInt() return try { val ctx = applicationContext ?: return false val uiManager = UIManagerHelper.getUIManagerForReactTag(ctx, viewTag) ?: return false val view = uiManager.resolveView(viewTag) ?: return false Handler(Looper.getMainLooper()).post { val activity = getCurrentActivity() ?: return@post if (view is EditText) { addFocusAndRegister(view, null, activity) } else { logEvent(view, "click") } } true } catch (e: Exception) { Log.v(TAG, "logClickEvent error: ${e.message}") false } } /** * Log EditText change event. * * @param target A valid native View Id for lookup. * @param controlId Accessibility ID(virtual id). * @param text The input string. * @return True if the operation was successful, false otherwise. */ override fun logTextChangeEvent(target: Double, controlId: String, text: Variant_NullType_String?): Boolean { val viewTag = target.toInt() return try { val ctx = applicationContext ?: return false val uiManager = UIManagerHelper.getUIManagerForReactTag(ctx, viewTag) ?: return false val view = uiManager.resolveView(viewTag) ?: return false Handler(Looper.getMainLooper()).post { val activity = getCurrentActivity() ?: return@post if (view is EditText && view.onFocusChangeListener == null) { logEvent(view, TLF_ON_FOCUS_CHANGE_IN, controlId) addFocusAndRegister(view, controlId, activity) } } true } catch (e: Exception) { Log.v(TAG, "logTextChangeEvent error: ${e.message}") false } } /** * Requests that the framework save the current application page name. * * @param logicalPageName The logical page name to be set. * @return True if the operation was successful, false otherwise. */ override fun setCurrentScreenName(logicalPageName: String): Boolean { val result = resumeConnect(getCurrentActivity(), logicalPageName, false) return result } /** * Requests that the framework logs an screen load event. * * @param logicalPageName The logical page name to be set. * @param referrer The referrer for the screen view. * @return True if the operation was successful, false otherwise. */ override fun logScreenViewContextLoad(logicalPageName: Variant_NullType_String?, referrer: Variant_NullType_String?): Boolean { val result = logScreenview(getCurrentActivity()!!, logicalPageName?.asSecondOrNull().toString(), ScreenviewType.LOAD, referrer?.asSecondOrNull()); return result } /** * Requests that the framework logs an screen unload event. * * @param logicalPageName The logical page name to be set. * @param referrer The referrer for the screen view. * @return True if the operation was successful, false otherwise. */ override fun logScreenViewContextUnload(logicalPageName: Variant_NullType_String?, referrer: Variant_NullType_String?): Boolean { val result = logScreenview(getCurrentActivity()!!, logicalPageName?.asSecondOrNull().toString(), ScreenviewType.UNLOAD, referrer?.asSecondOrNull()); return result } /** * Log Current Screen Layout using native side background thread. * * @param name Page name or title e.g. "Login View Controller"; Must not be empty. * @param delay The delay in milliseconds before logging the event. * @return True if the operation was successful, false otherwise. */ override fun logScreenLayout(name: String, delay: Double): Boolean { setCurrentScreenName(name) logScreenview(getCurrentActivity(), name, ScreenviewType.LOAD) var result = false if (LayoutUtil.canCaptureUserEvents(null, name)) { result = logScreenLayout( Objects.requireNonNull(getCurrentActivity()), name, if (delay.toInt() < 0) 300 else delay.toInt(), true ) } return result } /** * Logs a dialog show event with the specified dialog information. * * @param dialogId Unique identifier for the dialog. * @param dialogTitle The title of the dialog. * @param dialogType The type of dialog (alert, custom, modal). * @return True if the operation was successful, false otherwise. */ override fun logDialogShowEvent(dialogId: String, dialogTitle: String, dialogType: String): Boolean { var result: Boolean try { // Your existing logging code... val values = HashMap() values["dialogId"] = dialogId values["dialogTitle"] = dialogTitle values["dialogType"] = dialogType values["eventType"] = "dialog_show" values["timestamp"] = System.currentTimeMillis().toString() // For Alert dialogs, use delayed capture to ensure dialog is rendered if (dialogType == "alert") { // Schedule delayed capture to allow dialog to render Handler(Looper.getMainLooper()).postDelayed({ try { val dialog = findMostRecentDialog() if (dialog != null) { val activity = getCurrentActivity() if (activity != null) { DialogUtil.logDialog(getCurrentActivity(), dialog) // Tealeaf.logScreenLayoutSetOnShowListener(activity, dialog, dialogId, true) Log.v(TAG, "Delayed screenshot capture successful for dialog: $dialogId") } } else { Log.v(TAG, "Warning: Could not find dialog object for $dialogId after delay") // Fallback to regular screen layout capture logScreenLayout(Objects.requireNonNull(getCurrentActivity()), dialogTitle, 0, true) } } catch (e: Exception) { Log.v(TAG, "Error in delayed dialog capture: ${e.message}") // Fallback to regular screen layout capture logScreenLayout(Objects.requireNonNull(getCurrentActivity()), dialogTitle, 0, true) } }, DIALOG_CAPTURE_DELAY_MS) // Use configurable delay // Return true immediately since we're handling capture asynchronously result = true } else { // For non-alert dialogs, try immediate capture first val dialog = findMostRecentDialog() if (dialog != null) { val activity = getCurrentActivity() if (activity != null) { DialogUtil.logDialog(getCurrentActivity(), dialog) return true } else { result = false } } else { // Default fallback result = logScreenLayout(Objects.requireNonNull(getCurrentActivity()), dialogTitle, 300, true) } } } catch (e: Exception) { Log.v(TAG, "Error logging dialog show event: ${e.message}") result = false } return result } private fun findMostRecentDialog(): Dialog? { val activity = getCurrentActivity() ?: return null // Check fragments first val fragmentDialog = findDialogFromFragments(activity) if (fragmentDialog != null) return fragmentDialog // Check window manager for Alert dialogs with retry logic return findLatestDialogFromWindowManager(activity) } private fun findDialogFromFragments(activity: Activity): Dialog? { try { // Check support fragments first (more common in modern apps) if (activity is androidx.fragment.app.FragmentActivity) { val supportFm = activity.supportFragmentManager for (frag in supportFm.fragments) { if (frag is androidx.fragment.app.DialogFragment) { val dialog = frag.dialog if (dialog != null && dialog.isShowing) { return dialog } } } } // Check legacy fragments // Note: fm.fragments is only available in API 26+, so we'll skip this for older versions if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val fm = activity.fragmentManager if (fm != null) { for (frag in fm.fragments) { if (frag is DialogFragment) { val dialog = frag.dialog if (dialog != null && dialog.isShowing) { return dialog } } } } } } catch (e: Exception) { Log.v(TAG, "Error checking fragments: ${e.message}") } return null } private fun findLatestDialogFromWindowManager(activity: Activity): Dialog? { try { val wmgClass = Class.forName("android.view.WindowManagerGlobal") val wmgInstance = wmgClass.getMethod("getInstance").invoke(null) val viewsField = wmgClass.getDeclaredField("mViews") viewsField.isAccessible = true val views = viewsField.get(wmgInstance) as ArrayList val paramsField = wmgClass.getDeclaredField("mParams") paramsField.isAccessible = true val params = paramsField.get(wmgInstance) as ArrayList // Find the most recent dialog window (last in the list) for (i in views.indices.reversed()) { val view = views[i] val param = params[i] if (isDialogWindow(param)) { // Try to get dialog from view context var context = view.context while (context is ContextWrapper) { if (context is Dialog && context.isShowing) { Log.v(TAG, "Found dialog in WindowManager: ${context.javaClass.simpleName}") return context } context = context.baseContext } // Additional check: look for AlertDialog specifically if (view.javaClass.name.contains("AlertController")) { Log.v(TAG, "Found AlertController view, attempting to get dialog") // Try to find the dialog through the view's parent or other means val parent = view.parent if (parent is View) { var parentContext = parent.context while (parentContext is ContextWrapper) { if (parentContext is Dialog && parentContext.isShowing) { Log.v(TAG, "Found AlertDialog through parent context") return parentContext } parentContext = parentContext.baseContext } } } } } } catch (e: Exception) { Log.v(TAG, "Error accessing WindowManager: ${e.message}") } return null } private fun isDialogWindow(params: WindowManager.LayoutParams): Boolean { return params.type == WindowManager.LayoutParams.TYPE_APPLICATION_PANEL || params.type == WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL || (params.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND) != 0 } /** * Logs a dialog dismiss event with the specified dialog information. * * @param dialogId Unique identifier for the dialog. * @param dismissReason The reason for dismissing the dialog. * @return True if the operation was successful, false otherwise. */ override fun logDialogDismissEvent(dialogId: String, dismissReason: String): Boolean { var result = false try { val values = HashMap() values["dialogId"] = dialogId values["dismissReason"] = dismissReason values["eventType"] = "dialog_dismiss" values["timestamp"] = System.currentTimeMillis().toString() result = Connect.logCustomEvent("DialogDismissEvent", values, EOMonitoringLevel.kEOMonitoringLevelInfo.value) } catch (e: Exception) { Log.v(TAG, "Error logging dialog dismiss event: ${e.message}") result = false } return result } /** * Logs a dialog button click event with the specified button information. * * @param dialogId Unique identifier for the dialog. * @param buttonText The text of the clicked button. * @param buttonIndex The index of the clicked button. * @return True if the operation was successful, false otherwise. */ override fun logDialogButtonClickEvent(dialogId: String, buttonText: String, buttonIndex: Double): Boolean { var result: Boolean try { val values = HashMap() values["dialogId"] = dialogId values["buttonText"] = buttonText values["buttonIndex"] = buttonIndex.toInt().toString() values["eventType"] = "dialog_button_click" values["timestamp"] = System.currentTimeMillis().toString() result = Connect.logCustomEvent("DialogButtonClickEvent", values, EOMonitoringLevel.kEOMonitoringLevelInfo.value) // TODO: JS pass button id // Simple approach: find the most recently shown dialog // val dialog = findMostRecentDialog() // if (dialog != null) { // val activity = getCurrentActivity() // if (activity != null) { // val view = when (dialog) { // is AlertDialog -> dialog.getButton(id) // is androidx.appcompat.app.AlertDialog -> dialog.getButton(id) // else -> null // } // // if (view == null) { // return false // } // // Connect.logDialogEvent(dialog, 1) // } // } } catch (e: Exception) { Log.v(TAG, "Error logging dialog button click event: ${e.message}") result = false } return result } /** * Logs a custom dialog event with the specified event information. * * @param dialogId Unique identifier for the dialog. * @param eventName The name of the custom event. * @param values A map of values associated with the event. * @return True if the operation was successful, false otherwise. */ override fun logDialogCustomEvent( dialogId: String, eventName: String, values: Map ): Boolean { var result = false try { val eventValues = HashMap() eventValues["dialogId"] = dialogId eventValues["customEventName"] = eventName eventValues["eventType"] = "dialog_custom_event" eventValues["timestamp"] = System.currentTimeMillis().toString() // Add the custom values for (key in values.keys) { val value = values[key] if (value != null) { eventValues[key] = value.toString() } } result = Connect.logCustomEvent("DialogCustomEvent", eventValues, EOMonitoringLevel.kEOMonitoringLevelInfo.value) } catch (e: Exception) { Log.v(TAG, "Error logging dialog custom event: ${e.message}") result = false } return result } /** * Converts a map of Variant_Boolean_String_Double to a HashMap. * * @param values The map to be converted. * @return A HashMap representation of the input map which library can use. */ private fun convertToMap(values: Map): HashMap { val map = HashMap() for (key in values.keys) { val value = values[key] if (value != null) { map[key] = value.toString() } } return map } /** * Converts a map of Variant_Boolean_String_Double to a HashMap. * * @param values The map to be converted. * @return A HashMap representation of the input map which library can use. */ private fun convertToMapAny(values: Map): java.util.HashMap? { val map = HashMap() for (key in values.keys) { val value = values[key] if (value != null) { map[key] = value } } return map } /** * Gets the current activity from the ReactApplicationContext. * * @return The current activity or null if not available. */ private fun getCurrentActivity(): android.app.Activity? { return applicationContext?.currentActivity } /** * Add focus listener to handle EditText UI control. * * @param textView Input TextView. * @param accessibilityID Accessibility ID(virtual id). * @param activity Current activity. */ fun addFocusAndRegister(textView: TextView, accessibilityID: String?, activity: Activity) { textView.onFocusChangeListener = OnFocusChangeListener { v: View, hasFocus: Boolean -> if (hasFocus) { val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.showSoftInput(v, InputMethodManager.SHOW_FORCED) val keyboardView = KeyboardView(v.context.applicationContext, null) if (TextUtils.isEmpty(accessibilityID)) { logEvent(keyboardView, TLF_UI_KEYBOARD_DID_SHOW_NOTIFICATION) logEvent(v, TLF_ON_FOCUS_CHANGE_IN) } else { logEvent(keyboardView, TLF_UI_KEYBOARD_DID_SHOW_NOTIFICATION, accessibilityID!!) logEvent(v, TLF_ON_FOCUS_CHANGE_IN, accessibilityID!!) } } else { logEvent(v, TLF_ON_FOCUS_CHANGE_OUT) val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(v.windowToken, 0) val keyboardView = KeyboardView(v.context.applicationContext, null) if (TextUtils.isEmpty(accessibilityID)) { logEvent(keyboardView, TLF_UI_KEYBOARD_DID_HIDE_NOTIFICATION) } else { logEvent(keyboardView, TLF_UI_KEYBOARD_DID_HIDE_NOTIFICATION, accessibilityID!!) } } } registerFormField(textView, activity) } // override fun onWindowFocusChanged(hasFocus: Boolean) { // if (!reactContext.hasActiveCatalystInstance()) { // logEvent("WindowFocus", "Context is not ready. Skipping onWindowFocusChanged.") // return // } // // // Handle window focus change // if (hasFocus) { // logEvent("WindowFocus", "Window gained focus") // } else { // logEvent("WindowFocus", "Window lost focus") // } // } // override fun onWindowFocusChanged(hasFocus: Boolean) { // super.onWindowFocusChanged(hasFocus) // // val reactHost = (application as MainApplication).reactHost // if (reactHost != null) { // reactHost.onWindowFocusChange(hasFocus) // } // } fun onWindowFocusChanged(hasFocus: Boolean) { if (applicationContext == null) { // logEvent("WindowFocus", "Context is not ready. Skipping onWindowFocusChanged.") return } // Handle window focus change if (hasFocus) { // logEvent("WindowFocus", "Window gained focus") } else { // logEvent("WindowFocus", "Window lost focus") } } /** * Used when host resumes. */ override fun onHostResume() { // Initialize Connect library, and hook into activity lifecycle events to help detect if app is in background if (applicationContext == null) { // logEvent("Lifecycle", "onHostResume skipped: ReactContext is not ready") return } val activity = getCurrentActivity() if (activity == null) { // logEvent("Lifecycle", "onHostResume skipped: Activity is null") return } if (!isEnabled()) { if (getApplication() == null) { val app = resolveApplication() ?: return init(app) } // Qualified to bypass the `enable()` override on this class — // call the native SDK's `Connect.enable()` directly so the // log line "called from JS" doesn't fire on lifecycle wake-ups. Connect.enable() } onResume(activity, null) } /** * Used when host gets paused. */ override fun onHostPause() { val activity = getCurrentActivity() if (activity == null) { // logEvent("Lifecycle", "onHostPause skipped: Activity is null") return } Connect.onPause(activity, null) } /** * Used when host gets destroyed. */ override fun onHostDestroy() { val activity = getCurrentActivity() if (activity == null) { // logEvent("Lifecycle", "onHostDestroy skipped: Activity is null") return } Tealeaf.onDestroy(activity, null) // Uncomment if Connect.onDestroy is needed // Connect.onDestroy(activity, null) } companion object { const val TAG = "AcousticConnectRN" const val DIALOG_CAPTURE_DELAY_MS = 500L // Configurable delay for dialog screenshot capture // Class probed at runtime to detect whether the connect-push-fcm // artifact was included in the build. Conservative placeholder — the // actual class name is finalised under CA-137698; if that ticket lands // a different fully-qualified name, update this constant. private const val CONNECT_PUSH_FCM_PROBE_CLASS = "com.acoustic.connect.android.push.fcm.ConnectPushFcm" } }