Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### Updates

- Added `IterableConfig.decryptionFailureHandler` (Android only) to notify the app when native keychain decryption fails (SDK-557). The SDK clears stored PII and disables encryption for the device before invoking the callback; iOS has no equivalent native API and ignores this option.
- Added `Iterable.disableDeviceForAllUsers()` to unregister this device's push token from every user associated with the device (SDK-550).
- iOS: forwards to native `IterableAPI.disableDeviceForAllUsers()`.
- Android: graceful no-op that logs a warning; use `disableDeviceForCurrentUser()` to disable push for the current user. There is no public native "all users" equivalent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import com.iterable.iterableapi.IterableAuthManager;
import com.iterable.iterableapi.IterableConfig;
import com.iterable.iterableapi.IterableCustomActionHandler;
import com.iterable.iterableapi.IterableDecryptionFailureHandler;
import com.iterable.iterableapi.IterableEmbeddedMessage;
import com.iterable.iterableapi.IterableEmbeddedUpdateHandler;
import com.iterable.iterableapi.IterableHelper;
Expand All @@ -53,7 +54,7 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler {
public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableDecryptionFailureHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler {
public static final String NAME = "RNIterableAPI";

private static String TAG = "RNIterableAPIModule";
Expand Down Expand Up @@ -93,6 +94,10 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S
configBuilder.setAuthHandler(this);
}

if (configReadableMap.hasKey("decryptionFailureHandlerPresent") && configReadableMap.getBoolean("decryptionFailureHandlerPresent") == true) {
configBuilder.setDecryptionFailureHandler(this);
}

// Check if embedded messaging is enabled before building config
boolean enableEmbeddedMessaging = configReadableMap.hasKey("enableEmbeddedMessaging") && configReadableMap.getBoolean("enableEmbeddedMessaging");

Expand Down Expand Up @@ -161,6 +166,10 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap,
configBuilder.setAuthHandler(this);
}

if (configReadableMap.hasKey("decryptionFailureHandlerPresent") && configReadableMap.getBoolean("decryptionFailureHandlerPresent") == true) {
configBuilder.setDecryptionFailureHandler(this);
}

// NOTE: There does not seem to be a way to set the API endpoint
// override in the Android SDK. Check with @Ayyanchira and @evantk91 to
// see what the best approach is.
Expand Down Expand Up @@ -663,6 +672,24 @@ public String onAuthTokenRequested() {
}
}

private static final String DECRYPTION_FAILURE_DEFAULT_MESSAGE = "Decryption failed";

@Override
public void onDecryptionFailed(Exception exception) {
JSONObject messageJson = new JSONObject();
try {
String message = exception != null ? exception.getMessage() : null;
if (message == null || message.isEmpty()) {
message = DECRYPTION_FAILURE_DEFAULT_MESSAGE;
}
messageJson.put("message", message);
WritableMap eventData = Serialization.convertJsonToMap(messageJson);
sendEvent(EventName.handleDecryptionFailureCalled.name(), eventData);
} catch (JSONException e) {
IterableLogger.e(TAG, "Failed to send decryption failure event");
}
}

