diff --git a/CHANGELOG.md b/CHANGELOG.md index 60be265..c60b2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# Unreleased +- [feature] Added proxy support for `scene:willConnectToSession:options:` and + `scene:continueUserActivity:` to `GULSceneDelegateSwizzler`. Existing implementations + will override this behavior, so current scene delegate implementations will + continue to work as expected. (#242) +- [fixed] Fixed a potentional race-condition where `GULSceneDelegateSwizzler` + could miss being configured on scene delegates if they were spawned before + the proxy was ready. (#242) + # 8.1.2 - [fixed] Resolve EXC_BAD_ACCESS in GULNetworkURLSession via O(1) passive memory lifecycle cleanup. (#233) diff --git a/GoogleUtilities/AppDelegateSwizzler/GULSceneDelegateSwizzler.m b/GoogleUtilities/AppDelegateSwizzler/GULSceneDelegateSwizzler.m index 87adac4..b6f7aec 100644 --- a/GoogleUtilities/AppDelegateSwizzler/GULSceneDelegateSwizzler.m +++ b/GoogleUtilities/AppDelegateSwizzler/GULSceneDelegateSwizzler.m @@ -29,6 +29,13 @@ API_AVAILABLE(ios(13.0), tvos(13.0)) typedef void (*GULOpenURLContextsIMP)(id, SEL, UIScene *, NSSet *); +API_AVAILABLE(ios(13.0), tvos(13.0)) +typedef void (*GULWillConnectToSessionIMP)( + id, SEL, UIScene *, UISceneSession *, UISceneConnectionOptions *); + +API_AVAILABLE(ios(13.0), tvos(13.0)) +typedef void (*GULContinueUserActivityIMP)(id, SEL, UIScene *, NSUserActivity *); + API_AVAILABLE(ios(13.0), tvos(13.0)) typedef void (^GULSceneDelegateInterceptorCallback)(id); @@ -96,6 +103,18 @@ + (void)proxyOriginalSceneDelegate { selector:@selector(handleSceneWillConnectToNotification:) name:UISceneWillConnectNotification object:nil]; + + // For catching scene delegates that were already connected when swizzling started, + // which may or may not be an actual problem. Not sure, but better safe than sorry. + id sharedApplication = [GULAppDelegateSwizzler sharedApplication]; + if ([sharedApplication respondsToSelector:@selector(openSessions)]) { + NSSet *sessions = [sharedApplication openSessions]; + for (UISceneSession *session in sessions) { + if (session.scene) { + [GULSceneDelegateSwizzler proxySceneDelegateIfNeeded:session.scene]; + } + } + } } }); #endif // UISCENE_SUPPORTED @@ -327,12 +346,7 @@ - (void)scene:(UIScene *)scene [GULSceneDelegateSwizzler notifyInterceptorsWithMethodSelector:methodSelector callback:^(id interceptor) { - if ([interceptor - conformsToProtocol:@protocol(UISceneDelegate)]) { - id sceneInterceptor = - (id)interceptor; - [sceneInterceptor scene:scene openURLContexts:URLContexts]; - } + [interceptor scene:scene openURLContexts:URLContexts]; }]; if (openURLContextsIMP) { @@ -341,6 +355,50 @@ - (void)scene:(UIScene *)scene } } +- (void)scene:(UIScene *)scene + willConnectToSession:(UISceneSession *)session + options:(UISceneConnectionOptions *)connectionOptions + API_AVAILABLE(ios(13.0), tvos(13.0)) { + SEL methodSelector = @selector(scene:willConnectToSession:options:); + NSValue *willConnectToSessionIMPPointer = + [GULSceneDelegateSwizzler originalImplementationForSelector:methodSelector object:self]; + GULWillConnectToSessionIMP willConnectToSessionIMP = + [willConnectToSessionIMPPointer pointerValue]; + + [GULSceneDelegateSwizzler + notifyInterceptorsWithMethodSelector:methodSelector + callback:^(id interceptor) { + [interceptor scene:scene + willConnectToSession:session + options:connectionOptions]; + }]; + + // Call the real implementation if the real Scene Delegate has any. + if (willConnectToSessionIMP) { + willConnectToSessionIMP(self, methodSelector, scene, session, connectionOptions); + } +} + +- (void)scene:(UIScene *)scene + continueUserActivity:(NSUserActivity *)userActivity API_AVAILABLE(ios(13.0), tvos(13.0)) { + SEL methodSelector = @selector(scene:continueUserActivity:); + NSValue *continueUserActivityIMPPointer = + [GULSceneDelegateSwizzler originalImplementationForSelector:methodSelector object:self]; + GULContinueUserActivityIMP continueUserActivityIMP = + [continueUserActivityIMPPointer pointerValue]; + + [GULSceneDelegateSwizzler + notifyInterceptorsWithMethodSelector:methodSelector + callback:^(id interceptor) { + [interceptor scene:scene continueUserActivity:userActivity]; + }]; + + // Call the real implementation if the real Scene Delegate has any. + if (continueUserActivityIMP) { + continueUserActivityIMP(self, methodSelector, scene, userActivity); + } +} + + (void)proxySceneDelegateIfNeeded:(UIScene *)scene { Class realClass = [scene.delegate class]; NSString *className = NSStringFromClass(realClass); @@ -396,6 +454,24 @@ + (void)proxySceneDelegateIfNeeded:(UIScene *)scene { realClass:realClass storeDestinationImplementationTo:realImplementationsBySelector]; + // For scene:willConnectToSession:options: + SEL willConnectToSessionSEL = @selector(scene:willConnectToSession:options:); + [self proxyDestinationSelector:willConnectToSessionSEL + implementationsFromSourceSelector:willConnectToSessionSEL + fromClass:[GULSceneDelegateSwizzler class] + toClass:sceneDelegateSubClass + realClass:realClass + storeDestinationImplementationTo:realImplementationsBySelector]; + + // For scene:continueUserActivity: + SEL continueUserActivitySEL = @selector(scene:continueUserActivity:); + [self proxyDestinationSelector:continueUserActivitySEL + implementationsFromSourceSelector:continueUserActivitySEL + fromClass:[GULSceneDelegateSwizzler class] + toClass:sceneDelegateSubClass + realClass:realClass + storeDestinationImplementationTo:realImplementationsBySelector]; + // Store original implementations to a fake property of the original delegate. objc_setAssociatedObject(scene.delegate, &kGULRealIMPBySelectorKey, [realImplementationsBySelector copy], OBJC_ASSOCIATION_RETAIN_NONATOMIC); diff --git a/GoogleUtilities/Tests/Unit/Swizzler/GULSceneDelegateSwizzlerTest.m b/GoogleUtilities/Tests/Unit/Swizzler/GULSceneDelegateSwizzlerTest.m index 52630ac..9253aef 100644 --- a/GoogleUtilities/Tests/Unit/Swizzler/GULSceneDelegateSwizzlerTest.m +++ b/GoogleUtilities/Tests/Unit/Swizzler/GULSceneDelegateSwizzlerTest.m @@ -45,11 +45,39 @@ @interface GULTestSceneDelegate : NSObject @implementation GULTestSceneDelegate @end +API_AVAILABLE(ios(13.0), tvos(13.0)) +@interface GULImplementingTestSceneDelegate : NSObject +@property(nonatomic, assign) BOOL openURLContextsInvoked; +@property(nonatomic, assign) BOOL willConnectToSessionOptionsInvoked; +@property(nonatomic, assign) BOOL continueUserActivityInvoked; +@end + +@implementation GULImplementingTestSceneDelegate +- (void)scene:(UIScene *)scene openURLContexts:(NSSet *)URLContexts { + _openURLContextsInvoked = YES; +} +- (void)scene:(UIScene *)scene + willConnectToSession:(UISceneSession *)session + options:(UISceneConnectionOptions *)connectionOptions { + _willConnectToSessionOptionsInvoked = YES; +} +- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity { + _continueUserActivityInvoked = YES; +} +@end + @interface GULSceneDelegateSwizzlerTest : XCTestCase @end @implementation GULSceneDelegateSwizzlerTest +- (void)tearDown { + if (@available(iOS 13.0, tvOS 13.0, *)) { + [GULSceneDelegateSwizzler clearInterceptors]; + } + [super tearDown]; +} + - (void)testProxySceneDelegateWithNoSceneDelegate { if (@available(iOS 13, tvOS 13, *)) { id mockSharedScene = OCMClassMock([UIScene class]); @@ -85,6 +113,9 @@ - (void)testProxySceneDelegate { // After being proxied, it should be able to respond to the required method selector. XCTAssertTrue([realSceneDelegate respondsToSelector:@selector(scene:openURLContexts:)]); + XCTAssertTrue( + [realSceneDelegate respondsToSelector:@selector(scene:willConnectToSession:options:)]); + XCTAssertTrue([realSceneDelegate respondsToSelector:@selector(scene:continueUserActivity:)]); // Make sure that the class has changed. XCTAssertNotEqualObjects([realSceneDelegate class], realSceneDelegateClassBefore); @@ -144,6 +175,118 @@ - (void)testSceneOpenURLContextsIsInvokedOnInterceptors { } } +- (void)testSceneWillConnectToSessionOptionsIsInvokedOnInterceptors { + if (@available(iOS 13, tvOS 13, *)) { + id mockSession = OCMClassMock([UISceneSession class]); + id mockConnectionOptions = OCMClassMock([UISceneConnectionOptions class]); + + GULTestSceneDelegate *realSceneDelegate = [[GULTestSceneDelegate alloc] init]; + id mockSharedScene = OCMClassMock([UIScene class]); + OCMStub([mockSharedScene delegate]).andReturn(realSceneDelegate); + + id interceptor = OCMProtocolMock(@protocol(TestSceneProtocol)); + OCMExpect([interceptor scene:mockSharedScene + willConnectToSession:mockSession + options:mockConnectionOptions]); + + id interceptor2 = OCMProtocolMock(@protocol(TestSceneProtocol)); + OCMExpect([interceptor2 scene:mockSharedScene + willConnectToSession:mockSession + options:mockConnectionOptions]); + + [GULSceneDelegateSwizzler proxySceneDelegateIfNeeded:mockSharedScene]; + + [GULSceneDelegateSwizzler registerSceneDelegateInterceptor:interceptor]; + [GULSceneDelegateSwizzler registerSceneDelegateInterceptor:interceptor2]; + + [realSceneDelegate scene:mockSharedScene + willConnectToSession:mockSession + options:mockConnectionOptions]; + OCMVerifyAll(interceptor); + OCMVerifyAll(interceptor2); + + [mockSharedScene stopMocking]; + [mockSession stopMocking]; + [mockConnectionOptions stopMocking]; + mockSharedScene = nil; + mockSession = nil; + mockConnectionOptions = nil; + } +} + +- (void)testSceneContinueUserActivityIsInvokedOnInterceptors { + if (@available(iOS 13, tvOS 13, *)) { + NSUserActivity *userActivity = + [[NSUserActivity alloc] initWithActivityType:NSUserActivityTypeBrowsingWeb]; + + GULTestSceneDelegate *realSceneDelegate = [[GULTestSceneDelegate alloc] init]; + id mockSharedScene = OCMClassMock([UIScene class]); + OCMStub([mockSharedScene delegate]).andReturn(realSceneDelegate); + + id interceptor = OCMProtocolMock(@protocol(TestSceneProtocol)); + OCMExpect([interceptor scene:mockSharedScene continueUserActivity:userActivity]); + + id interceptor2 = OCMProtocolMock(@protocol(TestSceneProtocol)); + OCMExpect([interceptor2 scene:mockSharedScene continueUserActivity:userActivity]); + + [GULSceneDelegateSwizzler proxySceneDelegateIfNeeded:mockSharedScene]; + + [GULSceneDelegateSwizzler registerSceneDelegateInterceptor:interceptor]; + [GULSceneDelegateSwizzler registerSceneDelegateInterceptor:interceptor2]; + + [realSceneDelegate scene:mockSharedScene continueUserActivity:userActivity]; + OCMVerifyAll(interceptor); + OCMVerifyAll(interceptor2); + + [mockSharedScene stopMocking]; + mockSharedScene = nil; + } +} + +- (void)testDonorMethodsInvokeOriginalSceneDelegateImplementations { + if (@available(iOS 13, tvOS 13, *)) { + GULImplementingTestSceneDelegate *realSceneDelegate = + [[GULImplementingTestSceneDelegate alloc] init]; + id mockSharedScene = OCMClassMock([UIScene class]); + OCMStub([mockSharedScene delegate]).andReturn(realSceneDelegate); + + id interceptor = OCMProtocolMock(@protocol(TestSceneProtocol)); + OCMExpect([interceptor scene:mockSharedScene openURLContexts:[NSSet set]]); + + [GULSceneDelegateSwizzler proxySceneDelegateIfNeeded:mockSharedScene]; + [GULSceneDelegateSwizzler registerSceneDelegateInterceptor:interceptor]; + + [realSceneDelegate scene:mockSharedScene openURLContexts:[NSSet set]]; + OCMVerifyAll(interceptor); + XCTAssertTrue(realSceneDelegate.openURLContextsInvoked); + + id mockSession = OCMClassMock([UISceneSession class]); + id mockConnectionOptions = OCMClassMock([UISceneConnectionOptions class]); + OCMExpect([interceptor scene:mockSharedScene + willConnectToSession:mockSession + options:mockConnectionOptions]); + [realSceneDelegate scene:mockSharedScene + willConnectToSession:mockSession + options:mockConnectionOptions]; + OCMVerifyAll(interceptor); + XCTAssertTrue(realSceneDelegate.willConnectToSessionOptionsInvoked); + + NSUserActivity *userActivity = + [[NSUserActivity alloc] initWithActivityType:NSUserActivityTypeBrowsingWeb]; + OCMExpect([interceptor scene:mockSharedScene continueUserActivity:userActivity]); + [realSceneDelegate scene:mockSharedScene continueUserActivity:userActivity]; + OCMVerifyAll(interceptor); + XCTAssertTrue(realSceneDelegate.continueUserActivityInvoked); + + [mockSharedScene stopMocking]; + [mockSession stopMocking]; + [mockConnectionOptions stopMocking]; + mockSharedScene = nil; + mockSession = nil; + mockConnectionOptions = nil; + } +} + #if !TARGET_OS_MACCATALYST // Test fails on Catalyst.