From 4cc7a13e4cd5e3dcd2a7003468b138adbe0fa842 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 21 Sep 2026 10:57:29 -0500 Subject: [PATCH 1/4] fix: [SDK-5304] ignore null login externalId instead of crashing Co-authored-by: Cursor --- .../java/com/onesignal/rnonesignalandroid/RNOneSignal.java | 4 ++++ ios/RCTOneSignal/RCTOneSignalEventEmitter.mm | 4 ++++ src/index.test.ts | 6 ++++++ src/index.ts | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java index a9b093de..0d191f4d 100644 --- a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java +++ b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java @@ -598,6 +598,10 @@ public void addOutcomeWithValue(String name, double value) { @Override public void login(String externalUserId) { + if (externalUserId == null) { + Logging.error("login called with a null externalUserId", null); + return; + } OneSignal.login(externalUserId); } diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm index 4273ac11..bb85d707 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm @@ -138,6 +138,10 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { } RCT_EXPORT_METHOD(login : (NSString *)externalId) { + if (externalId == nil) { + NSLog(@"OneSignal: login called with a nil externalId"); + return; + } [OneSignal login:externalId]; } diff --git a/src/index.test.ts b/src/index.test.ts index 99bc865f..e06ef73a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -180,6 +180,12 @@ describe('OneSignal', () => { OneSignal.login('external-123'); expect(mockRNOneSignal.login).not.toHaveBeenCalled(); }); + + test('should not login if externalId is null', () => { + OneSignal.login(null as unknown as string); + expect(mockRNOneSignal.login).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('OneSignal: login: externalId is required'); + }); }); describe('logout', () => { diff --git a/src/index.ts b/src/index.ts index 212299d1..b61bffed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,6 +121,10 @@ export namespace OneSignal { */ export function login(externalId: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (externalId == null) { + console.error('OneSignal: login: externalId is required'); + return; + } RNOneSignal.login(externalId); } From af84b8198d7827ec9f1a70c0137a0df8d295299e Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 21 Sep 2026 14:27:03 -0500 Subject: [PATCH 2/4] fix: [SDK-5304] reject null and empty identity strings Kotlin non-null String APIs crash when JS passes null. Empty login is invalid too. Guard both in isNonEmptyString and skip the native call. Co-authored-by: Cursor --- .../rnonesignalandroid/RNOneSignal.java | 4 +- ios/RCTOneSignal/RCTOneSignalEventEmitter.mm | 4 +- src/helpers.test.ts | 18 +++++++ src/helpers.ts | 4 ++ src/index.test.ts | 38 ++++++++++++-- src/index.ts | 50 +++++++++++++++++-- 6 files changed, 106 insertions(+), 12 deletions(-) diff --git a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java index 0d191f4d..9967e2d7 100644 --- a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java +++ b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java @@ -598,8 +598,8 @@ public void addOutcomeWithValue(String name, double value) { @Override public void login(String externalUserId) { - if (externalUserId == null) { - Logging.error("login called with a null externalUserId", null); + if (externalUserId == null || externalUserId.isEmpty()) { + Logging.error("login called with a null or empty externalUserId", null); return; } OneSignal.login(externalUserId); diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm index bb85d707..0fa86c94 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm @@ -138,8 +138,8 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { } RCT_EXPORT_METHOD(login : (NSString *)externalId) { - if (externalId == nil) { - NSLog(@"OneSignal: login called with a nil externalId"); + if (externalId == nil || [externalId length] == 0) { + NSLog(@"OneSignal: login called with a nil or empty externalId"); return; } [OneSignal login:externalId]; diff --git a/src/helpers.test.ts b/src/helpers.test.ts index 8d904ebf..24b6f7ae 100644 --- a/src/helpers.test.ts +++ b/src/helpers.test.ts @@ -5,6 +5,7 @@ import { IOS_NULL_SENTINEL } from './constants/internal'; import { encodeNullsForIOS, isNativeModuleLoaded, + isNonEmptyString, isObjectSerializable, isValidCallback, } from './helpers'; @@ -66,6 +67,23 @@ describe('helpers', () => { }); }); + describe('isNonEmptyString', () => { + test.each([ + { description: 'a non-empty string', value: 'id', expected: true }, + { description: 'a whitespace string', value: ' ', expected: true }, + { description: 'an empty string', value: '', expected: false }, + { description: 'null', value: null, expected: false }, + { description: 'undefined', value: undefined, expected: false }, + { description: 'a number', value: 1, expected: false }, + { description: 'a boolean', value: true, expected: false }, + ])( + 'should return $expected for $description', + ({ value, expected }: { description: string; value: unknown; expected: boolean }) => { + expect(isNonEmptyString(value)).toBe(expected); + }, + ); + }); + describe('isObjectSerializable', () => { test.each([ { description: 'an empty object', value: {} }, diff --git a/src/helpers.ts b/src/helpers.ts index ca6e0f41..76d28927 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -18,6 +18,10 @@ export function isNativeModuleLoaded(module: object | null | undefined): boolean return true; } +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + /** * Returns true if the value is a JSON-serializable object. */ diff --git a/src/index.test.ts b/src/index.test.ts index e06ef73a..67ee4f7b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -112,6 +112,18 @@ describe('OneSignal', () => { expect(mockRNOneSignal.initialize).not.toHaveBeenCalled(); }); + test('should not initialize if appId is null', () => { + OneSignal.initialize(null as unknown as string); + expect(mockRNOneSignal.initialize).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('OneSignal: initialize: appId is required'); + }); + + test('should not initialize if appId is empty', () => { + OneSignal.initialize(''); + expect(mockRNOneSignal.initialize).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('OneSignal: initialize: appId is required'); + }); + test('should keep a permission event that arrives before the startup read resolves', async () => { let resolveStartupRead: ((granted: boolean) => void) | undefined; vi.mocked(mockRNOneSignal.hasNotificationPermission).mockReturnValueOnce( @@ -186,6 +198,12 @@ describe('OneSignal', () => { expect(mockRNOneSignal.login).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledWith('OneSignal: login: externalId is required'); }); + + test('should not login if externalId is empty', () => { + OneSignal.login(''); + expect(mockRNOneSignal.login).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('OneSignal: login: externalId is required'); + }); }); describe('logout', () => { @@ -754,6 +772,18 @@ describe('OneSignal', () => { OneSignal.User.addEmail(EMAIL); expect(mockRNOneSignal.addEmail).not.toHaveBeenCalled(); }); + + test('should not add email if email is null', () => { + OneSignal.User.addEmail(null as unknown as string); + expect(mockRNOneSignal.addEmail).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('OneSignal: addEmail: email is required'); + }); + + test('should not add email if email is empty', () => { + OneSignal.User.addEmail(''); + expect(mockRNOneSignal.addEmail).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('OneSignal: addEmail: email is required'); + }); }); describe('removeEmail', () => { @@ -1312,16 +1342,16 @@ describe('OneSignal', () => { expect(mockRNOneSignal.addTrigger).toHaveBeenCalledWith('key', 'value'); }); - test('should log error but still call native method if key is missing', () => { + test('should not add trigger if key is missing', () => { OneSignal.InAppMessages.addTrigger('', 'value'); expect(errorSpy).toHaveBeenCalled(); - expect(mockRNOneSignal.addTrigger).toHaveBeenCalledWith('', 'value'); + expect(mockRNOneSignal.addTrigger).not.toHaveBeenCalled(); }); - test('should log error but still call native method if value is null', () => { + test('should not add trigger if value is null', () => { OneSignal.InAppMessages.addTrigger('key', null as unknown as string); expect(errorSpy).toHaveBeenCalled(); - expect(mockRNOneSignal.addTrigger).toHaveBeenCalledWith('key', null); + expect(mockRNOneSignal.addTrigger).not.toHaveBeenCalled(); }); test('should not add trigger if native module is not loaded', () => { diff --git a/src/index.ts b/src/index.ts index b61bffed..9263b6b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import NotificationWillDisplayEvent from './events/NotificationWillDisplayEvent' import { encodeNullsForIOS, isNativeModuleLoaded, + isNonEmptyString, isObjectSerializable, isValidCallback, } from './helpers'; @@ -107,6 +108,10 @@ export namespace OneSignal { /** Initializes the OneSignal SDK. This should be called during startup of the application. */ export function initialize(appId: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(appId)) { + console.error('OneSignal: initialize: appId is required'); + return; + } RNOneSignal.initialize(appId); @@ -121,7 +126,7 @@ export namespace OneSignal { */ export function login(externalId: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; - if (externalId == null) { + if (!isNonEmptyString(externalId)) { console.error('OneSignal: login: externalId is required'); return; } @@ -452,6 +457,10 @@ export namespace OneSignal { /** Explicitly set a 2-character language code for the user. */ export function setLanguage(language: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(language)) { + console.error('OneSignal: setLanguage: language is required'); + return; + } RNOneSignal.setLanguage(language); } @@ -459,6 +468,10 @@ export namespace OneSignal { /** Set an alias for the current user. If this alias label already exists on this user, it will be overwritten with the new alias id. */ export function addAlias(label: string, id: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(label) || !isNonEmptyString(id)) { + console.error('OneSignal: addAlias: must include a label and an id'); + return; + } RNOneSignal.addAlias(label, id); } @@ -473,6 +486,10 @@ export namespace OneSignal { /** Remove an alias from the current user. */ export function removeAlias(label: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(label)) { + console.error('OneSignal: removeAlias: label is required'); + return; + } RNOneSignal.removeAlias(label); } @@ -487,6 +504,10 @@ export namespace OneSignal { /** Add a new email subscription to the current user. */ export function addEmail(email: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(email)) { + console.error('OneSignal: addEmail: email is required'); + return; + } RNOneSignal.addEmail(email); } @@ -497,6 +518,10 @@ export namespace OneSignal { */ export function removeEmail(email: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(email)) { + console.error('OneSignal: removeEmail: email is required'); + return; + } RNOneSignal.removeEmail(email); } @@ -504,6 +529,10 @@ export namespace OneSignal { /** Add a new SMS subscription to the current user. */ export function addSms(smsNumber: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(smsNumber)) { + console.error('OneSignal: addSms: smsNumber is required'); + return; + } RNOneSignal.addSms(smsNumber); } @@ -514,6 +543,10 @@ export namespace OneSignal { */ export function removeSms(smsNumber: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(smsNumber)) { + console.error('OneSignal: removeSms: smsNumber is required'); + return; + } RNOneSignal.removeSms(smsNumber); } @@ -525,7 +558,7 @@ export namespace OneSignal { export function addTag(key: string, value: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; - if (!key || value === undefined || value === null) { + if (!isNonEmptyString(key) || value == null) { console.error('OneSignal: addTag: must include a key and a value'); return; } @@ -547,6 +580,10 @@ export namespace OneSignal { /** Remove the data tag with the provided key from the current user. */ export function removeTag(key: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(key)) { + console.error('OneSignal: removeTag: key is required'); + return; + } RNOneSignal.removeTag(key); } @@ -791,9 +828,10 @@ export namespace OneSignal { export function addTrigger(key: string, value: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; - // value can be assigned to `false` so we cannot just check `!value` - if (!key || value == null) { + // false is a valid trigger value, so reject only null/undefined for value. + if (!isNonEmptyString(key) || value == null) { console.error('OneSignal: addTrigger: must include a key and a value'); + return; } RNOneSignal.addTrigger(key, value); @@ -812,6 +850,10 @@ export namespace OneSignal { /** Remove the trigger with the provided key from the current user. */ export function removeTrigger(key: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isNonEmptyString(key)) { + console.error('OneSignal: removeTrigger: key is required'); + return; + } RNOneSignal.removeTrigger(key); } From e79a5a4fe7f0645175c8d080fbef80e2dd637288 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 21 Sep 2026 14:43:02 -0500 Subject: [PATCH 3/4] fix: [SDK-5304] log invalid iOS login through OneSignalLog NSLog bypasses setLogLevel. Use the same OneSignalLog error path as the rest of the iOS bridge. Co-authored-by: Cursor --- ios/RCTOneSignal/RCTOneSignalEventEmitter.mm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm index 0fa86c94..180c35eb 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm @@ -139,7 +139,8 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { RCT_EXPORT_METHOD(login : (NSString *)externalId) { if (externalId == nil || [externalId length] == 0) { - NSLog(@"OneSignal: login called with a nil or empty externalId"); + [OneSignalLog onesignalLog:ONE_S_LL_ERROR + message:@"login called with a nil or empty externalId"]; return; } [OneSignal login:externalId]; From ada0ef6bb89e3c12fabfa216cf1db8caf27150ee Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 24 Sep 2026 12:28:45 -0500 Subject: [PATCH 4/4] fix: [SDK-5304] keep setLanguage("") as the device language reset Empty language is the only reset path. Null is still rejected. Co-authored-by: Cursor --- src/index.test.ts | 10 ++++++++++ src/index.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 67ee4f7b..eb8b1c48 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -700,6 +700,16 @@ describe('OneSignal', () => { expect(mockRNOneSignal.setLanguage).toHaveBeenCalledWith('en'); }); + test('forwards an empty language so native can reset', () => { + OneSignal.User.setLanguage(''); + expect(mockRNOneSignal.setLanguage).toHaveBeenCalledWith(''); + }); + + test('does not set a null language', () => { + OneSignal.User.setLanguage(null as unknown as string); + expect(mockRNOneSignal.setLanguage).not.toHaveBeenCalled(); + }); + test('should not set language if native module is not loaded', () => { isNativeLoadedSpy.mockReturnValue(false); OneSignal.User.setLanguage('en'); diff --git a/src/index.ts b/src/index.ts index 9263b6b2..371f9a94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -454,10 +454,10 @@ export namespace OneSignal { return RNOneSignal.getExternalId(); } - /** Explicitly set a 2-character language code for the user. */ + /** Explicitly set a 2-character language code for the user. Empty string resets to the device language. */ export function setLanguage(language: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; - if (!isNonEmptyString(language)) { + if (typeof language !== 'string') { console.error('OneSignal: setLanguage: language is required'); return; }