From d4c04b0602760975a307f3dcc8e8fdcf0ef6eadb Mon Sep 17 00:00:00 2001 From: tetek Date: Mon, 28 Sep 2026 16:03:02 +0200 Subject: [PATCH 1/5] Deliver callbacks to App Delegates that use fast forwarding Proxying a delegate that handles a selector via forwardingTargetForSelector: instead of implementing it caused the callback to be dropped silently. createSubclassWithObject: records the original implementation with class_getInstanceMethod(realClass, sel), which is NULL for such a delegate. Adding the donor method then makes message lookup succeed, so the runtime no longer consults the forwarding target, and each donor's trailing `if (realIMP)` is false. The message is neither delivered nor diagnosed. This is the shape of the delegate SwiftUI installs for UIApplicationDelegateAdaptor: SwiftUI.AppDelegate implements only a handful of selectors -- didFinishLaunchingWithOptions:, configurationForConnecting:options:, handleEventsForBackgroundURLSession:, respondsToSelector: and forwardingTargetForSelector: -- and forwards the rest to the app's own delegate. An app using that adaptor together with FirebaseMessaging or FirebaseAuth therefore stops receiving the APNS callbacks on its own delegate, and any app using a GoogleUtilities-based SDK stops receiving continueUserActivity: and openURL:options:. Each donor now falls back to the forwarding target when the original implementation is NULL, so the behaviour is unchanged for delegates that implement the selector themselves. --- .../GULAppDelegateSwizzler.m | 62 +++++++++++ .../Swizzler/GULAppDelegateSwizzlerTest.m | 104 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m b/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m index 1857001b..ea0cc96d 100644 --- a/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m +++ b/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m @@ -547,6 +547,21 @@ + (nullable NSValue *)originalImplementationForSelector:(SEL)selector object:(id return realImplementationBySelector[NSStringFromSelector(selector)]; } +// The App Delegate SwiftUI installs for UIApplicationDelegateAdaptor implements a handful of +// selectors itself and forwards the rest to the app's own delegate. Such a delegate has no +// implementation for the runtime to find, so the donor methods have nothing to call. ++ (nullable id)forwardingTargetForProxiedSelector:(SEL)selector + object:(id)object { + if (![object respondsToSelector:@selector(forwardingTargetForSelector:)]) { + return nil; + } + id target = [object forwardingTargetForSelector:selector]; + if (target == nil || target == object || ![target respondsToSelector:selector]) { + return nil; + } + return target; +} + + (void)proxyDestinationSelector:(SEL)destinationSelector implementationsFromSourceSelector:(SEL)sourceSelector fromClass:(Class)sourceClass @@ -696,6 +711,12 @@ - (BOOL)application:(GULApplication *)application #pragma clang diagnostic pop if (openURLOptionsIMP) { returnedValue |= openURLOptionsIMP(self, methodSelector, application, url, options); + } else { + returnedValue |= [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector + object:self] + application:application + openURL:url + options:options]; } return returnedValue; } @@ -731,6 +752,18 @@ - (BOOL)application:(GULApplication *)application if (openURLSourceApplicationAnnotationIMP) { returnedValue |= openURLSourceApplicationAnnotationIMP(self, methodSelector, application, url, sourceApplication, annotation); + } else { +// The forwarding target is messaged directly, unlike the IMP call above, so the deprecation of +// application:openURL:sourceApplication:annotation: is diagnosed here. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + returnedValue |= [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector + object:self] + application:application + openURL:url + sourceApplication:sourceApplication + annotation:annotation]; +#pragma clang diagnostic pop } return returnedValue; } @@ -765,6 +798,11 @@ - (void)application:(GULApplication *)application // Call the real implementation if the real App Delegate has any. if (handleBackgroundSessionIMP) { handleBackgroundSessionIMP(self, methodSelector, application, identifier, completionHandler); + } else { + [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self] + application:application + handleEventsForBackgroundURLSession:identifier + completionHandler:completionHandler]; } } @@ -795,6 +833,12 @@ - (BOOL)application:(GULApplication *)application if (continueUserActivityIMP) { returnedValue |= continueUserActivityIMP(self, methodSelector, application, userActivity, restorationHandler); + } else { + returnedValue |= [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector + object:self] + application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]; } return returnedValue; } @@ -825,6 +869,10 @@ - (void)application:(GULApplication *)application // Call the real implementation if the real App Delegate has any. if (didRegisterForRemoteNotificationsIMP) { didRegisterForRemoteNotificationsIMP(self, methodSelector, application, deviceToken); + } else { + [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self] + application:application + didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; } } @@ -851,6 +899,10 @@ - (void)application:(GULApplication *)application // Call the real implementation if the real App Delegate has any. if (didFailToRegisterForRemoteNotificationsIMP) { didFailToRegisterForRemoteNotificationsIMP(self, methodSelector, application, error); + } else { + [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self] + application:application + didFailToRegisterForRemoteNotificationsWithError:error]; } } @@ -896,6 +948,16 @@ - (void)application:(GULApplication *)application didReceiveRemoteNotificationWithCompletionIMP(self, methodSelector, application, userInfo, localCompletionHandler); + } else { + id forwardingTarget = + [GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self]; + if (forwardingTarget) { + dispatch_group_enter(callbackGroup); + + [forwardingTarget application:application + didReceiveRemoteNotification:userInfo + fetchCompletionHandler:localCompletionHandler]; + } } dispatch_group_notify(callbackGroup, dispatch_get_main_queue(), ^() { diff --git a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m index d7d059c3..dac8363a 100644 --- a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m +++ b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m @@ -239,6 +239,39 @@ - (BOOL)application:(GULApplication *)application @end +/** An App Delegate shaped like the one SwiftUI installs for UIApplicationDelegateAdaptor: it + * implements a handful of selectors itself and forwards the rest to the app's own delegate. + */ +@interface GULForwardingTestAppDelegate : NSObject + +/** The delegate every non-implemented selector is forwarded to. */ +@property(nonatomic, strong) GULTestAppDelegate *forwardingTarget; + +@end + +@implementation GULForwardingTestAppDelegate + +- (instancetype)init { + self = [super init]; + if (self) { + _forwardingTarget = [[GULTestAppDelegate alloc] init]; + } + return self; +} + +- (BOOL)respondsToSelector:(SEL)aSelector { + return [super respondsToSelector:aSelector] || [self.forwardingTarget respondsToSelector:aSelector]; +} + +- (id)forwardingTargetForSelector:(SEL)aSelector { + if ([self.forwardingTarget respondsToSelector:aSelector]) { + return self.forwardingTarget; + } + return [super forwardingTargetForSelector:aSelector]; +} + +@end + @interface GULFakeApplication : NSObject @property(nonatomic, strong) id delegate; @end @@ -1251,4 +1284,75 @@ - (void)testAppDelegateIsProxiedIncludingAPNSMethodsWhenEnabled { XCTAssertNotEqualObjects([originalAppDelegate class], originalAppDelegateClass); } + +#pragma mark - Fast forwarding App Delegates + +/** Tests that an App Delegate which forwards the remote notification selectors, rather than + * implementing them, still receives them after proxying. + */ +- (void)testForwardingAppDelegateReceivesRemoteNotificationsCallbacks { + GULApplication *application = [GULApplication sharedApplication]; + GULForwardingTestAppDelegate *forwardingDelegate = + [[GULForwardingTestAppDelegate alloc] init]; + GULTestAppDelegate *realDelegate = forwardingDelegate.forwardingTarget; + + [GULApplication sharedApplication].delegate = forwardingDelegate; + [GULAppDelegateSwizzler proxyOriginalDelegateIncludingAPNSMethods]; + + id proxiedDelegate = (id)forwardingDelegate; + + NSData *deviceToken = [NSData data]; + [proxiedDelegate application:application + didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; + XCTAssertEqual(realDelegate.remoteNotificationsDeviceToken, deviceToken); + + NSError *error = [NSError errorWithDomain:@"test" code:-1 userInfo:nil]; + [proxiedDelegate application:application + didFailToRegisterForRemoteNotificationsWithError:error]; + XCTAssertEqual(realDelegate.failToRegisterForRemoteNotificationsError, error); +} + +/** Same, for the user activity callback, which is proxied without the APNS methods. */ +- (void)testForwardingAppDelegateReceivesContinueUserActivity { + GULApplication *application = [GULApplication sharedApplication]; + GULForwardingTestAppDelegate *forwardingDelegate = + [[GULForwardingTestAppDelegate alloc] init]; + GULTestAppDelegate *realDelegate = forwardingDelegate.forwardingTarget; + + [GULApplication sharedApplication].delegate = forwardingDelegate; + [GULAppDelegateSwizzler proxyOriginalDelegate]; + + NSUserActivity *userActivity = [[NSUserActivity alloc] initWithActivityType:@"test"]; + id proxiedDelegate = (id)forwardingDelegate; + [proxiedDelegate application:application + continueUserActivity:userActivity + restorationHandler:^(NSArray *restorableObjects){ + }]; + + XCTAssertEqual(realDelegate.userActivity, userActivity); +} + +/** Interceptors must keep working for a forwarding App Delegate. */ +- (void)testForwardingAppDelegateStillNotifiesInterceptors { + GULApplication *application = [GULApplication sharedApplication]; + GULForwardingTestAppDelegate *forwardingDelegate = + [[GULForwardingTestAppDelegate alloc] init]; + GULFakeAppDelegateInterceptor *interceptor = [[GULFakeAppDelegateInterceptor alloc] init]; + + [GULApplication sharedApplication].delegate = forwardingDelegate; + [GULAppDelegateSwizzler proxyOriginalDelegateIncludingAPNSMethods]; + [GULAppDelegateSwizzler registerAppDelegateInterceptor:interceptor]; + + id proxiedDelegate = (id)forwardingDelegate; + [proxiedDelegate application:application + didRegisterForRemoteNotificationsWithDeviceToken:[NSData data]]; + + __block BOOL isCalled = NO; + dispatch_sync(interceptor.syncQueue, ^{ + isCalled = interceptor.isApplicationDidRegisterForRemoteNotificationsCalled; + }); + XCTAssertTrue(isCalled); + XCTAssertNotNil(forwardingDelegate.forwardingTarget.remoteNotificationsDeviceToken); +} + @end From 63f9c4f59f15e9c3c44bd588692f6e73790f29da Mon Sep 17 00:00:00 2001 From: tetek Date: Tue, 29 Sep 2026 09:23:32 +0200 Subject: [PATCH 2/5] Address review: forward via NSInvocation, add tests and changelog Forward through NSInvocation rather than by messaging the target directly, so that the APNS App Delegate selectors are not referenced as symbols. Referencing them directly triggers an Apple review warning about a missing Push Notification Entitlement even when the code never runs, which is why the surrounding code builds those selectors from strings. Adds tests for the value returned by a forwarded selector, for the completion handlers of application:handleEventsForBackgroundURLSession:completionHandler: and application:didReceiveRemoteNotification:fetchCompletionHandler:, and for a forwarding delegate whose target does not implement the selector at all. Adds the changelog entry. --- CHANGELOG.md | 4 + .../GULAppDelegateSwizzler.m | 128 +++++++++----- .../Swizzler/GULAppDelegateSwizzlerTest.m | 157 ++++++++++++++++-- 3 files changed, 236 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e64271eb..38b58b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# Unreleased +- [fixed] Deliver App Delegate callbacks to delegates that handle them by forwarding rather than by + implementing them, such as the one SwiftUI installs for `UIApplicationDelegateAdaptor`. (#247) + # 8.1.3 - [fixed] Fixed [GULSwizzler ivarObjectsForObject] to correctly query instance variables of an NSProxy and to exclude weak instances in the returned array. (#244) diff --git a/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m b/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m index ea0cc96d..6e2fe212 100644 --- a/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m +++ b/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m @@ -551,7 +551,7 @@ + (nullable NSValue *)originalImplementationForSelector:(SEL)selector object:(id // selectors itself and forwards the rest to the app's own delegate. Such a delegate has no // implementation for the runtime to find, so the donor methods have nothing to call. + (nullable id)forwardingTargetForProxiedSelector:(SEL)selector - object:(id)object { + object:(id)object { if (![object respondsToSelector:@selector(forwardingTargetForSelector:)]) { return nil; } @@ -562,6 +562,33 @@ + (nullable NSValue *)originalImplementationForSelector:(SEL)selector object:(id return target; } ++ (nullable NSInvocation *)forwardSelector:(SEL)selector + fromObject:(id)object + argumentSetter:(void (^)(NSInvocation *invocation))argumentSetter { + id target = [self forwardingTargetForProxiedSelector:selector object:object]; + if (target == nil) { + return nil; + } + NSInvocation *invocation = [self appDelegateInvocationForSelector:selector]; + if (invocation == nil) { + return nil; + } + [invocation setTarget:target]; + [invocation setSelector:selector]; + argumentSetter(invocation); + [invocation invoke]; + return invocation; +} + ++ (BOOL)boolReturnValueOfInvocation:(nullable NSInvocation *)invocation { + if (invocation == nil) { + return NO; + } + BOOL returnValue = NO; + [invocation getReturnValue:&returnValue]; + return returnValue; +} + + (void)proxyDestinationSelector:(SEL)destinationSelector implementationsFromSourceSelector:(SEL)sourceSelector fromClass:(Class)sourceClass @@ -712,11 +739,16 @@ - (BOOL)application:(GULApplication *)application if (openURLOptionsIMP) { returnedValue |= openURLOptionsIMP(self, methodSelector, application, url, options); } else { - returnedValue |= [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector - object:self] - application:application - openURL:url - options:options]; + returnedValue |= [GULAppDelegateSwizzler + boolReturnValueOfInvocation:[GULAppDelegateSwizzler + forwardSelector:methodSelector + fromObject:self + argumentSetter:^(NSInvocation *invocation) { + [invocation setArgument:(void *)(&application) + atIndex:2]; + [invocation setArgument:(void *)(&url) atIndex:3]; + [invocation setArgument:(void *)(&options) atIndex:4]; + }]]; } return returnedValue; } @@ -753,17 +785,18 @@ - (BOOL)application:(GULApplication *)application returnedValue |= openURLSourceApplicationAnnotationIMP(self, methodSelector, application, url, sourceApplication, annotation); } else { -// The forwarding target is messaged directly, unlike the IMP call above, so the deprecation of -// application:openURL:sourceApplication:annotation: is diagnosed here. -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - returnedValue |= [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector - object:self] - application:application - openURL:url - sourceApplication:sourceApplication - annotation:annotation]; -#pragma clang diagnostic pop + returnedValue |= [GULAppDelegateSwizzler + boolReturnValueOfInvocation:[GULAppDelegateSwizzler + forwardSelector:methodSelector + fromObject:self + argumentSetter:^(NSInvocation *invocation) { + [invocation setArgument:(void *)(&application) + atIndex:2]; + [invocation setArgument:(void *)(&url) atIndex:3]; + [invocation setArgument:(void *)(&sourceApplication) + atIndex:4]; + [invocation setArgument:(void *)(&annotation) atIndex:5]; + }]]; } return returnedValue; } @@ -799,10 +832,13 @@ - (void)application:(GULApplication *)application if (handleBackgroundSessionIMP) { handleBackgroundSessionIMP(self, methodSelector, application, identifier, completionHandler); } else { - [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self] - application:application - handleEventsForBackgroundURLSession:identifier - completionHandler:completionHandler]; + [GULAppDelegateSwizzler forwardSelector:methodSelector + fromObject:self + argumentSetter:^(NSInvocation *invocation) { + [invocation setArgument:(void *)(&application) atIndex:2]; + [invocation setArgument:(void *)(&identifier) atIndex:3]; + [invocation setArgument:(void *)(&completionHandler) atIndex:4]; + }]; } } @@ -834,11 +870,18 @@ - (BOOL)application:(GULApplication *)application returnedValue |= continueUserActivityIMP(self, methodSelector, application, userActivity, restorationHandler); } else { - returnedValue |= [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector - object:self] - application:application - continueUserActivity:userActivity - restorationHandler:restorationHandler]; + returnedValue |= [GULAppDelegateSwizzler + boolReturnValueOfInvocation:[GULAppDelegateSwizzler + forwardSelector:methodSelector + fromObject:self + argumentSetter:^(NSInvocation *invocation) { + [invocation setArgument:(void *)(&application) + atIndex:2]; + [invocation setArgument:(void *)(&userActivity) + atIndex:3]; + [invocation setArgument:(void *)(&restorationHandler) + atIndex:4]; + }]]; } return returnedValue; } @@ -870,9 +913,12 @@ - (void)application:(GULApplication *)application if (didRegisterForRemoteNotificationsIMP) { didRegisterForRemoteNotificationsIMP(self, methodSelector, application, deviceToken); } else { - [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self] - application:application - didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; + [GULAppDelegateSwizzler forwardSelector:methodSelector + fromObject:self + argumentSetter:^(NSInvocation *invocation) { + [invocation setArgument:(void *)(&application) atIndex:2]; + [invocation setArgument:(void *)(&deviceToken) atIndex:3]; + }]; } } @@ -900,9 +946,12 @@ - (void)application:(GULApplication *)application if (didFailToRegisterForRemoteNotificationsIMP) { didFailToRegisterForRemoteNotificationsIMP(self, methodSelector, application, error); } else { - [[GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self] - application:application - didFailToRegisterForRemoteNotificationsWithError:error]; + [GULAppDelegateSwizzler forwardSelector:methodSelector + fromObject:self + argumentSetter:^(NSInvocation *invocation) { + [invocation setArgument:(void *)(&application) atIndex:2]; + [invocation setArgument:(void *)(&error) atIndex:3]; + }]; } } @@ -949,14 +998,17 @@ - (void)application:(GULApplication *)application didReceiveRemoteNotificationWithCompletionIMP(self, methodSelector, application, userInfo, localCompletionHandler); } else { - id forwardingTarget = - [GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self]; - if (forwardingTarget) { + if ([GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self]) { dispatch_group_enter(callbackGroup); - [forwardingTarget application:application - didReceiveRemoteNotification:userInfo - fetchCompletionHandler:localCompletionHandler]; + [GULAppDelegateSwizzler + forwardSelector:methodSelector + fromObject:self + argumentSetter:^(NSInvocation *invocation) { + [invocation setArgument:(void *)(&application) atIndex:2]; + [invocation setArgument:(void *)(&userInfo) atIndex:3]; + [invocation setArgument:(void *)(&localCompletionHandler) atIndex:4]; + }]; } } diff --git a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m index dac8363a..d2cb6360 100644 --- a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m +++ b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m @@ -68,6 +68,7 @@ @interface GULTestAppDelegate : NSObject { @property(nonatomic, strong) NSData *remoteNotificationsDeviceToken; @property(nonatomic, strong) NSError *failToRegisterForRemoteNotificationsError; @property(nonatomic, strong) NSDictionary *remoteNotification; +@property(nonatomic, copy) void (^backgroundSessionCompletionHandler)(void); #if TARGET_OS_IOS || TARGET_OS_TV @property(nonatomic, copy) void (^remoteNotificationCompletionHandler)(UIBackgroundFetchResult); @@ -149,6 +150,7 @@ - (void)application:(GULApplication *)application completionHandler:(nonnull void (^)(void))completionHandler { self.application = application; _backgroundSessionID = identifier; + self.backgroundSessionCompletionHandler = completionHandler; } - (void)application:(GULApplication *)application @@ -239,28 +241,56 @@ - (BOOL)application:(GULApplication *)application @end +/** An App Delegate that returns YES from the selectors that have a return value. */ +@interface GULReturningTestAppDelegate : NSObject +@end + +@implementation GULReturningTestAppDelegate + +- (BOOL)application:(GULApplication *)app + openURL:(NSURL *)url + options:(NSDictionary *)options { + return YES; +} + +- (BOOL)application:(GULApplication *)application + continueUserActivity:(NSUserActivity *)userActivity + restorationHandler:(void (^)(NSArray> *__nullable + restorableObjects))restorationHandler { + return YES; +} + +@end + /** An App Delegate shaped like the one SwiftUI installs for UIApplicationDelegateAdaptor: it * implements a handful of selectors itself and forwards the rest to the app's own delegate. */ @interface GULForwardingTestAppDelegate : NSObject /** The delegate every non-implemented selector is forwarded to. */ -@property(nonatomic, strong) GULTestAppDelegate *forwardingTarget; +@property(nonatomic, strong) NSObject *forwardingTarget; + +- (instancetype)initWithForwardingTarget:(NSObject *)forwardingTarget; @end @implementation GULForwardingTestAppDelegate - (instancetype)init { + return [self initWithForwardingTarget:[[GULTestAppDelegate alloc] init]]; +} + +- (instancetype)initWithForwardingTarget:(NSObject *)forwardingTarget { self = [super init]; if (self) { - _forwardingTarget = [[GULTestAppDelegate alloc] init]; + _forwardingTarget = forwardingTarget; } return self; } - (BOOL)respondsToSelector:(SEL)aSelector { - return [super respondsToSelector:aSelector] || [self.forwardingTarget respondsToSelector:aSelector]; + return + [super respondsToSelector:aSelector] || [self.forwardingTarget respondsToSelector:aSelector]; } - (id)forwardingTargetForSelector:(SEL)aSelector { @@ -1284,7 +1314,6 @@ - (void)testAppDelegateIsProxiedIncludingAPNSMethodsWhenEnabled { XCTAssertNotEqualObjects([originalAppDelegate class], originalAppDelegateClass); } - #pragma mark - Fast forwarding App Delegates /** Tests that an App Delegate which forwards the remote notification selectors, rather than @@ -1292,9 +1321,8 @@ - (void)testAppDelegateIsProxiedIncludingAPNSMethodsWhenEnabled { */ - (void)testForwardingAppDelegateReceivesRemoteNotificationsCallbacks { GULApplication *application = [GULApplication sharedApplication]; - GULForwardingTestAppDelegate *forwardingDelegate = - [[GULForwardingTestAppDelegate alloc] init]; - GULTestAppDelegate *realDelegate = forwardingDelegate.forwardingTarget; + GULForwardingTestAppDelegate *forwardingDelegate = [[GULForwardingTestAppDelegate alloc] init]; + GULTestAppDelegate *realDelegate = (GULTestAppDelegate *)forwardingDelegate.forwardingTarget; [GULApplication sharedApplication].delegate = forwardingDelegate; [GULAppDelegateSwizzler proxyOriginalDelegateIncludingAPNSMethods]; @@ -1307,17 +1335,15 @@ - (void)testForwardingAppDelegateReceivesRemoteNotificationsCallbacks { XCTAssertEqual(realDelegate.remoteNotificationsDeviceToken, deviceToken); NSError *error = [NSError errorWithDomain:@"test" code:-1 userInfo:nil]; - [proxiedDelegate application:application - didFailToRegisterForRemoteNotificationsWithError:error]; + [proxiedDelegate application:application didFailToRegisterForRemoteNotificationsWithError:error]; XCTAssertEqual(realDelegate.failToRegisterForRemoteNotificationsError, error); } /** Same, for the user activity callback, which is proxied without the APNS methods. */ - (void)testForwardingAppDelegateReceivesContinueUserActivity { GULApplication *application = [GULApplication sharedApplication]; - GULForwardingTestAppDelegate *forwardingDelegate = - [[GULForwardingTestAppDelegate alloc] init]; - GULTestAppDelegate *realDelegate = forwardingDelegate.forwardingTarget; + GULForwardingTestAppDelegate *forwardingDelegate = [[GULForwardingTestAppDelegate alloc] init]; + GULTestAppDelegate *realDelegate = (GULTestAppDelegate *)forwardingDelegate.forwardingTarget; [GULApplication sharedApplication].delegate = forwardingDelegate; [GULAppDelegateSwizzler proxyOriginalDelegate]; @@ -1335,8 +1361,7 @@ - (void)testForwardingAppDelegateReceivesContinueUserActivity { /** Interceptors must keep working for a forwarding App Delegate. */ - (void)testForwardingAppDelegateStillNotifiesInterceptors { GULApplication *application = [GULApplication sharedApplication]; - GULForwardingTestAppDelegate *forwardingDelegate = - [[GULForwardingTestAppDelegate alloc] init]; + GULForwardingTestAppDelegate *forwardingDelegate = [[GULForwardingTestAppDelegate alloc] init]; GULFakeAppDelegateInterceptor *interceptor = [[GULFakeAppDelegateInterceptor alloc] init]; [GULApplication sharedApplication].delegate = forwardingDelegate; @@ -1352,7 +1377,109 @@ - (void)testForwardingAppDelegateStillNotifiesInterceptors { isCalled = interceptor.isApplicationDidRegisterForRemoteNotificationsCalled; }); XCTAssertTrue(isCalled); - XCTAssertNotNil(forwardingDelegate.forwardingTarget.remoteNotificationsDeviceToken); + XCTAssertNotNil( + ((GULTestAppDelegate *)forwardingDelegate.forwardingTarget).remoteNotificationsDeviceToken); +} + +/** Tests that the value returned by the forwarded selector is propagated back to the caller. */ +- (void)testForwardingAppDelegatePropagatesReturnValue { + GULApplication *application = [GULApplication sharedApplication]; + NSURL *url = [NSURL URLWithString:@"https://example.com"]; + NSUserActivity *userActivity = [[NSUserActivity alloc] initWithActivityType:@"test"]; + + GULForwardingTestAppDelegate *returningDelegate = [[GULForwardingTestAppDelegate alloc] + initWithForwardingTarget:[[GULReturningTestAppDelegate alloc] init]]; + [GULApplication sharedApplication].delegate = returningDelegate; + [GULAppDelegateSwizzler proxyOriginalDelegate]; + + id proxiedReturningDelegate = + (id)returningDelegate; + XCTAssertTrue([proxiedReturningDelegate application:application openURL:url options:@{}]); + XCTAssertTrue([proxiedReturningDelegate application:application + continueUserActivity:userActivity + restorationHandler:^(NSArray *restorableObjects){ + }]); + + [GULAppDelegateSwizzler resetProxyOriginalDelegateOnceToken]; + + // GULTestAppDelegate returns NO from both, which must be propagated just the same. + GULForwardingTestAppDelegate *forwardingDelegate = [[GULForwardingTestAppDelegate alloc] init]; + [GULApplication sharedApplication].delegate = forwardingDelegate; + [GULAppDelegateSwizzler proxyOriginalDelegate]; + + id proxiedDelegate = (id)forwardingDelegate; + XCTAssertFalse([proxiedDelegate application:application openURL:url options:@{}]); + XCTAssertFalse([proxiedDelegate application:application + continueUserActivity:userActivity + restorationHandler:^(NSArray *restorableObjects){ + }]); +} + +/** Tests that the completion handler of a forwarded selector reaches the delegate unchanged. */ +- (void)testForwardingAppDelegateForwardsCompletionHandlers { + GULApplication *application = [GULApplication sharedApplication]; + GULForwardingTestAppDelegate *forwardingDelegate = [[GULForwardingTestAppDelegate alloc] init]; + GULTestAppDelegate *realDelegate = (GULTestAppDelegate *)forwardingDelegate.forwardingTarget; + + [GULApplication sharedApplication].delegate = forwardingDelegate; + [GULAppDelegateSwizzler proxyOriginalDelegateIncludingAPNSMethods]; + + id proxiedDelegate = (id)forwardingDelegate; + + XCTestExpectation *backgroundSessionCalled = + [self expectationWithDescription:@"background session completion handler"]; + [proxiedDelegate application:application + handleEventsForBackgroundURLSession:@"session" + completionHandler:^{ + [backgroundSessionCalled fulfill]; + }]; + XCTAssertNotNil(realDelegate.backgroundSessionCompletionHandler); + realDelegate.backgroundSessionCompletionHandler(); + + XCTestExpectation *remoteNotificationCalled = + [self expectationWithDescription:@"remote notification completion handler"]; + [proxiedDelegate application:application + didReceiveRemoteNotification:@{@"test" : @"test"} + fetchCompletionHandler:^(UIBackgroundFetchResult result) { + [remoteNotificationCalled fulfill]; + }]; + XCTAssertNotNil(realDelegate.remoteNotificationCompletionHandler); + realDelegate.remoteNotificationCompletionHandler(UIBackgroundFetchResultNewData); + + [self waitForExpectations:@[ backgroundSessionCalled, remoteNotificationCalled ] timeout:1]; +} + +/** Tests that a forwarding App Delegate whose target does not implement a selector falls through + * safely rather than raising. + */ +- (void)testForwardingAppDelegateWithoutImplementationDoesNotRaise { + GULApplication *application = [GULApplication sharedApplication]; + GULForwardingTestAppDelegate *forwardingDelegate = [[GULForwardingTestAppDelegate alloc] + initWithForwardingTarget:[[GULEmptyTestAppDelegate alloc] init]]; + + [GULApplication sharedApplication].delegate = forwardingDelegate; + [GULAppDelegateSwizzler proxyOriginalDelegateIncludingAPNSMethods]; + + id proxiedDelegate = (id)forwardingDelegate; + + XCTAssertNoThrow([proxiedDelegate application:application + didRegisterForRemoteNotificationsWithDeviceToken:[NSData data]]); + XCTAssertNoThrow([proxiedDelegate application:application + didFailToRegisterForRemoteNotificationsWithError:[NSError errorWithDomain:@"test" + code:-1 + userInfo:nil]]); + XCTAssertNoThrow([proxiedDelegate application:application + didReceiveRemoteNotification:@{} + fetchCompletionHandler:^(UIBackgroundFetchResult result){ + }]); + + __block BOOL continueUserActivityResult = YES; + XCTAssertNoThrow(continueUserActivityResult = [proxiedDelegate + application:application + continueUserActivity:[[NSUserActivity alloc] initWithActivityType:@"t"] + restorationHandler:^(NSArray *restorableObjects){ + }]); + XCTAssertFalse(continueUserActivityResult); } @end From bb9ec9979d6e4188c84977815316d8dda32cceeb Mon Sep 17 00:00:00 2001 From: tetek Date: Mon, 5 Oct 2026 14:58:44 +0200 Subject: [PATCH 3/5] Guard iOS/tvOS-only selectors in forwarding delegate tests Fixes the macOS build: openURL:options:, handleEventsForBackgroundURLSession: and the fetchCompletionHandler: variant aren't part of NSApplicationDelegate. --- .../Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m index d2cb6360..676c697e 100644 --- a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m +++ b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m @@ -1394,7 +1394,9 @@ - (void)testForwardingAppDelegatePropagatesReturnValue { id proxiedReturningDelegate = (id)returningDelegate; +#if TARGET_OS_IOS || TARGET_OS_TV XCTAssertTrue([proxiedReturningDelegate application:application openURL:url options:@{}]); +#endif // TARGET_OS_IOS || TARGET_OS_TV XCTAssertTrue([proxiedReturningDelegate application:application continueUserActivity:userActivity restorationHandler:^(NSArray *restorableObjects){ @@ -1408,13 +1410,16 @@ - (void)testForwardingAppDelegatePropagatesReturnValue { [GULAppDelegateSwizzler proxyOriginalDelegate]; id proxiedDelegate = (id)forwardingDelegate; +#if TARGET_OS_IOS || TARGET_OS_TV XCTAssertFalse([proxiedDelegate application:application openURL:url options:@{}]); +#endif // TARGET_OS_IOS || TARGET_OS_TV XCTAssertFalse([proxiedDelegate application:application continueUserActivity:userActivity restorationHandler:^(NSArray *restorableObjects){ }]); } +#if TARGET_OS_IOS || TARGET_OS_TV /** Tests that the completion handler of a forwarded selector reaches the delegate unchanged. */ - (void)testForwardingAppDelegateForwardsCompletionHandlers { GULApplication *application = [GULApplication sharedApplication]; @@ -1448,6 +1453,7 @@ - (void)testForwardingAppDelegateForwardsCompletionHandlers { [self waitForExpectations:@[ backgroundSessionCalled, remoteNotificationCalled ] timeout:1]; } +#endif // TARGET_OS_IOS || TARGET_OS_TV /** Tests that a forwarding App Delegate whose target does not implement a selector falls through * safely rather than raising. @@ -1468,10 +1474,12 @@ - (void)testForwardingAppDelegateWithoutImplementationDoesNotRaise { didFailToRegisterForRemoteNotificationsWithError:[NSError errorWithDomain:@"test" code:-1 userInfo:nil]]); +#if TARGET_OS_IOS || TARGET_OS_TV XCTAssertNoThrow([proxiedDelegate application:application didReceiveRemoteNotification:@{} fetchCompletionHandler:^(UIBackgroundFetchResult result){ }]); +#endif // TARGET_OS_IOS || TARGET_OS_TV __block BOOL continueUserActivityResult = YES; XCTAssertNoThrow(continueUserActivityResult = [proxiedDelegate From 792388a5fd74752a7dd5c1f964fd520290a46aab Mon Sep 17 00:00:00 2001 From: tetek Date: Tue, 6 Oct 2026 13:42:37 +0200 Subject: [PATCH 4/5] Fix unused variable warning in macOS pod lib lint --- .../Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m index 676c697e..52c6573f 100644 --- a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m +++ b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m @@ -1384,7 +1384,9 @@ - (void)testForwardingAppDelegateStillNotifiesInterceptors { /** Tests that the value returned by the forwarded selector is propagated back to the caller. */ - (void)testForwardingAppDelegatePropagatesReturnValue { GULApplication *application = [GULApplication sharedApplication]; +#if TARGET_OS_IOS || TARGET_OS_TV NSURL *url = [NSURL URLWithString:@"https://example.com"]; +#endif // TARGET_OS_IOS || TARGET_OS_TV NSUserActivity *userActivity = [[NSUserActivity alloc] initWithActivityType:@"test"]; GULForwardingTestAppDelegate *returningDelegate = [[GULForwardingTestAppDelegate alloc] From e7a7c8430fc070ae06da2221a8d3633195c3aea6 Mon Sep 17 00:00:00 2001 From: tetek Date: Wed, 7 Oct 2026 09:18:49 +0200 Subject: [PATCH 5/5] Address review: balance dispatch group, harden forwarding tests - Enter the callback group and call forwardSelector: once, leaving the group if nothing was forwarded, so the system completion handler always runs. - Guard stored completion handlers against nil in tests and assert the forwarded fetch result. - Add a test merging an interceptor's fetch result with a forwarding delegate. - Wrap the CHANGELOG entry at 80 columns. --- CHANGELOG.md | 5 +- .../GULAppDelegateSwizzler.m | 24 +++++----- .../Swizzler/GULAppDelegateSwizzlerTest.m | 48 ++++++++++++++++++- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3267d034..888592ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased -- [fixed] Deliver App Delegate callbacks to delegates that handle them by forwarding rather than by - implementing them, such as the one SwiftUI installs for `UIApplicationDelegateAdaptor`. (#247) +- [fixed] Deliver App Delegate callbacks to delegates that handle them by + forwarding rather than by implementing them, such as the one SwiftUI installs + for `UIApplicationDelegateAdaptor`. (#247) # 8.1.4 - [fixed] `GULMutableDictionary` now ignores a `nil` key (and a `nil` object in diff --git a/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m b/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m index 6e2fe212..f540ecf9 100644 --- a/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m +++ b/GoogleUtilities/AppDelegateSwizzler/GULAppDelegateSwizzler.m @@ -998,17 +998,19 @@ - (void)application:(GULApplication *)application didReceiveRemoteNotificationWithCompletionIMP(self, methodSelector, application, userInfo, localCompletionHandler); } else { - if ([GULAppDelegateSwizzler forwardingTargetForProxiedSelector:methodSelector object:self]) { - dispatch_group_enter(callbackGroup); - - [GULAppDelegateSwizzler - forwardSelector:methodSelector - fromObject:self - argumentSetter:^(NSInvocation *invocation) { - [invocation setArgument:(void *)(&application) atIndex:2]; - [invocation setArgument:(void *)(&userInfo) atIndex:3]; - [invocation setArgument:(void *)(&localCompletionHandler) atIndex:4]; - }]; + dispatch_group_enter(callbackGroup); + + NSInvocation *forwardedInvocation = [GULAppDelegateSwizzler + forwardSelector:methodSelector + fromObject:self + argumentSetter:^(NSInvocation *invocation) { + [invocation setArgument:(void *)(&application) atIndex:2]; + [invocation setArgument:(void *)(&userInfo) atIndex:3]; + [invocation setArgument:(void *)(&localCompletionHandler) atIndex:4]; + }]; + // Nothing was forwarded, so the completion handler will never leave the group. + if (forwardedInvocation == nil) { + dispatch_group_leave(callbackGroup); } } diff --git a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m index 52c6573f..b58cc738 100644 --- a/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m +++ b/GoogleUtilities/Tests/Unit/Swizzler/GULAppDelegateSwizzlerTest.m @@ -1441,22 +1441,66 @@ - (void)testForwardingAppDelegateForwardsCompletionHandlers { [backgroundSessionCalled fulfill]; }]; XCTAssertNotNil(realDelegate.backgroundSessionCompletionHandler); - realDelegate.backgroundSessionCompletionHandler(); + if (realDelegate.backgroundSessionCompletionHandler) { + realDelegate.backgroundSessionCompletionHandler(); + } XCTestExpectation *remoteNotificationCalled = [self expectationWithDescription:@"remote notification completion handler"]; [proxiedDelegate application:application didReceiveRemoteNotification:@{@"test" : @"test"} fetchCompletionHandler:^(UIBackgroundFetchResult result) { + XCTAssertEqual(result, UIBackgroundFetchResultNewData); [remoteNotificationCalled fulfill]; }]; XCTAssertNotNil(realDelegate.remoteNotificationCompletionHandler); - realDelegate.remoteNotificationCompletionHandler(UIBackgroundFetchResultNewData); + if (realDelegate.remoteNotificationCompletionHandler) { + realDelegate.remoteNotificationCompletionHandler(UIBackgroundFetchResultNewData); + } [self waitForExpectations:@[ backgroundSessionCalled, remoteNotificationCalled ] timeout:1]; } #endif // TARGET_OS_IOS || TARGET_OS_TV +#if (TARGET_OS_IOS || TARGET_OS_TV) && !TARGET_OS_MACCATALYST +/** Tests that the fetch results of interceptors and a forwarding App Delegate are merged into a + * single call of the original completion handler. + */ +- (void)testForwardingAppDelegateMergesFetchResultWithInterceptors { + GULApplication *application = [GULApplication sharedApplication]; + GULForwardingTestAppDelegate *forwardingDelegate = [[GULForwardingTestAppDelegate alloc] init]; + GULTestAppDelegate *realDelegate = (GULTestAppDelegate *)forwardingDelegate.forwardingTarget; + + GULFakeAppDelegateInterceptor *interceptor = [[GULFakeAppDelegateInterceptor alloc] init]; + interceptor.onDidReceiveRemoteNotificationWithCompletion = + ^(NSDictionary *userInfo, void (^completionHandler)(UIBackgroundFetchResult)) { + completionHandler(UIBackgroundFetchResultNoData); + }; + + [GULApplication sharedApplication].delegate = forwardingDelegate; + [GULAppDelegateSwizzler proxyOriginalDelegateIncludingAPNSMethods]; + [GULAppDelegateSwizzler registerAppDelegateInterceptor:interceptor]; + + XCTestExpectation *completionCalled = [self expectationWithDescription:@"Completion called once"]; + completionCalled.assertForOverFulfill = YES; + + id proxiedDelegate = (id)forwardingDelegate; + [proxiedDelegate application:application + didReceiveRemoteNotification:@{@"test" : @"test"} + fetchCompletionHandler:^(UIBackgroundFetchResult result) { + XCTAssertEqual(result, UIBackgroundFetchResultNewData); + [completionCalled fulfill]; + }]; + + XCTAssertNotNil(realDelegate.remoteNotificationCompletionHandler); + if (realDelegate.remoteNotificationCompletionHandler) { + realDelegate.remoteNotificationCompletionHandler(UIBackgroundFetchResultNewData); + } + + [self waitForExpectations:@[ completionCalled ] timeout:1]; +} +#endif // (TARGET_OS_IOS || TARGET_OS_TV) && !TARGET_OS_MACCATALYST + /** Tests that a forwarding App Delegate whose target does not implement a selector falls through * safely rather than raising. */