diff --git a/README.md b/README.md index 2c91bbb..54da98c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/package.json b/package.json index ec5a5f5..45619e2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/NativeCarillon.ts b/src/NativeCarillon.ts index 039dc10..5787983 100644 --- a/src/NativeCarillon.ts +++ b/src/NativeCarillon.ts @@ -65,4 +65,4 @@ export interface Spec extends TurboModule { readonly onOpened: CodegenTypes.EventEmitter; } -export default TurboModuleRegistry.getEnforcing('Carillon'); +export default TurboModuleRegistry.get('Carillon'); diff --git a/src/__tests__/expo-go.test.ts b/src/__tests__/expo-go.test.ts new file mode 100644 index 0000000..2d95818 --- /dev/null +++ b/src/__tests__/expo-go.test.ts @@ -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; + +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(); + }); +}); diff --git a/src/carillon-module.ts b/src/carillon-module.ts new file mode 100644 index 0000000..a24e971 --- /dev/null +++ b/src/carillon-module.ts @@ -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(); diff --git a/src/index.tsx b/src/index.tsx index 82688cd..453348e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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