@Override
public void onAuthFailure(AuthFailure authFailure) {
// Create a JSON object for the authFailure object
Expand Down Expand Up @@ -815,6 +842,7 @@ enum EventName {
handleAuthFailureCalled,
handleAuthSuccessCalled,
handleCustomActionCalled,
handleDecryptionFailureCalled,
handleEmbeddedMessageUpdateCalled,
handleEmbeddedMessagingDisabledCalled,
handleInAppCalled,
Expand Down
2 changes: 2 additions & 0 deletions ios/RNIterableAPI/ReactIterableAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import React
case handleAuthFailureCalled
case handleEmbeddedMessageUpdateCalled
case handleEmbeddedMessagingDisabledCalled
// Android-only native API; listed so JS addListener does not warn on iOS.
case handleDecryptionFailureCalled
}

@objc public static var supportedEvents: [String] {
Expand Down
2 changes: 2 additions & 0 deletions src/__mocks__/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const mockNativeEventEmitterConstructor = jest.fn().mockImplementation(() => ({
eventType: string,
listener: (...args: unknown[]) => void
) => mockNativeEventEmitter.removeListener(eventType, listener),
listenerCount: (eventType: string) =>
mockNativeEventEmitter.listenerCount(eventType),
}));

jest.mock(
Expand Down
73 changes: 73 additions & 0 deletions src/core/classes/Iterable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ describe('Iterable', () => {
nativeEmitter.removeAllListeners(
IterableEventName.handleEmbeddedMessagingDisabledCalled
);
nativeEmitter.removeAllListeners(
IterableEventName.handleDecryptionFailureCalled
);

// Clear any pending timers
jest.clearAllTimers();
Expand Down Expand Up @@ -1271,6 +1274,76 @@ describe('Iterable', () => {
});
});

describe('decryptionFailureHandler', () => {
it('should call decryptionFailureHandler when handleDecryptionFailureCalled event is emitted', () => {
const nativeEmitter = new NativeEventEmitter();
nativeEmitter.removeAllListeners(
IterableEventName.handleDecryptionFailureCalled
);
const config = new IterableConfig();
config.logReactNativeSdkCalls = false;
config.decryptionFailureHandler = jest.fn();
Iterable.initialize('apiKey', config);
nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, {
message: 'Keychain decrypt error',
});
expect(config.decryptionFailureHandler).toHaveBeenCalledWith({
message: 'Keychain decrypt error',
});
expect(config.decryptionFailureHandler).toHaveBeenCalledTimes(1);
});

it('should use a generic message when the event payload message is empty', () => {
const nativeEmitter = new NativeEventEmitter();
nativeEmitter.removeAllListeners(
IterableEventName.handleDecryptionFailureCalled
);
const config = new IterableConfig();
config.logReactNativeSdkCalls = false;
config.decryptionFailureHandler = jest.fn();
Iterable.initialize('apiKey', config);
nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, {
message: ' ',
});
expect(config.decryptionFailureHandler).toHaveBeenCalledWith({
message: 'Decryption failed',
});
});

it('should not set up listener if decryptionFailureHandler is not provided', () => {
const nativeEmitter = new NativeEventEmitter();
nativeEmitter.removeAllListeners(
IterableEventName.handleDecryptionFailureCalled
);
const config = new IterableConfig();
config.logReactNativeSdkCalls = false;
Iterable.initialize('apiKey', config);
expect(
nativeEmitter.listenerCount(
IterableEventName.handleDecryptionFailureCalled
)
).toBe(0);
expect(() => {
nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, {
message: 'ignored',
});
}).not.toThrow();
});

it('should include decryptionFailureHandlerPresent flag in config dict when callback is provided', () => {
const config = new IterableConfig();
config.decryptionFailureHandler = jest.fn();
const configDict = config.toDict();
expect(configDict.decryptionFailureHandlerPresent).toBe(true);
});

it('should set decryptionFailureHandlerPresent flag to false when callback is not provided', () => {
const config = new IterableConfig();
const configDict = config.toDict();
expect(configDict.decryptionFailureHandlerPresent).toBe(false);
});
});

