From c6795ec09ccc95b60c214836bd61a1a9b3546a2e Mon Sep 17 00:00:00 2001 From: ArditXhaferi Date: Sun, 31 May 2026 22:35:06 +0200 Subject: [PATCH] fix(ios): guard PushKit payload serialization to prevent iOS 26 SIGABRT +didReceiveIncomingPushWithPayload: forwarded the raw payload.dictionaryPayload to the RN bridge, which serializes it with NSJSONSerialization. A JSON-unsafe value (unpaired UTF-16 surrogate, NaN/Inf, non-string key/value type) makes +dataWithJSONObject:options:error: raise an ObjC NSException that is uncatchable from a Swift AppDelegate / the bridge under the iOS 26 SDK, aborting the app. Pre-flight the serialization at the call site and forward only a proven-safe body (or a minimal fallback) so a bad payload can never abort the process. Fixes #122. --- .../RNVoipPushNotificationManager.m | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/ios/RNVoipPushNotification/RNVoipPushNotificationManager.m b/ios/RNVoipPushNotification/RNVoipPushNotificationManager.m index f4b3701..6e5b0e9 100644 --- a/ios/RNVoipPushNotification/RNVoipPushNotificationManager.m +++ b/ios/RNVoipPushNotification/RNVoipPushNotificationManager.m @@ -18,6 +18,89 @@ NSString *const RNVoipPushRemoteNotificationReceivedEvent = @"RNVoipPushRemoteNotificationReceivedEvent"; NSString *const RNVoipPushDidLoadWithEvents = @"RNVoipPushDidLoadWithEvents"; +// --- Return a guaranteed JSON-serializable string. Unpaired UTF-16 surrogates +// --- pass +isValidJSONObject: yet still make -dataWithJSONObject: throw, so we +// --- use NSJSONSerialization itself as the oracle: original -> lossy ASCII -> "". +static NSString *RNVoipSafeString(NSString *string) +{ + if (![string isKindOfClass:[NSString class]] || string.length == 0) { + return @""; + } + NSData *asciiData = [string dataUsingEncoding:NSASCIIStringEncoding allowLossyConversion:YES]; + NSString *asciiLossy = asciiData ? [[NSString alloc] initWithData:asciiData encoding:NSASCIIStringEncoding] : nil; + NSArray *candidates = @[ string, asciiLossy ?: @"", @"" ]; + for (NSString *candidate in candidates) { + @try { + NSError *error = nil; + if ([NSJSONSerialization dataWithJSONObject:@[ candidate ] options:0 error:&error] != nil && error == nil) { + return candidate; + } + } @catch (NSException *exception) { + // try the next, more conservative candidate + } + } + return @""; +} + +// --- Recursively coerce any object into a JSON-safe representation so the RN +// --- bridge's NSJSONSerialization can never throw NSInvalidArgumentException +// --- (observed as a SIGABRT on the iOS 26 SDK). +static id RNVoipSanitizeJSONObject(id object) +{ + if (object == nil || [object isKindOfClass:[NSNull class]]) { + return [NSNull null]; + } + if ([object isKindOfClass:[NSString class]]) { + return RNVoipSafeString((NSString *)object); + } + if ([object isKindOfClass:[NSNumber class]]) { + double value = [(NSNumber *)object doubleValue]; + if (isnan(value) || isinf(value)) { return @0; } // NaN/Infinity are not valid JSON + return object; + } + if ([object isKindOfClass:[NSArray class]]) { + NSMutableArray *result = [NSMutableArray arrayWithCapacity:[(NSArray *)object count]]; + for (id element in (NSArray *)object) { + [result addObject:RNVoipSanitizeJSONObject(element)]; + } + return result; + } + if ([object isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:[(NSDictionary *)object count]]; + [(NSDictionary *)object enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) { + NSString *stringKey = [key isKindOfClass:[NSString class]] ? RNVoipSafeString((NSString *)key) : RNVoipSafeString([key description]); + if (stringKey.length > 0) { + result[stringKey] = RNVoipSanitizeJSONObject(value); + } + }]; + return result; + } + return RNVoipSafeString([object description]); // NSData / NSDate / custom -> string +} + +// --- Return a body the RN bridge is GUARANTEED to serialize. The bridge runs +// --- NSJSONSerialization on the JS thread asynchronously, so a @try/@catch +// --- around sendEventWithName: cannot catch the throw; we therefore pre-flight +// --- the exact serialization HERE and only forward a proven-safe body. +static id RNVoipSerializableBodyOrFallback(NSDictionary *rawPayload) +{ + id sanitized = RNVoipSanitizeJSONObject(rawPayload); + @try { + if ([NSJSONSerialization isValidJSONObject:sanitized]) { + NSError *error = nil; + if ([NSJSONSerialization dataWithJSONObject:sanitized options:0 error:&error] != nil && error == nil) { + return sanitized; + } + } + } @catch (NSException *exception) { + // fall through to the minimal payload below + } + NSLog(@"[RNVoipPushNotification] PushKit payload not JSON-serializable after sanitize; forwarding minimal fallback."); + NSDictionary *raw = [rawPayload isKindOfClass:[NSDictionary class]] ? rawPayload : @{}; + return @{ @"_rnvoip_sanitized": @YES, + @"voip_call_id": RNVoipSafeString([raw[@"voip_call_id"] description]) }; +} + @implementation RNVoipPushNotificationManager { bool _hasListeners; @@ -177,7 +260,12 @@ + (void)didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(NSSt #endif RNVoipPushNotificationManager *voipPushManager = [RNVoipPushNotificationManager sharedInstance]; - [voipPushManager sendEventWithNameWrapper:RNVoipPushRemoteNotificationReceivedEvent body:payload.dictionaryPayload]; + // iOS 26 SIGABRT fix: the RN bridge serializes the event body with + // NSJSONSerialization asynchronously on the JS thread, so a @try/@catch + // around sendEventWithName: cannot catch the throw. Pre-flight the exact + // serialization here and only forward a body proven to serialize. + id safeBody = RNVoipSerializableBodyOrFallback(payload.dictionaryPayload); + [voipPushManager sendEventWithNameWrapper:RNVoipPushRemoteNotificationReceivedEvent body:safeBody]; } // --- getter for completionHandlers