diff --git a/.artifacts/feedback/search-before-specifying-interface-method.md b/.artifacts/feedback/search-before-specifying-interface-method.md new file mode 100644 index 000000000..d8aa9d5d8 --- /dev/null +++ b/.artifacts/feedback/search-before-specifying-interface-method.md @@ -0,0 +1,10 @@ +--- +kind: decision +applies-to: rules/specifier.md +--- + +# Search for an existing owner before specifying a method body + +Before a spec is finalized, search the tracker and open pull requests for every interface method this change must implement. Include long-lived branches, not only the default branch. If a ticket already defines that method, the spec depends on it and keeps that behaviour. A no-op is allowed only when the search finds no owner. + +A compile stub is not "too small for a spec" when the class already implements the interface and the pinned SDK requires a new method body. That body is a bridge contract another ticket may already own. Specifying an empty override on the default branch will overwrite the real behaviour when the branches meet. diff --git a/.artifacts/feedback/search-before-stubbing-interface-method.md b/.artifacts/feedback/search-before-stubbing-interface-method.md new file mode 100644 index 000000000..0f2543e9b --- /dev/null +++ b/.artifacts/feedback/search-before-stubbing-interface-method.md @@ -0,0 +1,10 @@ +--- +kind: decision +applies-to: rules/developer.md +--- + +# Search for an existing owner before stubbing an interface method + +Before writing a body for an interface method the current ticket did not name, search the tracker and branches other than the one you are on, including long-lived feature branches and open pull requests. If a ticket already defines the method, implement that contract or stop and ask. Do not invent a no-op. + +A green build of the current branch only proves the stub compiles. It cannot see an implementation that already emits, forwards, or otherwise behaves, sitting on another branch. Record the search in the handoff: the ticket, the branch, and whether the method already has a behaviour or is still absent. diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e01c633..56547ffb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Added `IterableConfig.enableUnknownUserActivation` (default `false`) to opt in to Unknown User Activation on the native SDK (SDK-794). Visitor consent via `Iterable.setVisitorUsageTracked` is also required before the native SDK stores unknown-user events. - Added `IterableConfig.enableForegroundCriteriaFetch` (default `true`) to control whether the SDK fetches unknown-user targeting criteria when the app enters the foreground (SDK-554). - Added `IterableConfig.eventThresholdLimit` (default `100`) to cap locally stored unknown-user events before identification (SDK-553). The value is forwarded to the native SDK and applies when unknown-user activation is enabled. +- Added `Iterable.setVisitorUsageTracked` and `Iterable.getVisitorUsageTracked` to record and read visitor consent for Unknown User Activation (SDK-561). Consent is persisted by the native SDK. - Added `IterableConfig.identityResolution` and the `IterableIdentityResolution` type so RN apps can configure default visitor replay and unknown-to-known merge behavior (SDK-556). ## 3.2.1 diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index c3396295d..b998b468a 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -249,6 +249,15 @@ public void getUserId(Promise promise) { promise.resolve(RNIterableInternal.getUserId()); } + public void setVisitorUsageTracked(boolean tracked) { + IterableLogger.d(TAG, "setVisitorUsageTracked: " + tracked); + IterableApi.getInstance().setVisitorUsageTracked(tracked); + } + + public void getVisitorUsageTracked(Promise promise) { + promise.resolve(IterableApi.getInstance().getVisitorUsageTracked()); + } + public void trackEvent(String name, @Nullable ReadableMap dataFields) { IterableLogger.v(TAG, "trackEvent"); IterableApi.getInstance().track(name, optSerializedDataFields(dataFields)); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index fb71142b8..0cd0d9533 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -72,6 +72,16 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @Override + public void setVisitorUsageTracked(boolean tracked) { + moduleImpl.setVisitorUsageTracked(tracked); + } + + @Override + public void getVisitorUsageTracked(Promise promise) { + moduleImpl.getVisitorUsageTracked(promise); + } + @Override public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index c2f9b3161..9efe250ce 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -73,6 +73,16 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @ReactMethod + public void setVisitorUsageTracked(boolean tracked) { + moduleImpl.setVisitorUsageTracked(tracked); + } + + @ReactMethod + public void getVisitorUsageTracked(Promise promise) { + moduleImpl.getVisitorUsageTracked(promise); + } + @ReactMethod public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 965b2d81e..745307dfd 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -112,6 +112,15 @@ - (void)getUserId:(RCTPromiseResolveBlock)resolve [_swiftAPI getUserId:resolve rejecter:reject]; } +- (void)setVisitorUsageTracked:(BOOL)tracked { + [_swiftAPI setVisitorUsageTracked:tracked]; +} + +- (void)getVisitorUsageTracked:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_swiftAPI getVisitorUsageTracked:resolve rejecter:reject]; +} + - (void)setInAppShowResponse:(double)inAppShowResponse { [_swiftAPI setInAppShowResponse:inAppShowResponse]; } @@ -403,6 +412,15 @@ - (void)wakeApp { [_swiftAPI getUserId:resolve rejecter:reject]; } +RCT_EXPORT_METHOD(setVisitorUsageTracked : (BOOL)tracked) { + [_swiftAPI setVisitorUsageTracked:tracked]; +} + +RCT_EXPORT_METHOD(getVisitorUsageTracked : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { + [_swiftAPI getVisitorUsageTracked:resolve rejecter:reject]; +} + RCT_EXPORT_METHOD(setInAppShowResponse : (double)inAppShowResponse) { [_swiftAPI setInAppShowResponse:inAppShowResponse]; } diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index a216a0e69..475edca3e 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -129,6 +129,21 @@ import React resolver(IterableAPI.userId) } + @objc(setVisitorUsageTracked:) + public func setVisitorUsageTracked(tracked: Bool) { + ITBInfo() + IterableAPI.setVisitorUsageTracked(isVisitorUsageTracked: tracked) + } + + @objc(getVisitorUsageTracked:rejecter:) + public func getVisitorUsageTracked( + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + ITBInfo() + resolver(IterableAPI.getVisitorUsageTracked()) + } + // MARK: - Iterable API Request Functions @objc(setInAppShowResponse:) diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 60013bdbb..407e093a1 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -9,6 +9,8 @@ export class MockRNIterableAPI { static attributionInfo?: IterableAttributionInfo; static messages?: IterableInAppMessage[]; static clickedUrl?: string; + static visitorUsageTracked = false; + static unknownVisitorEvents: string[] = []; static async getEmail(): Promise { return await new Promise((resolve) => { @@ -32,6 +34,19 @@ export class MockRNIterableAPI { MockRNIterableAPI.token = authToken; }); + static setVisitorUsageTracked = jest.fn((tracked: boolean): void => { + MockRNIterableAPI.visitorUsageTracked = tracked; + MockRNIterableAPI.unknownVisitorEvents = []; + }); + + static getVisitorUsageTracked = jest.fn(async (): Promise => { + return await Promise.resolve(MockRNIterableAPI.visitorUsageTracked); + }); + + static pushUnknownVisitorEventForTests(eventId: string): void { + MockRNIterableAPI.unknownVisitorEvents.push(eventId); + } + static disableDeviceForCurrentUser = jest.fn(); static registerDeviceToken = jest.fn((token: string): void => { diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index 7e96bc649..6c27f8d6d 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -85,6 +85,8 @@ export interface Spec extends TurboModule { getEmail(): Promise; setUserId(userId?: string | null, authToken?: string | null): void; getUserId(): Promise; + setVisitorUsageTracked(tracked: boolean): void; + getVisitorUsageTracked(): Promise; // In-app messaging setInAppShowResponse(number: number): void; diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 6d1dc0e4c..c98cf065c 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -23,6 +23,7 @@ import { IterableInAppTriggerType, IterableLogLevel, } from '../..'; +import { resetSdkInitializationStateForTests } from './Iterable'; import { TestHelper } from '../../__tests__/TestHelper'; import { IterableLogger } from './IterableLogger'; @@ -81,6 +82,101 @@ describe('Iterable', () => { }); }); + describe('visitorUsageTracked', () => { + beforeEach(() => { + MockRNIterableAPI.visitorUsageTracked = false; + MockRNIterableAPI.unknownVisitorEvents = []; + resetSdkInitializationStateForTests(); + }); + + it('should expose setVisitorUsageTracked and getVisitorUsageTracked on Iterable', () => { + expect(typeof Iterable.setVisitorUsageTracked).toBe('function'); + expect(typeof Iterable.getVisitorUsageTracked).toBe('function'); + }); + + it('should forward true and false to RNIterableAPI after initialize', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(true); + + Iterable.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(false); + }); + + it('should resolve getVisitorUsageTracked to the value stored in the mock', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + MockRNIterableAPI.visitorUsageTracked = true; + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(true); + + MockRNIterableAPI.visitorUsageTracked = false; + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should resolve to false when the integrator never calls the setter', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should return the persisted mock value after initialize runs again', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + Iterable.setVisitorUsageTracked(true); + + resetSdkInitializationStateForTests(); + await Iterable.initialize('test-key', config); + + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(true); + }); + + it('should clear unknown-visitor events in the mock on each setVisitorUsageTracked call', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + MockRNIterableAPI.pushUnknownVisitorEventForTests('event-1'); + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.unknownVisitorEvents).toEqual([]); + + MockRNIterableAPI.pushUnknownVisitorEventForTests('event-2'); + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.unknownVisitorEvents).toEqual([]); + }); + + it('should not forward setVisitorUsageTracked before initialize resolves', async () => { + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).not.toHaveBeenCalled(); + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should not call trackEvent, setEmail, or setUserId from visitor usage methods', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(true); + await Iterable.getVisitorUsageTracked(); + + expect(MockRNIterableAPI.trackEvent).not.toHaveBeenCalled(); + expect(MockRNIterableAPI.setEmail).not.toHaveBeenCalled(); + expect(MockRNIterableAPI.setUserId).not.toHaveBeenCalled(); + }); + }); + describe('logout', () => { it('should call setEmail with null', () => { // GIVEN no parameters diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index aaaa84073..89ae3c811 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -27,6 +27,13 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); const defaultConfig = new IterableConfig(); +let sdkInitialized = false; + +/** Resets init gating for unit tests only; not part of the public package API. */ +export function resetSdkInitializationStateForTests(): void { + sdkInitialized = false; +} + /** * Checks if the response is an IterableAuthResponse */ @@ -157,12 +164,18 @@ export class Iterable { apiKey: string, config: IterableConfig = new IterableConfig() ): Promise { + sdkInitialized = false; Iterable.savedConfig = config; this.setupIterable(config); const version = this.getVersionFromPackageJson(); - return IterableApi.initializeWithApiKey(apiKey, { config, version }); + return IterableApi.initializeWithApiKey(apiKey, { config, version }).then( + (initialized) => { + sdkInitialized = true; + return initialized; + } + ); } /** @@ -176,6 +189,7 @@ export class Iterable { config: IterableConfig = new IterableConfig(), apiEndPoint: string ): Promise { + sdkInitialized = false; this.setupIterable(config); const version = this.getVersionFromPackageJson(); @@ -184,6 +198,9 @@ export class Iterable { config, version, apiEndPoint, + }).then((initialized) => { + sdkInitialized = true; + return initialized; }); } @@ -333,6 +350,45 @@ export class Iterable { return IterableApi.getUserId(); } + /** + * Record whether the end user has consented to visitor / unknown-user tracking. + * + * The value is persisted by the native SDK across app restarts. Each call + * clears locally stored unknown-visitor events on the native side. + * + * This method only records consent; it does not opt the app into Unknown User + * Activation by itself. Unknown-user flows also require + * `IterableConfig.enableUnknownUserActivation` (defaults to `false`) at + * `Iterable.initialize`. Use {@link Iterable.getVisitorUsageTracked} to read + * the stored value. + * + * No-op before `Iterable.initialize` has resolved (does not forward to native). + */ + static setVisitorUsageTracked(tracked: boolean): void { + if (!sdkInitialized) { + return; + } + IterableApi.setVisitorUsageTracked(tracked); + } + + /** + * Returns whether visitor / unknown-user tracking consent has been recorded. + * + * Reads the value persisted by the native SDK (including across app restarts + * after a prior `setVisitorUsageTracked` call). + * + * Resolves to `false` before `Iterable.initialize` has resolved or if the + * integrator has never called `setVisitorUsageTracked`. Visitor consent alone + * does not enable unknown-user flows; those also require + * `IterableConfig.enableUnknownUserActivation` (defaults to `false`) at init. + */ + static getVisitorUsageTracked(): Promise { + if (!sdkInitialized) { + return Promise.resolve(false); + } + return IterableApi.getVisitorUsageTracked(); + } + /** * Disable the device's token for the current user. This will disable push notifications for the current user. * diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index d639d79ed..0e99e9a49 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -271,6 +271,25 @@ describe('IterableApi', () => { }); }); + describe('visitorUsageTracked', () => { + beforeEach(() => { + MockRNIterableAPI.visitorUsageTracked = false; + }); + + it('should call RNIterableAPI.setVisitorUsageTracked with the tracked flag', () => { + IterableApi.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(true); + + IterableApi.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(false); + }); + + it('should return visitor usage tracked from RNIterableAPI', async () => { + MockRNIterableAPI.visitorUsageTracked = true; + await expect(IterableApi.getVisitorUsageTracked()).resolves.toBe(true); + }); + }); + describe('disableDeviceForCurrentUser', () => { it('should call RNIterableAPI.disableDeviceForCurrentUser', () => { // GIVEN no parameters diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index 2fad862a7..65377914f 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -129,6 +129,16 @@ export class IterableApi { return RNIterableAPI.getUserId(); } + static setVisitorUsageTracked(tracked: boolean) { + IterableLogger.log('setVisitorUsageTracked: ', tracked); + return RNIterableAPI.setVisitorUsageTracked(tracked); + } + + static getVisitorUsageTracked() { + IterableLogger.log('getVisitorUsageTracked'); + return RNIterableAPI.getVisitorUsageTracked(); + } + /** * Disable the device for the current user. */