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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# @exostack/carillon-react-native

Carillon SDK for React Native, using the native [Swift][swift] and [Kotlin][kotlin]
SDKs. Requires a native build; Expo Go cannot load this module.
SDKs. Requires a native build for push notifications. In Expo Go, the SDK is inactive and logs one warning when imported.

## Install

Expand Down Expand Up @@ -153,6 +153,13 @@ plugin.

## Expo setup

Expo Go can import the SDK without crashing. Calls have no native or network effects,
and listeners never emit events. Permission methods return `undetermined`,
`canRequestPermission()` returns `false`, and `getDeviceId()` returns `null`.
`debugInfo()` returns `{ available: false, reason: 'expo_go', device_id: null }`.
Tag argument validation still applies. Use a development build to test notifications.
A missing native module outside Expo Go remains an installation error.

Add the plugin to your Expo configuration:

```json
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@exostack/carillon-react-native",
"version": "0.4.0",
"version": "0.4.1",
"description": "React Native bridge over the Carillon native SDKs.",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion src/NativeCarillon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,4 @@ export interface Spec extends TurboModule {
readonly onOpened: CodegenTypes.EventEmitter<CodegenTypes.UnsafeObject>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('Carillon');
export default TurboModuleRegistry.get<Spec>('Carillon');
101 changes: 101 additions & 0 deletions src/__tests__/expo-go.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';

const runtime = globalThis as typeof globalThis & {
expo?: { modules?: { ExpoGo?: unknown } };
};
const originalExpo = runtime.expo;
const get = jest.fn<() => unknown>();
const nativeModules: { ExponentConstants?: { appOwnership: string } } = {};
let warning: ReturnType<typeof jest.spyOn>;

function load() {
return (require('../index') as typeof import('../index')).default;
}

beforeEach(() => {
jest.resetModules();
get.mockReset().mockReturnValue(null);
delete runtime.expo;
delete nativeModules.ExponentConstants;
jest.doMock('react-native', () => ({
TurboModuleRegistry: { get },
NativeModules: nativeModules,
}));
warning = jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warning.mockRestore();
runtime.expo = originalExpo;
});

describe('Expo Go', () => {
it('imports and safely handles the public API without a native module', async () => {
runtime.expo = { modules: { ExpoGo: {} } };
const sdk = load();
sdk.configure({ key: 'test' });
sdk.configure({ key: 'test' });
sdk.identify('user');
sdk.identify(null);
sdk.setTags({ plan: 'pro' });
sdk.setTag('plan', 'pro');
sdk.setTagNumber('score', 1.5);
sdk.setTagBoolean('subscribed', false);
sdk.setTagDate('expires', new Date('2026-10-01T00:00:00Z'));
sdk.removeTag('plan');
sdk.removeTagNumber('score');
sdk.removeTagBoolean('subscribed');
sdk.removeTagDate('expires');
sdk.optIn();
sdk.optOut();
sdk.openNotificationSettings();
sdk.clearNotifications();
sdk.didOpen({});
sdk.didReceive({});
sdk.didRotateToken('token');
const handler = jest.fn(() => 'show' as const);
const offOpened = sdk.onOpened(handler);
const offDevice = sdk.onDeviceIdChanged(handler);
const offReceived = sdk.onReceived(handler);
sdk.onReceived(handler)();
offOpened();
offDevice();
offReceived();
expect(handler).not.toHaveBeenCalled();
await expect(sdk.requestPermission()).resolves.toBe('undetermined');
await expect(sdk.getPermission()).resolves.toBe('undetermined');
await expect(sdk.canRequestPermission()).resolves.toBe(false);
await expect(sdk.getDeviceId()).resolves.toBeNull();
await expect(sdk.debugInfo()).resolves.toEqual({
available: false, reason: 'expo_go', device_id: null,
});
expect(load()).toBe(sdk);
expect(warning).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledWith(expect.stringContaining('Expo Go'));
expect(() => sdk.setTagNumber('score', NaN)).toThrow();
});

it('recognizes legacy Expo Go ownership', () => {
nativeModules.ExponentConstants = { appOwnership: 'expo' };
expect(() => load()).not.toThrow();
expect(warning).toHaveBeenCalledTimes(1);
});

it.each(['standalone', 'guest'])('rejects a missing module in %s builds', (appOwnership) => {
nativeModules.ExponentConstants = { appOwnership };
expect(() => load()).toThrow('Native module is missing');
expect(warning).not.toHaveBeenCalled();
});

it('rejects a missing module in bare React Native', () => {
expect(() => load()).toThrow('Native module is missing');
});

it('uses the real native module when present', () => {
const configure = jest.fn();
get.mockReturnValue({ configure });
load().configure({ key: 'mobile-key' });
expect(configure).toHaveBeenCalledWith('mobile-key', undefined, undefined);
expect(warning).not.toHaveBeenCalled();
});
});
61 changes: 61 additions & 0 deletions src/carillon-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NativeModules } from 'react-native';

import NativeCarillon, { type Spec } from './NativeCarillon';

const expo = (globalThis as typeof globalThis & {
expo?: { modules?: { ExpoGo?: unknown } };
}).expo;

// Expo Go exposes ExpoGo through JSI; older runtimes expose appOwnership.
const isExpoGo =
expo?.modules?.ExpoGo != null ||
NativeModules.ExponentConstants?.appOwnership === 'expo';

function resolveModule(): Spec {
if (NativeCarillon) return NativeCarillon;
if (!isExpoGo) {
throw new Error(
'[Carillon] Native module is missing. Complete the native setup and rebuild the app.'
);
}

console.warn(
'[Carillon] Expo Go detected: the SDK is inactive. Registration, notifications and tag updates are disabled. Use a development build to enable Carillon.'
);

const noop = () => {};
const subscribe = () => ({ remove: noop });
return {
configure: noop,
requestPermission: async () => 'undetermined',
getPermission: async () => 'undetermined',
canRequestPermission: async () => false,
openNotificationSettings: noop,
didOpen: noop,
didReceive: noop,
didRotateToken: noop,
identify: noop,
clearIdentity: noop,
setTags: noop,
setTagNumber: noop,
setTagBoolean: noop,
setTagDate: noop,
removeTagNumber: noop,
removeTagBoolean: noop,
removeTagDate: noop,
optIn: noop,
optOut: noop,
debugInfo: async () => ({ available: false, reason: 'expo_go', device_id: null }),
startObservingOpens: noop,
startObservingDeviceId: noop,
startObservingReceived: noop,
stopObservingReceived: noop,
finishReceived: noop,
clearNotifications: noop,
onReceived: subscribe,
onDeviceIdChanged: subscribe,
onOpened: subscribe,
};
}

export default resolveModule();
2 changes: 1 addition & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import NativeCarillon from './NativeCarillon';
import NativeCarillon from './carillon-module';

/**
* Carillon React Native SDK. Uses the native Swift and Kotlin SDKs for
Expand Down
Loading