import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; import { DecisionReason, Event, HackleRemoteConfig, PropertyValue, Screen, SdkConfig, User, VariationKey, HackleInAppMessageListener, HackleInAppMessageView, InAppMessageEvents, PropertyOperations, PropertyOperationsBuilder, HackleSubscriptionOperations, EvaluationMode, } from './models'; import { Emitter, UserUtil } from './utils'; import Logger, { logLevels } from './utils/logger'; import { SDK_NAME, SDK_VERSION } from './constants'; interface HackleReactNativeSdkTypeClientEventMap { 'user-updated': User | null; } type HackleReactNativeSdkType = { initialize( sdkKey: string, config: { [key: string]: string | number | boolean | object | undefined }, user?: User | null ): Promise; variationDetail(experimentKey: number): Promise; variationDetailSync(experimentKey: number): ReactNativeDecision; featureFlagDetail( featureKey: number ): Promise; featureFlagDetailSync(featureKey: number): ReactNativeFeatureFlagDecision; track(event: Event): void; getRemoteConfigStringSync(parameterKey: string, defaultValue: string): string; getRemoteConfigString( parameterKey: string, defaultValue: string ): Promise; getRemoteConfigDoubleSync(parameterKey: string, defaultValue: number): number; getRemoteConfigDouble( parameterKey: string, defaultValue: number ): Promise; getRemoteConfigBooleanSync( parameterKey: string, defaultValue: boolean ): boolean; getRemoteConfigBoolean( parameterKey: string, defaultValue: boolean ): Promise; getDeviceId(): string; getUser(): Promise; setUser(user: User): Promise; setUserId(userId: string | null | undefined): Promise; setDeviceId(deviceId: string): Promise; updateUserProperties( operations: Record> ): Promise; updatePushSubscriptions(operations: Record): Promise; updateSmsSubscriptions(operations: Record): Promise; updateKakaoSubscriptions(operations: Record): Promise; resetUser(): Promise; setPhoneNumber(phoneNumber: string): Promise; unsetPhoneNumber(): Promise; showUserExplorer(): Promise; hideUserExplorer(): Promise; fetch(): Promise; isInvocableString(command: string): Promise; bridgeInvoke(command: string): Promise; resolveInAppMessageClick(result: boolean): Promise; closeInAppMessageView(): Promise; dismissDisplayedInAppMessageView(): Promise; setBackButtonDismissesInAppMessageView(dismisses: boolean): Promise; beginListening(): Promise; endListening(): Promise; setCurrentScreen(screen: Screen): Promise; setOptOutTracking(optOut: boolean): Promise; isOptOutTracking(): Promise; }; const HackleReactNativeSdk: HackleReactNativeSdkType = NativeModules.HackleReactNativeSdk; const eventEmitter = new NativeEventEmitter(NativeModules.HackleReactNativeSdk); /** * Hackle React Native SDK client interface * * Provides methods for A/B testing, feature flags, remote config, event tracking, and user management. */ export interface ReactNativeSDKClient extends Emitter { /** * Gets the variation key for an A/B test experiment asynchronously * * @param experimentKey - The experiment key identifier * @returns Promise resolving to the variation key (e.g., "A", "B") */ variation(experimentKey: number): Promise; /** * Gets the variation key for an A/B test experiment synchronously * * @param experimentKey - The experiment key identifier * @returns The variation key (e.g., "A", "B") */ variationSync(experimentKey: number): string; /** * Gets detailed decision information for an A/B test experiment asynchronously * * @param experimentKey - The experiment key identifier * @returns Promise resolving to decision details including variation, reason, and parameters */ variationDetail(experimentKey: number): Promise; /** * Gets detailed decision information for an A/B test experiment synchronously * * @param experimentKey - The experiment key identifier * @returns Decision details including variation, reason, and parameters */ variationDetailSync(experimentKey: number): ReactNativeDecision; /** * Checks if a feature flag is enabled asynchronously * * @param featureKey - The feature flag key identifier * @returns Promise resolving to true if the feature is enabled, false otherwise */ isFeatureOn(featureKey: number): Promise; /** * Checks if a feature flag is enabled synchronously * * @param featureKey - The feature flag key identifier * @returns True if the feature is enabled, false otherwise */ isFeatureOnSync(featureKey: number): boolean; /** * Gets detailed decision information for a feature flag asynchronously * * @param featureKey - The feature flag key identifier * @returns Promise resolving to feature flag decision details including isOn status, reason, and parameters */ featureFlagDetail( featureKey: number ): Promise; /** * Gets detailed decision information for a feature flag synchronously * * @param featureKey - The feature flag key identifier * @returns Feature flag decision details including isOn status, reason, and parameters */ featureFlagDetailSync(featureKey: number): ReactNativeFeatureFlagDecision; /** * Tracks an event * * @param event - The event to track with a key, and properties */ track(event: Event): void; /** * Gets the remote config asynchronously * * @returns Promise resolving to remote config object for retrieving configuration values */ remoteConfig(): Promise; /** * Gets the remote config synchronously * * @returns Remote config object for retrieving configuration values */ remoteConfigSync(): HackleRemoteConfig; /** * Waits for the SDK to be fully initialized * * @returns Promise that resolves when the SDK is ready */ onReady(): Promise; /** * Gets the current user object * * @returns Promise resolving to the current user */ getUser(): Promise; /** * Sets the current user * * @param user - The user object to set * @returns Promise that resolves when the user is set */ setUser(user: User): Promise; /** * Sets the user ID * * @param userId - The user ID to set, or null/undefined to clear * @returns Promise that resolves when the user ID is set */ setUserId(userId: string | null | undefined): Promise; /** * Sets the device ID * * @param deviceId - The device ID to set * @returns Promise that resolves when the device ID is set */ setDeviceId(deviceId: string): Promise; /** * Sets a user property * * @deprecated Use updateUserProperties(operations) instead * @param key - The property key * @param value - The property value * @returns Promise that resolves when the property is set */ setUserProperty(key: string, value: PropertyValue): Promise; /** * Sets user properties * * @deprecated Use updateUserProperties(operations) instead * @param properties - Object containing property key-value pairs * @returns Promise that resolves when the properties are set */ setUserProperties(properties: { [key: string]: PropertyValue; }): Promise; /** * Updates user properties using operations * * @param operations - Property operations to apply * @returns Promise that resolves when the operations are applied */ updateUserProperties(operations: PropertyOperations): Promise; /** * Updates push notification subscription preferences * * @param operations - Subscription operations to apply * @returns Promise that resolves when the subscriptions are updated */ updatePushSubscriptions( operations: HackleSubscriptionOperations ): Promise; /** * Updates SMS subscription preferences * * @param operations - Subscription operations to apply * @returns Promise that resolves when the subscriptions are updated */ updateSmsSubscriptions( operations: HackleSubscriptionOperations ): Promise; /** * Updates Kakao subscription preferences * * @param operations - Subscription operations to apply * @returns Promise that resolves when the subscriptions are updated */ updateKakaoSubscriptions( operations: HackleSubscriptionOperations ): Promise; /** * Resets the current user to an anonymous state * * @returns Promise that resolves when the user is reset */ resetUser(): Promise; /** * Sets the user's phone number * * @param phoneNumber - The phone number to set * @returns Promise that resolves when the phone number is set */ setPhoneNumber(phoneNumber: string): Promise; /** * Removes the user's phone number * * @returns Promise that resolves when the phone number is unset */ unsetPhoneNumber(): Promise; /** * Shows the user explorer UI for debugging * * @returns Promise that resolves when the user explorer is shown */ showUserExplorer(): Promise; /** * Hides the user explorer UI * * @returns Promise that resolves when the user explorer is hidden */ hideUserExplorer(): Promise; /** * Manually fetches the latest configuration from the server * * @returns Promise that resolves when the fetch is complete */ fetch(): Promise; /** * Checks if a command string can be invoked via bridge * * @param command - The command string to check * @returns Promise resolving to true if the command is invocable, false otherwise */ isInvocableString(command: string): Promise; /** * Invokes a command via the bridge * * @param command - The command string to invoke * @returns Promise resolving to the command result string */ bridgeInvoke(command: string): Promise; /** * Sets the in-app message listener for handling in-app message events * * @param listener - The listener to handle in-app message events, or null to remove */ setInAppMessageListener(listener: HackleInAppMessageListener | null): void; /** * Dismisses the currently displayed in-app message view. * If no in-app message view is currently displayed, this method does nothing. * * @returns Promise that resolves when the dismiss is complete */ dismissDisplayedInAppMessageView(): Promise; /** * Configures whether the back button dismisses in-app message views (Android only) * * @param dismisses - True to allow back button to dismiss, false otherwise * @returns Promise that resolves when the setting is applied */ setBackButtonDismissesInAppMessageView(dismisses: boolean): Promise; /** * Updates the current screen * * @param screen - Screen information * @returns Promise that resolves when the screen is set */ setCurrentScreen(screen: Screen): Promise; /** * Sets the opt-out tracking state * * @param optOut - True to opt out of tracking, false to opt in * @returns Promise that resolves when the setting is applied */ setOptOutTracking(optOut: boolean): Promise; /** * Gets the current opt-out tracking state * * @returns Promise resolving to true if tracking is opted out, false otherwise */ isOptOutTracking(): Promise; } const log = Logger.log; class HackleReactNativeClient extends Emitter implements ReactNativeSDKClient { private readonly client: HackleReactNativeSdkType; private readonly isReady: Promise; private readonly evaluationMode: EvaluationMode; private _currentUser: User | null = null; private inAppMessageListener: HackleInAppMessageListener | null = null; constructor(sdkKey: string, config?: SdkConfig, user?: User) { super(); this.client = HackleReactNativeSdk; this.evaluationMode = config?.evaluationMode ?? 'local'; this.isReady = HackleReactNativeSdk.initialize(sdkKey, { wrapperName: SDK_NAME, wrapperVersion: SDK_VERSION, ...config, }, user ?? null); // This is to prevent the "This Binding" problem. this.variation = this.variation.bind(this); this.variationSync = this.variationSync.bind(this); this.variationDetail = this.variationDetail.bind(this); this.variationDetailSync = this.variationDetailSync.bind(this); this.isFeatureOn = this.isFeatureOn.bind(this); this.isFeatureOnSync = this.isFeatureOnSync.bind(this); this.featureFlagDetail = this.featureFlagDetail.bind(this); this.featureFlagDetailSync = this.featureFlagDetailSync.bind(this); this.track = this.track.bind(this); this.remoteConfig = this.remoteConfig.bind(this); this.remoteConfigSync = this.remoteConfigSync.bind(this); this.onReady = this.onReady.bind(this); this.getUser = this.getUser.bind(this); this.setUser = this.setUser.bind(this); this.setUserId = this.setUserId.bind(this); this.setDeviceId = this.setDeviceId.bind(this); this.setUserProperty = this.setUserProperty.bind(this); this.setUserProperties = this.setUserProperties.bind(this); this.updateUserProperties = this.updateUserProperties.bind(this); this.resetUser = this.resetUser.bind(this); this.setPhoneNumber = this.setPhoneNumber.bind(this); this.unsetPhoneNumber = this.unsetPhoneNumber.bind(this); this.updateUser = this.updateUser.bind(this); this.showUserExplorer = this.showUserExplorer.bind(this); this.hideUserExplorer = this.hideUserExplorer.bind(this); this.fetch = this.fetch.bind(this); this.isInvocableString = this.isInvocableString.bind(this); this.bridgeInvoke = this.bridgeInvoke.bind(this); this.setInAppMessageListener = this.setInAppMessageListener.bind(this); this.dismissDisplayedInAppMessageView = this.dismissDisplayedInAppMessageView.bind(this); this.setInAppMessageEventEmitter = this.setInAppMessageEventEmitter.bind(this); this.setCurrentScreen = this.setCurrentScreen.bind(this); this.setOptOutTracking = this.setOptOutTracking.bind(this); this.isOptOutTracking = this.isOptOutTracking.bind(this); if (user) { this._currentUser = user; } if (config?.debug) { Logger.setLogLevel(logLevels.DEBUG); } this.setInAppMessageEventEmitter(); } variation(experimentKey: number): Promise { return this.variationDetail(experimentKey).then((it) => it.variation); } variationSync(experimentKey: number): string { return this.variationDetailSync(experimentKey).variation; } variationDetail(experimentKey: number): Promise { return this.client.variationDetail(experimentKey); } variationDetailSync(experimentKey: number): ReactNativeDecision { return this.client.variationDetailSync(experimentKey); } isFeatureOn(featureKey: number): Promise { return this.featureFlagDetail(featureKey).then((it) => it.isOn); } isFeatureOnSync(featureKey: number): boolean { return this.featureFlagDetailSync(featureKey).isOn; } featureFlagDetail( featureKey: number ): Promise { return this.client.featureFlagDetail(featureKey); } featureFlagDetailSync(featureKey: number): ReactNativeFeatureFlagDecision { return this.client.featureFlagDetailSync(featureKey); } track(event: Event) { log.debug(`call track : ${JSON.stringify(event)}`); this.client.track(event); } remoteConfig(): Promise { return Promise.resolve(new HackleRemoteConfigImpl()); } remoteConfigSync(): HackleRemoteConfig { return new HackleRemoteConfigImpl(); } onReady(): Promise { return this.isReady; } getUser(): Promise { return this.client.getUser(); } async setUser(user: User): Promise { log.debug(`call setUser : ${JSON.stringify(user)}`); await this.client.setUser(user); await this.getUser().then((u) => this.updateUser(u)); } async setUserId(userId: string | null | undefined): Promise { log.debug(`call setUser userId: ${userId}`); // undefined이 들어오면 네이티브 쪽에서 null로 받을 수 있도록 null로 변환 await this.client.setUserId(userId ?? null); await this.getUser().then((u) => this.updateUser(u)); } async setDeviceId(deviceId: string): Promise { log.debug(`call setDeviceId deviceId: ${deviceId}`); await this.client.setDeviceId(deviceId); await this.getUser().then((u) => this.updateUser(u)); } /** @deprecated Use updateUserProperties(operations) instead */ async setUserProperty(key: string, value: PropertyValue): Promise { log.debug(`call setUserProperty : ${key} : ${value}`); const operations = new PropertyOperationsBuilder().set(key, value).build(); await this.updateUserProperties(operations); } /** @deprecated Use updateUserProperties(operations) instead */ async setUserProperties(properties: { [key: string]: PropertyValue; }): Promise { log.debug(`call setUserProperties: ${JSON.stringify(properties)}`); const builder = new PropertyOperationsBuilder(); Object.entries(properties).forEach(([key, value]) => { builder.set(key, value); }); await this.updateUserProperties(builder.build()); } async updateUserProperties(operations: PropertyOperations): Promise { log.debug(`call updateUserProperties: ${JSON.stringify(operations)}`); await this.client.updateUserProperties(operations.toRecord()); await this.getUser().then((u) => this.updateUser(u)); } async updatePushSubscriptions( operations: HackleSubscriptionOperations ): Promise { log.debug(`call updatePushSubscriptions: ${JSON.stringify(operations)}`); await this.client.updatePushSubscriptions(operations.toRecord()); } async updateSmsSubscriptions( operations: HackleSubscriptionOperations ): Promise { log.debug(`call updateSmsSubscriptions: ${JSON.stringify(operations)}`); await this.client.updateSmsSubscriptions(operations.toRecord()); } async updateKakaoSubscriptions( operations: HackleSubscriptionOperations ): Promise { log.debug(`call updateKakaoSubscriptions: ${JSON.stringify(operations)}`); await this.client.updateKakaoSubscriptions(operations.toRecord()); } async resetUser(): Promise { log.debug(`call resetUser`); await this.client.resetUser(); await this.getUser().then((u) => this.updateUser(u)); } async setPhoneNumber(phoneNumber: string): Promise { log.debug(`call setPhoneNumber : ${phoneNumber}`); await this.client.setPhoneNumber(phoneNumber); } async unsetPhoneNumber(): Promise { log.debug(`call unsetPhoneNumber`); await this.client.unsetPhoneNumber(); } private updateUser(user: User | null) { const changed = !UserUtil.isUserEqual(this._currentUser, user); if (changed || this.evaluationMode === 'remote') { this.emit('user-updated', user); } this._currentUser = user; } showUserExplorer(): Promise { log.debug(`call showUserExplorer`); return this.client.showUserExplorer(); } hideUserExplorer(): Promise { log.debug(`call hideUserExplorer`); return this.client.hideUserExplorer(); } fetch(): Promise { log.debug(`call fetch`); return this.client.fetch(); } isInvocableString(command: string): Promise { log.debug(`call isInvocableString`); return this.client.isInvocableString(command); } bridgeInvoke(command: string): Promise { log.debug(`call bridgeInvoke`); return this.client.bridgeInvoke(command); } private setInAppMessageEventEmitter() { eventEmitter.addListener(InAppMessageEvents.BEFORE_OPEN, (event) => { this.inAppMessageListener?.beforeInAppMessageOpen?.(event.inAppMessage); }); eventEmitter.addListener(InAppMessageEvents.AFTER_OPEN, (event) => { this.inAppMessageListener?.afterInAppMessageOpen?.(event.inAppMessage); }); eventEmitter.addListener(InAppMessageEvents.BEFORE_CLOSE, (event) => { this.inAppMessageListener?.beforeInAppMessageClose?.(event.inAppMessage); }); eventEmitter.addListener(InAppMessageEvents.AFTER_CLOSE, (event) => { this.inAppMessageListener?.afterInAppMessageClose?.(event.inAppMessage); }); eventEmitter.addListener(InAppMessageEvents.ON_CLICK, (event) => { if (this.inAppMessageListener?.onInAppMessageClick === null) { this.client.resolveInAppMessageClick(false); return; } const shouldHandleClick = this.inAppMessageListener?.onInAppMessageClick?.( event.inAppMessage, new HackleInAppMessageViewImpl(), event.action ) ?? false; this.client.resolveInAppMessageClick(shouldHandleClick); }); } setInAppMessageListener(listener: HackleInAppMessageListener | null) { if (listener == null) { this.client.endListening(); this.inAppMessageListener = null; return; } this.inAppMessageListener = listener; this.client.beginListening(); } async dismissDisplayedInAppMessageView(): Promise { log.debug('call dismissDisplayedInAppMessageView'); await this.client.dismissDisplayedInAppMessageView(); } setBackButtonDismissesInAppMessageView(dismisses: boolean): Promise { if (Platform.OS === 'ios') { return Promise.resolve(); } log.debug(`call setBackButtonDismissesInAppMessageView: ${dismisses}`); return this.client.setBackButtonDismissesInAppMessageView(dismisses); } async setCurrentScreen(screen: Screen): Promise { log.debug(`call setCurrentScreen: ${JSON.stringify(screen)}`); await this.client.setCurrentScreen(screen); } async setOptOutTracking(optOut: boolean): Promise { log.debug(`call setOptOutTracking: ${optOut}`); await this.client.setOptOutTracking(optOut); } async isOptOutTracking(): Promise { log.debug(`call isOptOutTracking`); return this.client.isOptOutTracking(); } } /** * Implementation of HackleInAppMessageView interface * * Provides methods to control in-app message views. */ export class HackleInAppMessageViewImpl implements HackleInAppMessageView { private readonly client: HackleReactNativeSdkType; constructor() { this.client = HackleReactNativeSdk; } /** * Closes the in-app message view */ close(): void { this.client.closeInAppMessageView(); } } /** * Creates a new Hackle SDK client instance * * @param sdkKey - The SDK key provided by Hackle * @param config - Optional SDK configuration * @param user - Optional user injected when initializing the SDK * @returns A new ReactNativeSDKClient instance */ export function createInstance( sdkKey: string, config?: SdkConfig, user?: User ): ReactNativeSDKClient { return new HackleReactNativeClient(sdkKey, config, user); } /** * Gets the device ID * * @returns The device ID string */ export function getDeviceId(): string { return HackleReactNativeSdk.getDeviceId(); } /** * Feature flag decision result * * Contains the decision result for a feature flag evaluation including activation status, reason, and parameters. */ export class ReactNativeFeatureFlagDecision { /** Whether the feature flag is enabled */ isOn: boolean; /** The reason for the decision */ reason: DecisionReason; /** Additional parameters associated with the decision */ readonly parameters: { [key: string]: string | number | boolean }; constructor( isOn: boolean, reason: DecisionReason, parameters: { [key: string]: string | number | boolean } ) { this.isOn = isOn; this.reason = reason; this.parameters = parameters; } } /** * A/B test experiment decision result * * Contains the decision result for an A/B test experiment including variation, reason, and parameters. */ export class ReactNativeDecision { /** The assigned variation key (e.g., "A", "B") */ variation: VariationKey; /** The reason for the decision */ reason: DecisionReason; /** Additional parameters associated with the decision */ readonly parameters: { [key: string]: string | number | boolean }; constructor( variation: VariationKey, reason: DecisionReason, parameters: { [key: string]: string | number | boolean } ) { this.variation = variation; this.reason = reason; this.parameters = parameters; } } /** * Implementation of HackleRemoteConfig interface * * Provides methods to retrieve remote configuration values. */ export class HackleRemoteConfigImpl implements HackleRemoteConfig { private readonly client: HackleReactNativeSdkType; constructor() { this.client = HackleReactNativeSdk; } /** * Gets a remote configuration value asynchronously * * @param key - The configuration key * @param defaultValue - The default value to return if the key is not found * @returns Promise resolving to the configuration value */ get( key: string, defaultValue: string | number | boolean ): Promise { switch (typeof defaultValue) { case 'string': return this.client.getRemoteConfigString(key, defaultValue); case 'number': return this.client.getRemoteConfigDouble(key, defaultValue); case 'boolean': return this.client.getRemoteConfigBoolean(key, defaultValue); default: console.warn( '[HACKLE_SDK] Please input valid type for default value. support type : string, number, boolean' ); return defaultValue; } } /** * Gets a remote configuration value synchronously * * @param key - The configuration key * @param defaultValue - The default value to return if the key is not found * @returns The configuration value */ getSync( key: string, defaultValue: string | number | boolean ): string | number | boolean { switch (typeof defaultValue) { case 'string': return this.client.getRemoteConfigStringSync(key, defaultValue); case 'number': return this.client.getRemoteConfigDoubleSync(key, defaultValue); case 'boolean': return this.client.getRemoteConfigBooleanSync(key, defaultValue); default: console.warn( '[HACKLE_SDK] Please input valid type for default value. support type : string, number, boolean' ); return defaultValue; } } }