diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 517f1d10..64a547cc 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -40,6 +40,8 @@ const mockRNOneSignal = { addPushSubscriptionObserver: vi.fn(), getPushSubscriptionId: vi.fn(), getPushSubscriptionToken: vi.fn(), + waitForPushSubscriptionIdAsync: vi.fn(), + waitForPushSubscriptionTokenAsync: vi.fn(), getOptedIn: vi.fn(), optOut: vi.fn(), optIn: vi.fn(), diff --git a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java index 773a8a3e..db2e1706 100644 --- a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java +++ b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java @@ -514,6 +514,102 @@ public void getPushSubscriptionToken(Promise promise) { } } + @ReactMethod + public void waitForPushSubscriptionIdAsync(final int timeoutMs, final Promise promise) { + IPushSubscription pushSubscription = OneSignal.getUser().getPushSubscription(); + String currentId = pushSubscription.getId(); + + // If ID already exists, resolve immediately + if (currentId != null && !currentId.isEmpty()) { + promise.resolve(currentId); + return; + } + + // Create a one-time observer + final IPushSubscriptionObserver observer = new IPushSubscriptionObserver() { + private boolean isResolved = false; + private final android.os.Handler handler = new android.os.Handler(android.os.Looper.getMainLooper()); + private Runnable timeoutRunnable; + + { + // Set up timeout + timeoutRunnable = new Runnable() { + @Override + public void run() { + if (!isResolved) { + isResolved = true; + OneSignal.getUser().getPushSubscription().removeObserver(this); + promise.resolve(null); + } + } + }; + handler.postDelayed(timeoutRunnable, timeoutMs); + } + + @Override + public void onPushSubscriptionChange(PushSubscriptionChangedState state) { + String newId = state.getCurrent().getId(); + if (newId != null && !newId.isEmpty() && !isResolved) { + isResolved = true; + handler.removeCallbacks(timeoutRunnable); + OneSignal.getUser().getPushSubscription().removeObserver(this); + promise.resolve(newId); + } + } + }; + + // Add the observer + pushSubscription.addObserver(observer); + } + + @ReactMethod + public void waitForPushSubscriptionTokenAsync(final int timeoutMs, final Promise promise) { + IPushSubscription pushSubscription = OneSignal.getUser().getPushSubscription(); + String currentToken = pushSubscription.getToken(); + + // If token already exists, resolve immediately + if (currentToken != null && !currentToken.isEmpty()) { + promise.resolve(currentToken); + return; + } + + // Create a one-time observer + final IPushSubscriptionObserver observer = new IPushSubscriptionObserver() { + private boolean isResolved = false; + private final android.os.Handler handler = new android.os.Handler(android.os.Looper.getMainLooper()); + private Runnable timeoutRunnable; + + { + // Set up timeout + timeoutRunnable = new Runnable() { + @Override + public void run() { + if (!isResolved) { + isResolved = true; + OneSignal.getUser().getPushSubscription().removeObserver(this); + promise.resolve(null); + } + } + }; + handler.postDelayed(timeoutRunnable, timeoutMs); + } + + @Override + public void onPushSubscriptionChange(PushSubscriptionChangedState state) { + String newToken = state.getCurrent().getToken(); + if (newToken != null && !newToken.isEmpty() && !isResolved) { + isResolved = true; + handler.removeCallbacks(timeoutRunnable); + OneSignal.getUser().getPushSubscription().removeObserver(this); + promise.resolve(newToken); + } + } + }; + + // Add the observer + pushSubscription.addObserver(observer); + } + @ReactMethod public void getOptedIn(Promise promise) { IPushSubscription pushSubscription = OneSignal.getUser().getPushSubscription(); diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.m b/ios/RCTOneSignal/RCTOneSignalEventEmitter.m index 50bc26a4..7a6e0639 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.m +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.m @@ -79,7 +79,7 @@ - (void)stopObserving { } else if (error.userInfo[@"returned"]) { return @[error.userInfo[@"returned"]]; } - + return @[error.localizedDescription]; } @@ -112,7 +112,7 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { [OneSignal logout]; } -RCT_EXPORT_METHOD(enterLiveActivity:(NSString *)activityId +RCT_EXPORT_METHOD(enterLiveActivity:(NSString *)activityId withToken:(NSString *)token withResponse:(RCTResponseSenderBlock)callback) { [OneSignal.LiveActivities enter:activityId withToken:token withSuccess:^(NSDictionary *result) { @@ -131,7 +131,7 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { }]; } -RCT_EXPORT_METHOD(setPushToStartToken:(NSString *)activityType +RCT_EXPORT_METHOD(setPushToStartToken:(NSString *)activityType withToken:(NSString *)token) { #if !TARGET_OS_MACCATALYST NSError* err=nil; @@ -209,7 +209,7 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { } // OneSignal.InAppMessages namespace methods -RCT_REMAP_METHOD(getPaused, +RCT_REMAP_METHOD(getPaused, getPausedResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { resolve(@([OneSignal.InAppMessages paused])); @@ -269,13 +269,13 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { } // OneSignal.Notifications namespace methods -RCT_REMAP_METHOD(hasNotificationPermission, +RCT_REMAP_METHOD(hasNotificationPermission, hasNotificationPermissionResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { resolve(@([OneSignal.Notifications permission])); } -RCT_REMAP_METHOD(canRequestNotificationPermission, +RCT_REMAP_METHOD(canRequestNotificationPermission, canRequestNotificationPermissionResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { resolve(@([OneSignal.Notifications canRequestPermission])); @@ -408,7 +408,7 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { } RCT_EXPORT_METHOD(removeSms:(NSString *)smsNumber) { - [OneSignal.User removeSms:smsNumber]; + [OneSignal.User removeSms:smsNumber]; } RCT_EXPORT_METHOD(addTag:(NSString *)key value:(id)value) { @@ -436,7 +436,7 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { getOnesignalIdResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { NSString *onesignalId = OneSignal.User.onesignalId; - + if (onesignalId == nil || [onesignalId length] == 0) { resolve([NSNull null]); // Resolve with null if nil or empty } else { @@ -448,7 +448,7 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { getExternalIdResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { NSString *externalId = OneSignal.User.externalId; - + if (externalId == nil || [externalId length] == 0) { resolve([NSNull null]); // Resolve with null if nil or empty } else { @@ -474,13 +474,13 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { // OneSignal.User.pushSubscription namespace methods -RCT_REMAP_METHOD(getOptedIn, +RCT_REMAP_METHOD(getOptedIn, getOptedInResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { resolve(@(OneSignal.User.pushSubscription.optedIn)); } -RCT_REMAP_METHOD(getPushSubscriptionId, +RCT_REMAP_METHOD(getPushSubscriptionId, getPushSubscriptionIdResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { NSString *pushId = OneSignal.User.pushSubscription.id; @@ -491,7 +491,7 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { } } -RCT_REMAP_METHOD(getPushSubscriptionToken, +RCT_REMAP_METHOD(getPushSubscriptionToken, getPushSubscriptionTokenResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { NSString *token = OneSignal.User.pushSubscription.token; @@ -502,6 +502,78 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { } } +RCT_REMAP_METHOD(waitForPushSubscriptionIdAsync, + waitForPushSubscriptionIdWithTimeoutMs:(nonnull NSNumber *)timeoutMs + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + NSString *currentId = OneSignal.User.pushSubscription.id; + + // If ID already exists, resolve immediately + if (currentId && ![currentId isEqualToString:@""]) { + resolve(currentId); + return; + } + + __block BOOL isResolved = NO; + __block id observer = nil; + + // Create observer + observer = [OneSignal.User.pushSubscription addObserver:^(OSPushSubscriptionChangedState * _Nonnull state) { + NSString *newId = state.current.id; + if (newId && ![newId isEqualToString:@""] && !isResolved) { + isResolved = YES; + [OneSignal.User.pushSubscription removeObserver:observer]; + resolve(newId); + } + }]; + + // Set up timeout + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)([timeoutMs integerValue] * NSEC_PER_MSEC)), + dispatch_get_main_queue(), ^{ + if (!isResolved) { + isResolved = YES; + [OneSignal.User.pushSubscription removeObserver:observer]; + resolve([NSNull null]); + } + }); +} + +RCT_REMAP_METHOD(waitForPushSubscriptionTokenAsync, + waitForPushSubscriptionTokenWithTimeoutMs:(nonnull NSNumber *)timeoutMs + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + NSString *currentToken = OneSignal.User.pushSubscription.token; + + // If token already exists, resolve immediately + if (currentToken && ![currentToken isEqualToString:@""]) { + resolve(currentToken); + return; + } + + __block BOOL isResolved = NO; + __block id observer = nil; + + // Create observer + observer = [OneSignal.User.pushSubscription addObserver:^(OSPushSubscriptionChangedState * _Nonnull state) { + NSString *newToken = state.current.token; + if (newToken && ![newToken isEqualToString:@""] && !isResolved) { + isResolved = YES; + [OneSignal.User.pushSubscription removeObserver:observer]; + resolve(newToken); + } + }]; + + // Set up timeout + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)([timeoutMs integerValue] * NSEC_PER_MSEC)), + dispatch_get_main_queue(), ^{ + if (!isResolved) { + isResolved = YES; + [OneSignal.User.pushSubscription removeObserver:observer]; + resolve([NSNull null]); + } + }); +} + RCT_EXPORT_METHOD(optIn) { [OneSignal.User.pushSubscription optIn]; } diff --git a/src/index.test.ts b/src/index.test.ts index 6b40bd7d..41f45a6a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -454,6 +454,48 @@ describe('OneSignal', () => { OneSignal.User.pushSubscription.getIdAsync(), ).rejects.toThrow('OneSignal native module not loaded'); }); + + test('should wait for subscription id using native method', async () => { + // Mock the native wait method to resolve with ID after delay + mockRNOneSignal.waitForPushSubscriptionIdAsync.mockResolvedValue( + PUSH_ID, + ); + + const result = await OneSignal.User.pushSubscription.getIdAsync({ + timeout: 5000, + }); + + expect(result).toBe(PUSH_ID); + expect( + mockRNOneSignal.waitForPushSubscriptionIdAsync, + ).toHaveBeenCalledWith(5000); + }); + + test('should return null if id not available after timeout', async () => { + mockRNOneSignal.waitForPushSubscriptionIdAsync.mockResolvedValue(null); + + const result = await OneSignal.User.pushSubscription.getIdAsync({ + timeout: 1000, + }); + + expect(result).toBeNull(); + expect( + mockRNOneSignal.waitForPushSubscriptionIdAsync, + ).toHaveBeenCalledWith(1000); + }); + + test('should use default timeout if not specified', async () => { + mockRNOneSignal.waitForPushSubscriptionIdAsync.mockResolvedValue( + PUSH_ID, + ); + + const result = await OneSignal.User.pushSubscription.getIdAsync(); + + expect(result).toBe(PUSH_ID); + expect( + mockRNOneSignal.waitForPushSubscriptionIdAsync, + ).toHaveBeenCalledWith(5000); // Default timeout + }); }); describe('getPushSubscriptionToken (deprecated)', () => { @@ -502,6 +544,50 @@ describe('OneSignal', () => { OneSignal.User.pushSubscription.getTokenAsync(), ).rejects.toThrow('OneSignal native module not loaded'); }); + + test('should wait for subscription token using native method', async () => { + // Mock the native wait method to resolve with token + vi.mocked( + mockRNOneSignal.waitForPushSubscriptionTokenAsync, + ).mockResolvedValue(PUSH_TOKEN); + + const result = await OneSignal.User.pushSubscription.getTokenAsync({ + timeout: 5000, + }); + + expect(result).toBe(PUSH_TOKEN); + expect( + mockRNOneSignal.waitForPushSubscriptionTokenAsync, + ).toHaveBeenCalledWith(5000); + }); + + test('should return null if token not available after timeout', async () => { + vi.mocked( + mockRNOneSignal.waitForPushSubscriptionTokenAsync, + ).mockResolvedValue(null); + + const result = await OneSignal.User.pushSubscription.getTokenAsync({ + timeout: 1000, + }); + + expect(result).toBeNull(); + expect( + mockRNOneSignal.waitForPushSubscriptionTokenAsync, + ).toHaveBeenCalledWith(1000); + }); + + test('should use default timeout if not specified', async () => { + vi.mocked( + mockRNOneSignal.waitForPushSubscriptionTokenAsync, + ).mockResolvedValue(PUSH_TOKEN); + + const result = await OneSignal.User.pushSubscription.getTokenAsync(); + + expect(result).toBe(PUSH_TOKEN); + expect( + mockRNOneSignal.waitForPushSubscriptionTokenAsync, + ).toHaveBeenCalledWith(5000); // Default timeout + }); }); describe('getOptedIn (deprecated)', () => { diff --git a/src/index.ts b/src/index.ts index 2fc85415..2d80c87a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -313,14 +313,37 @@ export namespace OneSignal { return pushSub.id ? pushSub.id : ''; } - export async function getIdAsync(): Promise { + /** + * Gets the push subscription ID, waiting for it to be available if necessary. + * + * This method addresses a race condition where the subscription ID may not be + * immediately available after permission is granted. It uses native event + * listeners to wait for the ID to be generated, with a configurable timeout. + * + * @param options - Optional configuration + * @param options.timeout - Maximum time to wait in milliseconds (default: 5000) + * @returns The subscription ID, or null if not available after timeout + */ + export async function getIdAsync(options?: { + timeout?: number; + }): Promise { if (!isNativeModuleLoaded(RNOneSignal)) { return Promise.reject( new Error('OneSignal native module not loaded'), ); } - return await RNOneSignal.getPushSubscriptionId(); + const timeout = options?.timeout ?? 5000; + + // Use the native wait method which listens for subscription events + const id = await RNOneSignal.waitForPushSubscriptionIdAsync(timeout); + + // Update cached state if we got an ID + if (id) { + pushSub.id = id; + } + + return id; } /** @@ -337,15 +360,39 @@ export namespace OneSignal { return pushSub.token ? pushSub.token : ''; } - /** The readonly push subscription token */ - export async function getTokenAsync(): Promise { + /** + * Gets the push subscription token, waiting for it to be available if necessary. + * + * This method addresses a race condition where the subscription token may not be + * immediately available after permission is granted. It uses native event + * listeners to wait for the token to be generated, with a configurable timeout. + * + * @param options - Optional configuration + * @param options.timeout - Maximum time to wait in milliseconds (default: 5000) + * @returns The subscription token, or null if not available after timeout + */ + export async function getTokenAsync(options?: { + timeout?: number; + }): Promise { if (!isNativeModuleLoaded(RNOneSignal)) { return Promise.reject( new Error('OneSignal native module not loaded'), ); } - return await RNOneSignal.getPushSubscriptionToken(); + const timeout = options?.timeout ?? 5000; + + // Use the native wait method which listens for subscription events + const token = await RNOneSignal.waitForPushSubscriptionTokenAsync( + timeout, + ); + + // Update cached state if we got a token + if (token) { + pushSub.token = token; + } + + return token; } /**