describe('embedded messaging callbacks', () => {
describe('onEmbeddedMessageUpdate', () => {
it('should call onEmbeddedMessageUpdate when handleEmbeddedMessageUpdateCalled event is emitted', () => {
Expand Down
18 changes: 18 additions & 0 deletions src/core/classes/Iterable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation';
import { IterableAuthResponseResult } from '../enums/IterableAuthResponseResult';
import { IterableEventName } from '../enums/IterableEventName';
import type { IterableAuthFailure } from '../types/IterableAuthFailure';
import type { IterableDecryptionFailure } from '../types/IterableDecryptionFailure';
import { callUrlHandler } from '../utils/callUrlHandler';
import { IterableAction } from './IterableAction';
import { IterableActionContext } from './IterableActionContext';
Expand Down Expand Up @@ -1018,6 +1019,9 @@ export class Iterable {
RNEventEmitter.removeAllListeners(
IterableEventName.handleEmbeddedMessagingDisabledCalled
);
RNEventEmitter.removeAllListeners(
IterableEventName.handleDecryptionFailureCalled
);
}

/**
Expand Down Expand Up @@ -1310,6 +1314,20 @@ export class Iterable {
);
}
}

if (Iterable.savedConfig.decryptionFailureHandler) {
RNEventEmitter.addListener(
IterableEventName.handleDecryptionFailureCalled,
(payload: IterableDecryptionFailure) => {
const rawMessage = payload?.message?.trim();
const message =
rawMessage && rawMessage.length > 0
? rawMessage
: 'Decryption failed';
Iterable.savedConfig.decryptionFailureHandler?.({ message });
}
);
}
}

/**
Expand Down
26 changes: 26 additions & 0 deletions src/core/classes/IterableConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IterableDataRegion } from '../enums/IterableDataRegion';
import { IterableLogLevel } from '../enums/IterableLogLevel';
import { IterablePushPlatform } from '../enums/IterablePushPlatform';
import type { IterableAuthFailure } from '../types/IterableAuthFailure';
import type { IterableDecryptionFailure } from '../types/IterableDecryptionFailure';
import type { IterableRetryPolicy } from '../types/IterableRetryPolicy';
import { IterableAction } from './IterableAction';
import type { IterableActionContext } from './IterableActionContext';
Expand Down Expand Up @@ -225,6 +226,29 @@ export class IterableConfig {
*/
onJwtError?: (authFailure: IterableAuthFailure) => void;

/**
* A callback invoked when the Android SDK fails to decrypt PII in keychain
* storage. Before calling this handler, the native SDK clears stored PII,
* disables encryption for the device, and requires the user to sign in again.
*
* **Android only.** iOS does not surface decryption failures this way; setting
* this callback on iOS has no effect.
*
* @param failure - Details about the decryption failure.
*
* @example
* ```typescript
* const config = new IterableConfig();
* config.decryptionFailureHandler = (failure) => {
* console.error('Iterable decryption failed:', failure.message);
* // Prompt the user to log in again
* };
* ```
*/
decryptionFailureHandler?: (
failure: IterableDecryptionFailure
) => void;

/**
* Set the verbosity of Android and iOS project's log system.
*
Expand Down Expand Up @@ -463,6 +487,8 @@ export class IterableConfig {
* A boolean indicating if an authentication handler is present.
*/
authHandlerPresent: this.authHandler !== undefined,
decryptionFailureHandlerPresent:
this.decryptionFailureHandler !== undefined,
/**
* A boolean indicating if an embedded message update callback is present.
*/
Expand Down
4 changes: 4 additions & 0 deletions src/core/enums/IterableEventName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ export enum IterableEventName {
handleEmbeddedMessageUpdateCalled = 'handleEmbeddedMessageUpdateCalled',
/** Event that fires when embedded messaging is disabled */
handleEmbeddedMessagingDisabledCalled = 'handleEmbeddedMessagingDisabledCalled',
/**
* Event that fires when Android keychain decryption fails (Android only).
*/
handleDecryptionFailureCalled = 'handleDecryptionFailureCalled',
}
7 changes: 7 additions & 0 deletions src/core/types/IterableDecryptionFailure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Payload for Android keychain decryption failure notifications.
*/
export interface IterableDecryptionFailure {
/** Human-readable description of the decryption failure */
message?: string;
}
1 change: 1 addition & 0 deletions src/core/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './IterableAuthFailure';
export * from './IterableDecryptionFailure';
export * from './IterableEdgeInsetDetails';
export * from './IterableRetryPolicy';
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
} from './core/hooks';
export type {
IterableAuthFailure,
IterableDecryptionFailure,
IterableEdgeInsetDetails,
IterableRetryPolicy,
} from './core/types';
Expand Down
Loading