Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .artifacts/feedback/search-before-stubbing-interface-method.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
10 changes: 10 additions & 0 deletions android/src/newarch/java/com/RNIterableAPIModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions android/src/oldarch/java/com/RNIterableAPIModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions ios/RNIterableAPI/RNIterableAPI.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down Expand Up @@ -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];
}
Expand Down
15 changes: 15 additions & 0 deletions ios/RNIterableAPI/ReactIterableAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:)
Expand Down
15 changes: 15 additions & 0 deletions src/__mocks__/MockRNIterableAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
return await new Promise((resolve) => {
Expand All @@ -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<boolean> => {
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 => {
Expand Down
2 changes: 2 additions & 0 deletions src/api/NativeRNIterableAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export interface Spec extends TurboModule {
getEmail(): Promise<string | null>;
setUserId(userId?: string | null, authToken?: string | null): void;
getUserId(): Promise<string | null | undefined>;
setVisitorUsageTracked(tracked: boolean): void;
getVisitorUsageTracked(): Promise<boolean>;

// In-app messaging
setInAppShowResponse(number: number): void;
Expand Down
96 changes: 96 additions & 0 deletions src/core/classes/Iterable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
IterableInAppTriggerType,
IterableLogLevel,
} from '../..';
import { resetSdkInitializationStateForTests } from './Iterable';
import { TestHelper } from '../../__tests__/TestHelper';
import { IterableLogger } from './IterableLogger';

Expand Down Expand Up @@ -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
Expand Down
58 changes: 57 additions & 1 deletion src/core/classes/Iterable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -157,12 +164,18 @@ export class Iterable {
apiKey: string,
config: IterableConfig = new IterableConfig()
): Promise<boolean> {
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;
}
);
}

/**
Expand All @@ -176,6 +189,7 @@ export class Iterable {
config: IterableConfig = new IterableConfig(),
apiEndPoint: string
): Promise<boolean> {
sdkInitialized = false;
this.setupIterable(config);

const version = this.getVersionFromPackageJson();
Expand All @@ -184,6 +198,9 @@ export class Iterable {
config,
version,
apiEndPoint,
}).then((initialized) => {
sdkInitialized = true;
return initialized;
});
}

Expand Down Expand Up @@ -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<boolean> {
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.
*
Expand Down
Loading
Loading