diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 2edc6402..6222c878 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -5,6 +5,7 @@ const createEventEmitterMock = () => vi.fn(() => ({ remove: vi.fn() })); const mockRNOneSignal: Record = { initialize: vi.fn(), login: vi.fn(), + loginWithProfile: vi.fn(), logout: vi.fn(), setPrivacyConsentRequired: vi.fn(), setPrivacyConsentGiven: vi.fn(), diff --git a/android/build.gradle b/android/build.gradle index cc2c0edf..53b98410 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -17,7 +17,7 @@ def safeFlagGet(envProp, gradleProp) { return normalizedValue != null && (normalizedValue.equalsIgnoreCase('true') || normalizedValue == '1') } -def oneSignalVersion = '5.10.2' +def oneSignalVersion = '5.11.0' def oneSignalDisableLocation = safeFlagGet('ONESIGNAL_DISABLE_LOCATION', 'onesignal.disableLocation') android { diff --git a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java index ec28e64a..32674451 100644 --- a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java +++ b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java @@ -43,9 +43,12 @@ of this software and associated documentation files (the "Software"), to deal import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.onesignal.Continue; +import com.onesignal.LoginData; import com.onesignal.OneSignal; +import com.onesignal.OneSignalResult; import com.onesignal.common.OneSignalWrapper; import com.onesignal.debug.LogLevel; import com.onesignal.debug.internal.logging.Logging; @@ -70,6 +73,12 @@ of this software and associated documentation files (the "Software"), to deal import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import kotlin.Result; +import kotlin.coroutines.Continuation; +import kotlin.coroutines.CoroutineContext; +import kotlin.coroutines.EmptyCoroutineContext; +import kotlin.coroutines.intrinsics.IntrinsicsKt; import org.json.JSONException; public class RNOneSignal extends NativeOneSignalSpec @@ -605,6 +614,77 @@ public void login(String externalUserId) { OneSignal.login(externalUserId); } + @Override + public void loginWithProfile(String externalId, ReadableMap profile, @Nullable String jwt, Promise promise) { + AtomicBoolean settled = new AtomicBoolean(false); + Continuation> continuation = new Continuation>() { + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(Object result) { + settleLoginWithProfile(promise, settled, result); + } + }; + + Object returned; + try { + returned = OneSignal.login(externalId, RNUtils.convertReadableMapToUserProfile(profile), jwt, continuation); + } catch (Throwable t) { + settleLoginWithProfile(promise, settled, new Result.Failure(t)); + return; + } + // A non-suspending call returns its result here and never invokes resumeWith. + if (returned != IntrinsicsKt.getCOROUTINE_SUSPENDED()) { + settleLoginWithProfile(promise, settled, returned); + } + } + + private static void settleLoginWithProfile(Promise promise, AtomicBoolean settled, Object result) { + if (!settled.compareAndSet(false, true)) return; + + WritableMap payload; + try { + if (result instanceof OneSignalResult) { + payload = RNUtils.convertMapToWritableMap(((OneSignalResult) result).toMap()); + } else { + Throwable error = result instanceof Result.Failure ? ((Result.Failure) result).exception : null; + if (error != null) Logging.error("loginWithProfile failed", error); + payload = loginWithProfileFailure( + error != null ? describe(error) : "Unexpected loginWithProfile result"); + } + } catch (Throwable t) { + Logging.error("Failed to serialize loginWithProfile result", t); + payload = loginWithProfileFailure(describe(t)); + } + promise.resolve(payload); + } + + private static String describe(Throwable t) { + return t.getMessage() != null ? t.getMessage() : t.toString(); + } + + private static WritableMap loginWithProfileFailure(String message) { + WritableMap error = Arguments.createMap(); + error.putString("code", "UNKNOWN"); + error.putString("source", "CLIENT"); + error.putNull("backendCode"); + error.putNull("httpStatus"); + error.putString("message", message); + error.putNull("meta"); + + WritableArray errors = Arguments.createArray(); + errors.pushMap(error); + + WritableMap payload = Arguments.createMap(); + payload.putBoolean("success", false); + payload.putNull("data"); + payload.putArray("error", errors); + return payload; + } + @Override public void logout() { OneSignal.logout(); diff --git a/android/src/main/java/com/onesignal/rnonesignalandroid/RNUtils.java b/android/src/main/java/com/onesignal/rnonesignalandroid/RNUtils.java index d2027583..df5c9828 100644 --- a/android/src/main/java/com/onesignal/rnonesignalandroid/RNUtils.java +++ b/android/src/main/java/com/onesignal/rnonesignalandroid/RNUtils.java @@ -7,6 +7,7 @@ import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; +import com.onesignal.OneSignalUserProfile; import com.onesignal.debug.internal.logging.Logging; import com.onesignal.inAppMessages.IInAppMessage; import com.onesignal.inAppMessages.IInAppMessageClickEvent; @@ -291,6 +292,84 @@ public static HashMap convertReadableMapIntoStringMap(ReadableMa return stringMap; } + public static OneSignalUserProfile convertReadableMapToUserProfile(ReadableMap profile) { + String email = null; + String sms = null; + Map tags = new HashMap<>(); + Map aliases = new HashMap<>(); + if (profile != null) { + if (profile.hasKey("email") && profile.getType("email") == ReadableType.String) { + email = profile.getString("email"); + } + if (profile.hasKey("sms") && profile.getType("sms") == ReadableType.String) { + sms = profile.getString("sms"); + } + if (profile.hasKey("tags") && profile.getType("tags") == ReadableType.Map) { + tags = convertReadableMapIntoStringMap(profile.getMap("tags")); + } + if (profile.hasKey("aliases") && profile.getType("aliases") == ReadableType.Map) { + aliases = convertReadableMapIntoStringMap(profile.getMap("aliases")); + } + } + return OneSignalUserProfile.builder() + .setEmail(email) + .setSms(sms) + .setTags(tags) + .setAliases(aliases) + .build(); + } + + public static WritableMap convertMapToWritableMap(Map map) { + WritableMap writableMap = Arguments.createMap(); + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object value = entry.getValue(); + + if (value == null) { + writableMap.putNull(key); + } else if (value instanceof String) { + writableMap.putString(key, (String) value); + } else if (value instanceof Boolean) { + writableMap.putBoolean(key, (Boolean) value); + } else if (value instanceof Integer) { + writableMap.putInt(key, (Integer) value); + } else if (value instanceof Number) { + writableMap.putDouble(key, ((Number) value).doubleValue()); + } else if (value instanceof Map) { + writableMap.putMap(key, convertMapToWritableMap((Map) value)); + } else if (value instanceof Iterable) { + writableMap.putArray(key, convertIterableToWritableArray((Iterable) value)); + } else { + writableMap.putString(key, String.valueOf(value)); + } + } + return writableMap; + } + + private static WritableArray convertIterableToWritableArray(Iterable items) { + WritableArray writableArray = Arguments.createArray(); + for (Object item : items) { + if (item == null) { + writableArray.pushNull(); + } else if (item instanceof String) { + writableArray.pushString((String) item); + } else if (item instanceof Boolean) { + writableArray.pushBoolean((Boolean) item); + } else if (item instanceof Integer) { + writableArray.pushInt((Integer) item); + } else if (item instanceof Number) { + writableArray.pushDouble(((Number) item).doubleValue()); + } else if (item instanceof Map) { + writableArray.pushMap(convertMapToWritableMap((Map) item)); + } else if (item instanceof Iterable) { + writableArray.pushArray(convertIterableToWritableArray((Iterable) item)); + } else { + writableArray.pushString(String.valueOf(item)); + } + } + return writableArray; + } + public static HashMap convertPermissionToMap(boolean granted) { HashMap hash = new HashMap<>(); diff --git a/examples/demo/src/components/modals/LoginWithProfileModal.tsx b/examples/demo/src/components/modals/LoginWithProfileModal.tsx new file mode 100644 index 00000000..0d296a4a --- /dev/null +++ b/examples/demo/src/components/modals/LoginWithProfileModal.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { + Modal, + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, +} from 'react-native'; + +import { AppColors, AppDialogStyles, AppInputProps } from '../../theme'; + +interface Props { + visible: boolean; + onConfirm: (externalUserId: string, email: string, sms: string, jwt: string) => void; + onClose: () => void; +} + +export default function LoginWithProfileModal({ visible, onConfirm, onClose }: Props) { + const [externalUserId, setExternalUserId] = useState(''); + const [email, setEmail] = useState(''); + const [sms, setSms] = useState(''); + const [jwt, setJwt] = useState(''); + + const canSubmit = !!externalUserId.trim(); + + const handleConfirm = () => { + if (!canSubmit) { + return; + } + onConfirm(externalUserId.trim(), email.trim(), sms.trim(), jwt.trim()); + handleClose(); + }; + + const handleClose = () => { + setExternalUserId(''); + setEmail(''); + setSms(''); + setJwt(''); + onClose(); + }; + + return ( + + + + Login With Profile + + + + + + + Cancel + + + + Login + + + + + + + ); +} + +const styles = StyleSheet.create({ + inputSpacing: { + marginBottom: 12, + }, +}); diff --git a/examples/demo/src/components/sections/UserSection.tsx b/examples/demo/src/components/sections/UserSection.tsx index 474c9dfa..696008a4 100644 --- a/examples/demo/src/components/sections/UserSection.tsx +++ b/examples/demo/src/components/sections/UserSection.tsx @@ -1,25 +1,47 @@ import React, { useState } from 'react'; -import { View, Text, StyleSheet } from 'react-native'; +import { View, Text, StyleSheet, Platform } from 'react-native'; import { AppColors, AppTextStyles, AppTheme, AppSpacing } from '../../theme'; import ActionButton from '../ActionButton'; +import LoginWithProfileModal from '../modals/LoginWithProfileModal'; import SingleInputModal from '../modals/SingleInputModal'; import SectionCard from '../SectionCard'; interface Props { externalUserId: string | undefined; onLogin: (externalUserId: string) => Promise; + onLoginWithProfile: ( + externalUserId: string, + email: string, + sms: string, + jwt: string, + ) => Promise; onLogout: () => Promise; } -export default function UserSection({ externalUserId, onLogin, onLogout }: Props) { +export default function UserSection({ + externalUserId, + onLogin, + onLoginWithProfile, + onLogout, +}: Props) { const [loginOpen, setLoginOpen] = useState(false); + const [loginWithProfileOpen, setLoginWithProfileOpen] = useState(false); const isLoggedIn = !!externalUserId; const handleLogin = async (userId: string) => { await onLogin(userId); }; + const handleLoginWithProfile = async ( + userId: string, + email: string, + sms: string, + jwt: string, + ) => { + await onLoginWithProfile(userId, email, sms, jwt); + }; + const handleLogout = async () => { await onLogout(); }; @@ -49,6 +71,14 @@ export default function UserSection({ externalUserId, onLogin, onLogout }: Props onPress={() => setLoginOpen(true)} testID="login_user_button" /> + {Platform.OS === 'android' && ( + setLoginWithProfileOpen(true)} + variant="outlined" + testID="composite_login_button" + /> + )} {isLoggedIn && ( setLoginOpen(false)} testID="login_user_id_input" /> + setLoginWithProfileOpen(false)} + /> ); } diff --git a/examples/demo/src/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts index 06177ef6..c2c6a2f0 100644 --- a/examples/demo/src/hooks/useOneSignal.ts +++ b/examples/demo/src/hooks/useOneSignal.ts @@ -77,6 +77,12 @@ export type UseOneSignalReturn = { isLoading: boolean; isReady: boolean; loginUser: (externalUserId: string) => Promise; + loginUserWithProfile: ( + externalUserId: string, + email: string, + sms: string, + jwt: string, + ) => Promise; logoutUser: () => Promise; setConsentRequired: (required: boolean) => Promise; setConsentGiven: (granted: boolean) => Promise; @@ -133,6 +139,8 @@ function useOneSignalState(): UseOneSignalReturn { const requestSequenceRef = useRef(0); + const loginSequenceRef = useRef(0); + const fetchUserDataFromApi = useCallback(async () => { const requestId = requestSequenceRef.current + 1; requestSequenceRef.current = requestId; @@ -354,6 +362,7 @@ function useOneSignalState(): UseOneSignalReturn { }, [fetchUserDataFromApi]); const loginUser = async (nextExternalUserId: string) => { + loginSequenceRef.current++; requestSequenceRef.current++; setAliasesList([]); setEmailsList([]); @@ -375,7 +384,49 @@ function useOneSignalState(): UseOneSignalReturn { } }; + const loginUserWithProfile = async ( + nextExternalUserId: string, + email: string, + sms: string, + jwt: string, + ) => { + const loginId = ++loginSequenceRef.current; + requestSequenceRef.current++; + setAliasesList([]); + setEmailsList([]); + setSmsNumbersList([]); + setTagsList([]); + setTriggersList([]); + setIsLoading(true); + + const result = await OneSignal.loginWithProfile( + nextExternalUserId, + { email: email || undefined, sms: sms || undefined }, + jwt || null, + ); + // A logout or another login during the await owns the state now. + if (loginSequenceRef.current !== loginId) return; + if (!result.success) { + const details = (result.error ?? []).map((e) => `${e.code}: ${e.message}`).join(', '); + console.error(`Login with profile error: ${details}`); + setIsLoading(false); + return; + } + + try { + await preferences.setExternalUserId(nextExternalUserId); + setExternalUserId(nextExternalUserId); + console.log(`Logged in with profile as: ${nextExternalUserId}`); + // The user 'change' listener does not fire when the external id is unchanged. + await fetchUserDataFromApi(); + } catch (err) { + console.error(`Login with profile error: ${String(err)}`); + setIsLoading(false); + } + }; + const logoutUser = async () => { + loginSequenceRef.current++; requestSequenceRef.current++; setIsLoading(false); OneSignal.logout(); @@ -598,6 +649,7 @@ function useOneSignalState(): UseOneSignalReturn { isLoading, isReady, loginUser, + loginUserWithProfile, logoutUser, setConsentRequired, setConsentGiven, diff --git a/examples/demo/src/screens/HomeScreen.tsx b/examples/demo/src/screens/HomeScreen.tsx index 61b95856..0ef92f49 100644 --- a/examples/demo/src/screens/HomeScreen.tsx +++ b/examples/demo/src/screens/HomeScreen.tsx @@ -66,6 +66,7 @@ export default function HomeScreen() { diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm index 180c35eb..f464ff79 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm @@ -146,6 +146,26 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { [OneSignal login:externalId]; } +// Required by the shared TurboModule spec. There is no iOS native API yet. +RCT_EXPORT_METHOD(loginWithProfile : (NSString *)externalId + profile : (NSDictionary *)profile + jwt : (NSString *_Nullable)jwt + resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) { + resolve(@{ + @"success" : @NO, + @"data" : [NSNull null], + @"error" : @[ @{ + @"code" : @"UNKNOWN", + @"source" : @"CLIENT", + @"backendCode" : [NSNull null], + @"httpStatus" : [NSNull null], + @"message" : @"loginWithProfile is only supported on Android", + @"meta" : [NSNull null] + } ] + }); +} + RCT_EXPORT_METHOD(logout) { [OneSignal logout]; } RCT_EXPORT_METHOD(enterLiveActivity : (NSString *)activityId diff --git a/src/NativeOneSignal.ts b/src/NativeOneSignal.ts index 1c17ebb8..3c6716ac 100644 --- a/src/NativeOneSignal.ts +++ b/src/NativeOneSignal.ts @@ -7,6 +7,7 @@ export interface Spec extends TurboModule { // OneSignal root initialize(appId: string): void; login(externalId: string): void; + loginWithProfile(externalId: string, profile: Object, jwt: string | null): Promise; logout(): void; setPrivacyConsentRequired(required: boolean): void; setPrivacyConsentGiven(granted: boolean): void; diff --git a/src/index.test.ts b/src/index.test.ts index d4354e79..a3127f31 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -207,6 +207,136 @@ describe('OneSignal', () => { }); }); + describe('loginWithProfile', () => { + const SUCCESS = { + success: true, + data: { onesignalId: 'os-id', externalId: 'external-123', emailSubscriptionId: 'email-sub' }, + error: null, + }; + const failure = (code: string, message: string) => ({ + success: false, + data: null, + error: [{ code, source: 'CLIENT', backendCode: null, httpStatus: null, message, meta: null }], + }); + + beforeEach(() => { + mockPlatform.OS = 'android'; + }); + + test('marshals arguments and parses the native result', async () => { + mockRNOneSignal.loginWithProfile.mockResolvedValue(SUCCESS); + const result = await OneSignal.loginWithProfile( + 'external-123', + { + email: 'a@b.co', + sms: '+15555550100', + tags: { level: 5 as unknown as string }, + aliases: { crm: 'c-1' }, + }, + 'jwt-token', + ); + expect(mockRNOneSignal.loginWithProfile).toHaveBeenCalledWith( + 'external-123', + { email: 'a@b.co', sms: '+15555550100', tags: { level: '5' }, aliases: { crm: 'c-1' } }, + 'jwt-token', + ); + expect(result).toEqual(SUCCESS); + }); + + test('drops absent fields and sends a null jwt when omitted', async () => { + mockRNOneSignal.loginWithProfile.mockResolvedValue(SUCCESS); + await OneSignal.loginWithProfile('external-123', { email: undefined, sms: null as never }); + expect(mockRNOneSignal.loginWithProfile).toHaveBeenCalledWith('external-123', {}, null); + }); + + test('sends a null jwt when empty', async () => { + mockRNOneSignal.loginWithProfile.mockResolvedValue(SUCCESS); + await OneSignal.loginWithProfile('external-123', {}, ''); + expect(mockRNOneSignal.loginWithProfile).toHaveBeenCalledWith('external-123', {}, null); + }); + + test('returns a failure for a malformed native result', async () => { + mockRNOneSignal.loginWithProfile.mockResolvedValue({ success: 'yes' }); + const result = await OneSignal.loginWithProfile('external-123', {}); + expect(result).toEqual(failure('UNKNOWN', 'Malformed loginWithProfile result')); + }); + + test.each([ + ['an Error', new Error('bridge down'), 'bridge down'], + ['a non-Error', 'bridge down', 'bridge down'], + ])('resolves a failure when native rejects with %s', async (_label, reason, message) => { + mockRNOneSignal.loginWithProfile.mockRejectedValue(reason); + const result = await OneSignal.loginWithProfile('external-123', {}); + expect(result).toEqual(failure('UNKNOWN', message)); + }); + + test('returns the unsupported failure on iOS without calling native', async () => { + mockPlatform.OS = 'ios'; + const result = await OneSignal.loginWithProfile('external-123', { email: 'a@b.co' }); + expect(mockRNOneSignal.loginWithProfile).not.toHaveBeenCalled(); + expect(result).toEqual(failure('UNKNOWN', 'loginWithProfile is only supported on Android')); + }); + + test('returns a failure if native module is not loaded', async () => { + isNativeLoadedSpy.mockReturnValue(false); + const result = await OneSignal.loginWithProfile('external-123', {}); + expect(mockRNOneSignal.loginWithProfile).not.toHaveBeenCalled(); + expect(result).toEqual(failure('UNKNOWN', 'OneSignal native module not loaded')); + }); + + test.each([ + ['null', null], + ['empty', ''], + ])('rejects a %s externalId as INVALID_ARGUMENT', async (_label, externalId) => { + const result = await OneSignal.loginWithProfile(externalId as string, {}); + expect(mockRNOneSignal.loginWithProfile).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] loginWithProfile: externalId is required'); + expect(result).toEqual(failure('INVALID_ARGUMENT', 'externalId is required')); + }); + + test.each([ + ['a non-object profile', null, '[OneSignal] loginWithProfile: profile must be an object'], + ['a non-string email', { email: 5 }, '[OneSignal] loginWithProfile: email must be a string'], + ['a non-string sms', { sms: true }, '[OneSignal] loginWithProfile: sms must be a string'], + [ + 'non-object tags', + { tags: 'x' }, + '[OneSignal] loginWithProfile: tags: argument must be an object', + ], + [ + 'a null tag value', + { tags: { a: null } }, + '[OneSignal] loginWithProfile: tags: value is required', + ], + [ + 'an empty alias id', + { aliases: { crm: '' } }, + '[OneSignal] loginWithProfile: aliases: value is required', + ], + ])('rejects %s as INVALID_ARGUMENT', async (_label, profile, message) => { + const result = await OneSignal.loginWithProfile('external-123', profile as never); + expect(mockRNOneSignal.loginWithProfile).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(message); + expect(result).toEqual(failure('INVALID_ARGUMENT', 'profile is invalid')); + }); + + test('resolves INVALID_ARGUMENT when a tag value cannot be stringified', async () => { + const profile = { tags: { a: Object.create(null) } }; + const result = await OneSignal.loginWithProfile('external-123', profile as never); + expect(mockRNOneSignal.loginWithProfile).not.toHaveBeenCalled(); + expect(result).toEqual(failure('INVALID_ARGUMENT', 'profile is invalid')); + }); + + test('rejects a non-string jwt as INVALID_ARGUMENT', async () => { + const result = await OneSignal.loginWithProfile('external-123', {}, 5 as never); + expect(mockRNOneSignal.loginWithProfile).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] loginWithProfile: jwtBearerToken must be a string', + ); + expect(result).toEqual(failure('INVALID_ARGUMENT', 'jwtBearerToken must be a string')); + }); + }); + describe('logout', () => { test('should logout', () => { OneSignal.logout(); diff --git a/src/index.ts b/src/index.ts index c057a2ec..66272be5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { isObjectSerializable, isValidCallback, } from './helpers'; +import { IOS_UNSUPPORTED_MESSAGE, loginFailure, parseLoginResult } from './loginResult'; import NativeOneSignal from './NativeOneSignal'; import type { InAppMessage, @@ -39,6 +40,7 @@ import type { InAppMessageWillDisplayEvent, } from './types/inAppMessage'; import type { LiveActivitySetupOptions } from './types/liveActivities'; +import type { LoginData, OneSignalResult, OneSignalUserProfile } from './types/login'; import type { NotificationClickEvent, NotificationListeners } from './types/notificationEvents'; import type { PushSubscriptionChangedState, PushSubscriptionState } from './types/subscription'; import type { UserChangedState, UserState } from './types/user'; @@ -87,6 +89,35 @@ function stringifyValues(values: Record): Record [key, String(value)])); } +function toLoginProfilePayload(profile: unknown): Record | null { + if (!isObject(profile, 'loginWithProfile: profile')) return null; + const payload: Record = {}; + + for (const field of ['email', 'sms'] as const) { + const value = profile[field]; + if (value == null) continue; + if (typeof value !== 'string') { + console.error(`[OneSignal] loginWithProfile: ${field} must be a string`); + return null; + } + payload[field] = value; + } + + const { tags, aliases } = profile as { + tags?: Record; + aliases?: Record; + }; + if (tags != null) { + if (hasMissingEntries(tags, 'loginWithProfile: tags', true)) return null; + payload.tags = stringifyValues(tags); + } + if (aliases != null) { + if (hasMissingEntries(aliases, 'loginWithProfile: aliases')) return null; + payload.aliases = aliases; + } + return payload; +} + let notificationPermission = false; let permissionObserverAdded = false; let subscriptionObserverAdded = false; @@ -157,6 +188,48 @@ export namespace OneSignal { RNOneSignal.login(externalId); } + /** + * Logs in and applies the profile in a single request, resolving with the outcome. Never rejects. + * Android only for now: on iOS this resolves with a failure result. + */ + export async function loginWithProfile( + externalId: string, + profile: OneSignalUserProfile, + jwtBearerToken?: string | null, + ): Promise> { + if (!isNativeModuleLoaded(RNOneSignal)) { + return loginFailure('OneSignal native module not loaded'); + } + if (Platform.OS !== 'android') return loginFailure(IOS_UNSUPPORTED_MESSAGE); + + if (isMissing(externalId, 'loginWithProfile: externalId')) { + return loginFailure('externalId is required', 'INVALID_ARGUMENT'); + } + let payload: Record | null; + try { + payload = toLoginProfilePayload(profile); + } catch (error) { + console.error('[OneSignal] loginWithProfile: profile could not be read', error); + payload = null; + } + if (!payload) return loginFailure('profile is invalid', 'INVALID_ARGUMENT'); + if (jwtBearerToken != null && typeof jwtBearerToken !== 'string') { + console.error('[OneSignal] loginWithProfile: jwtBearerToken must be a string'); + return loginFailure('jwtBearerToken must be a string', 'INVALID_ARGUMENT'); + } + + try { + const result = await RNOneSignal.loginWithProfile( + externalId, + payload, + jwtBearerToken || null, + ); + return parseLoginResult(result); + } catch (error) { + return loginFailure(error instanceof Error ? error.message : String(error)); + } + } + /** * Once (or if) the user is no longer identifiable in your app (i.e. they logged out), the logout method should be * called. @@ -1030,5 +1103,12 @@ export { export { default as OSNotification } from './OSNotification'; export type { InAppMessageClickResult } from './types/inAppMessage'; +export type { + LoginData, + OneSignalErrorCode, + OneSignalErrorDetail, + OneSignalResult, + OneSignalUserProfile, +} from './types/login'; export type { NotificationClickResult } from './types/notificationEvents'; export type { PushSubscriptionState } from './types/subscription'; diff --git a/src/loginResult.test.ts b/src/loginResult.test.ts new file mode 100644 index 00000000..5643c606 --- /dev/null +++ b/src/loginResult.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from 'vite-plus/test'; + +import { IOS_UNSUPPORTED_MESSAGE, loginFailure, parseLoginResult } from './loginResult'; + +const unknownFailure = (message: string) => ({ + success: false, + data: null, + error: [ + { + code: 'UNKNOWN', + source: 'CLIENT', + backendCode: null, + httpStatus: null, + message, + meta: null, + }, + ], +}); + +describe('loginFailure', () => { + test('defaults to an UNKNOWN client error', () => { + expect(loginFailure('boom')).toEqual(unknownFailure('boom')); + }); + + test('uses the given code', () => { + expect(loginFailure('bad', 'INVALID_ARGUMENT').error?.[0].code).toBe('INVALID_ARGUMENT'); + }); +}); + +describe('parseLoginResult', () => { + test('parses a success envelope', () => { + const result = parseLoginResult({ + success: true, + data: { + onesignalId: 'os-id', + externalId: 'ext-id', + emailSubscriptionId: 'email-sub', + smsSubscriptionId: 'sms-sub', + }, + error: null, + }); + expect(result).toEqual({ + success: true, + data: { + onesignalId: 'os-id', + externalId: 'ext-id', + emailSubscriptionId: 'email-sub', + smsSubscriptionId: 'sms-sub', + }, + error: null, + }); + }); + + test('omits optional subscription ids that are null or missing', () => { + const result = parseLoginResult({ + success: true, + data: { onesignalId: 'os-id', externalId: 'ext-id', emailSubscriptionId: null }, + }); + expect(result.data).toEqual({ onesignalId: 'os-id', externalId: 'ext-id' }); + }); + + test('parses a backend failure envelope', () => { + const result = parseLoginResult({ + success: false, + data: null, + error: [ + { + code: 'BACKEND_ERROR', + source: 'BACKEND', + backendCode: 'user-conflict', + httpStatus: 409, + message: 'Conflict', + meta: { field: 'email' }, + }, + ], + }); + expect(result).toEqual({ + success: false, + data: null, + error: [ + { + code: 'BACKEND_ERROR', + source: 'BACKEND', + backendCode: 'user-conflict', + httpStatus: 409, + message: 'Conflict', + meta: { field: 'email' }, + }, + ], + }); + }); + + test('treats missing keys in an error entry as null', () => { + const result = parseLoginResult({ + success: false, + error: [{ code: 'STORAGE_LOCKED', source: 'CLIENT', message: 'locked' }], + }); + expect(result.error).toEqual([ + { + code: 'STORAGE_LOCKED', + source: 'CLIENT', + backendCode: null, + httpStatus: null, + message: 'locked', + meta: null, + }, + ]); + }); + + test('parses the iOS envelope with nulls dropped by the bridge', () => { + const result = parseLoginResult({ + success: false, + error: [{ code: 'UNKNOWN', source: 'CLIENT', message: IOS_UNSUPPORTED_MESSAGE }], + }); + expect(result).toEqual(unknownFailure(IOS_UNSUPPORTED_MESSAGE)); + }); + + test('normalizes wrongly typed error fields', () => { + const result = parseLoginResult({ + success: false, + error: [ + { + code: 'NOT_A_CODE', + source: 'ELSEWHERE', + backendCode: 42, + httpStatus: 4.5, + message: 7, + meta: ['x'], + }, + 'not-an-object', + ], + }); + expect(result.error).toEqual([ + { + code: 'UNKNOWN', + source: 'CLIENT', + backendCode: null, + httpStatus: null, + message: '', + meta: null, + }, + unknownFailure('Malformed error entry').error[0], + ]); + }); + + test.each([ + ['null', null], + ['a string', 'success'], + ['an array', []], + ['a missing success flag', { data: null }], + ['a non-boolean success flag', { success: 'true' }], + [ + 'a success that carries errors', + { + success: true, + data: { onesignalId: 'os', externalId: 'ext' }, + error: [{ code: 'BACKEND_ERROR', source: 'BACKEND', message: 'x' }], + }, + ], + ])('returns UNKNOWN failure for %s', (_label, value) => { + expect(parseLoginResult(value)).toEqual(unknownFailure('Malformed loginWithProfile result')); + }); + + test.each([ + ['missing data', { success: true }], + ['non-object data', { success: true, data: 'x' }], + ['missing onesignalId', { success: true, data: { externalId: 'ext' } }], + ['empty externalId', { success: true, data: { onesignalId: 'os', externalId: '' } }], + ])('returns UNKNOWN failure for success with %s', (_label, value) => { + expect(parseLoginResult(value)).toEqual(unknownFailure('Malformed loginWithProfile data')); + }); + + test.each([ + ['missing error', { success: false }], + ['empty error list', { success: false, error: [] }], + ['non-array error', { success: false, error: { code: 'UNKNOWN' } }], + ])('returns UNKNOWN failure for failure with %s', (_label, value) => { + expect(parseLoginResult(value)).toEqual( + unknownFailure('loginWithProfile failed without error details'), + ); + }); +}); diff --git a/src/loginResult.ts b/src/loginResult.ts new file mode 100644 index 00000000..8941465e --- /dev/null +++ b/src/loginResult.ts @@ -0,0 +1,96 @@ +import type { + LoginData, + OneSignalErrorCode, + OneSignalErrorDetail, + OneSignalResult, +} from './types/login'; + +const ERROR_CODES: readonly OneSignalErrorCode[] = [ + 'BACKEND_ERROR', + 'INVALID_ARGUMENT', + 'NOT_INITIALIZED', + 'STORAGE_LOCKED', + 'UNKNOWN', +]; + +export const IOS_UNSUPPORTED_MESSAGE = 'loginWithProfile is only supported on Android'; + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +const CLIENT_ERROR: OneSignalErrorDetail = { + code: 'UNKNOWN', + source: 'CLIENT', + backendCode: null, + httpStatus: null, + message: '', + meta: null, +}; + +export function loginFailure( + message: string, + code: OneSignalErrorCode = 'UNKNOWN', +): OneSignalResult { + return { success: false, data: null, error: [{ ...CLIENT_ERROR, code, message }] }; +} + +function parseErrorDetail(value: unknown): OneSignalErrorDetail { + const raw = asRecord(value); + if (!raw) return { ...CLIENT_ERROR, message: 'Malformed error entry' }; + + const code = asString(raw.code); + return { + code: + code && ERROR_CODES.includes(code as OneSignalErrorCode) + ? (code as OneSignalErrorCode) + : 'UNKNOWN', + source: raw.source === 'BACKEND' ? 'BACKEND' : 'CLIENT', + backendCode: asString(raw.backendCode), + httpStatus: Number.isInteger(raw.httpStatus) ? (raw.httpStatus as number) : null, + message: asString(raw.message) ?? '', + meta: asRecord(raw.meta), + }; +} + +function parseLoginData(value: unknown): LoginData | null { + const raw = asRecord(value); + const onesignalId = asString(raw?.onesignalId); + const externalId = asString(raw?.externalId); + if (!raw || !onesignalId || !externalId) return null; + + const data: LoginData = { onesignalId, externalId }; + const emailSubscriptionId = asString(raw.emailSubscriptionId); + const smsSubscriptionId = asString(raw.smsSubscriptionId); + if (emailSubscriptionId) data.emailSubscriptionId = emailSubscriptionId; + if (smsSubscriptionId) data.smsSubscriptionId = smsSubscriptionId; + return data; +} + +// The iOS bridge drops null dictionary values, so absent keys are read as null. +export function parseLoginResult(value: unknown): OneSignalResult { + const raw = asRecord(value); + if (!raw || typeof raw.success !== 'boolean') { + return loginFailure('Malformed loginWithProfile result'); + } + + if (raw.success) { + if (Array.isArray(raw.error) && raw.error.length > 0) { + return loginFailure('Malformed loginWithProfile result'); + } + const data = parseLoginData(raw.data); + if (!data) return loginFailure('Malformed loginWithProfile data'); + return { success: true, data, error: null }; + } + + if (!Array.isArray(raw.error) || raw.error.length === 0) { + return loginFailure('loginWithProfile failed without error details'); + } + return { success: false, data: null, error: raw.error.map(parseErrorDetail) }; +} diff --git a/src/types/login.ts b/src/types/login.ts new file mode 100644 index 00000000..c3f4b1f5 --- /dev/null +++ b/src/types/login.ts @@ -0,0 +1,35 @@ +export interface OneSignalUserProfile { + email?: string; + sms?: string; + tags?: Record; + aliases?: Record; +} + +export type OneSignalErrorCode = + | 'BACKEND_ERROR' + | 'INVALID_ARGUMENT' + | 'NOT_INITIALIZED' + | 'STORAGE_LOCKED' + | 'UNKNOWN'; + +export interface OneSignalErrorDetail { + code: OneSignalErrorCode; + source: 'CLIENT' | 'BACKEND'; + backendCode: string | null; + httpStatus: number | null; + message: string; + meta: Record | null; +} + +export interface LoginData { + onesignalId: string; + externalId: string; + emailSubscriptionId?: string; + smsSubscriptionId?: string; +} + +export interface OneSignalResult { + success: boolean; + data: T | null; + error: OneSignalErrorDetail[] | null; +}