diff --git a/FirebaseCore/Extension/FirebaseCoreInternal.h b/FirebaseCore/Extension/FirebaseCoreInternal.h index 25610089c08..671a85e098c 100644 --- a/FirebaseCore/Extension/FirebaseCoreInternal.h +++ b/FirebaseCore/Extension/FirebaseCoreInternal.h @@ -24,5 +24,6 @@ #import "FIRHeartbeatLogger.h" #import "FIRLibrary.h" #import "FIRLogger.h" +#import "UIApplication+FIRSceneDelegateFinder.h" #endif // FIREBASECORE_FIREBASECOREINTERNAL_H diff --git a/FirebaseCore/Extension/UIApplication+FIRSceneDelegateFinder.h b/FirebaseCore/Extension/UIApplication+FIRSceneDelegateFinder.h new file mode 100644 index 00000000000..bf1de741914 --- /dev/null +++ b/FirebaseCore/Extension/UIApplication+FIRSceneDelegateFinder.h @@ -0,0 +1,62 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIApplication (FIRSceneDelegateFinder) + +/** + Iterates through the connectedScenes of the specified application instance to find a + foreground scene whose delegate responds to the specified selector. Prioritizes + `UISceneActivationStateForegroundActive` scenes over `UISceneActivationStateForegroundInactive` + scenes. + + Note that a scene in the `ForegroundInactive` state is visible and loaded + in the foreground, but is temporarily not receiving touch events for whatever + reason (eg; because a system dialog, permission prompt, or notification center + overlay is covering it). It's totally valid to send events through these scenes, + so we fall back to checking if these scenes exist if we don't find a better + alternative (ie; a scene that's in the foregound _and_ active). + + ### Usage Example + ```objc + SEL selector = @selector(scene:continueUserActivity:); + UIScene *targetScene = [UIApplication + fir_findForegroundSceneWithDelegateRespondingToSelector:selector + onApplication:self.mainApplication]; + + if (targetScene) { + [targetScene.delegate scene:targetScene continueUserActivity:userActivity]; + } + ``` + + @param selector The selector to find a scene delegate for (e.g. + `@selector(scene:continueUserActivity:)`). + @param application UIApplication instance to search for scenes from. + @return The matching UIScene instance, or nil if no matching scene delegate is found. + */ ++ (nullable UIScene *) + fir_findForegroundSceneWithDelegateRespondingToSelector:(SEL)selector + onApplication:(nullable UIApplication *)application; + +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION diff --git a/FirebaseCore/Sources/UIApplication+FIRSceneDelegateFinder.m b/FirebaseCore/Sources/UIApplication+FIRSceneDelegateFinder.m new file mode 100644 index 00000000000..42e2a9a3103 --- /dev/null +++ b/FirebaseCore/Sources/UIApplication+FIRSceneDelegateFinder.m @@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION + +#import "FirebaseCore/Extension/UIApplication+FIRSceneDelegateFinder.h" + +@implementation UIApplication (FIRSceneDelegateFinder) + ++ (nullable UIScene *)fir_findForegroundSceneWithDelegateRespondingToSelector:(SEL)selector { + return [self fir_findForegroundSceneWithDelegateRespondingToSelector:selector onApplication:nil]; +} + ++ (nullable UIScene *) + fir_findForegroundSceneWithDelegateRespondingToSelector:(SEL)selector + onApplication:(UIApplication *)application { + UIApplication *app = application; + + if (!app) { + return nil; + } + + UIScene *targetScene = nil; + + for (UIScene *scene in app.connectedScenes) { + id sceneDelegate = scene.delegate; + if ([sceneDelegate respondsToSelector:selector]) { + if (scene.activationState == UISceneActivationStateForegroundActive) { + targetScene = scene; + break; + } else if (scene.activationState == UISceneActivationStateForegroundInactive) { + targetScene = scene; + } + } + } + + return targetScene; +} + +@end + +#endif // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION diff --git a/FirebaseCore/Tests/Unit/UIApplication+FIRSceneDelegateFinderTests.m b/FirebaseCore/Tests/Unit/UIApplication+FIRSceneDelegateFinderTests.m new file mode 100644 index 00000000000..e4931fdef76 --- /dev/null +++ b/FirebaseCore/Tests/Unit/UIApplication+FIRSceneDelegateFinderTests.m @@ -0,0 +1,109 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION + +#import "FirebaseCore/Tests/Unit/FIRTestCase.h" + +#import "FirebaseCore/Extension/UIApplication+FIRSceneDelegateFinder.h" + +@interface MockSceneDelegate : NSObject +- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity; +@end + +@implementation MockSceneDelegate +- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity { +} +@end + +@interface UIApplication_FIRSceneDelegateFinderTests : FIRTestCase +@property(nonatomic, strong) id mockApplication; +@end + +@implementation UIApplication_FIRSceneDelegateFinderTests + +- (void)setUp { + [super setUp]; + self.mockApplication = OCMClassMock([UIApplication class]); +} + +- (void)tearDown { + [self.mockApplication stopMocking]; + [super tearDown]; +} + +- (void)testNoMatchingScene { + id mockScene = OCMClassMock([UIScene class]); + OCMStub([mockScene activationState]).andReturn(UISceneActivationStateForegroundActive); + // Delegate does NOT respond to target selector + id mockDelegate = OCMProtocolMock(@protocol(UIApplicationDelegate)); + OCMStub([mockScene delegate]).andReturn(mockDelegate); + + NSSet *connectedScenes = [NSSet setWithObject:mockScene]; + OCMStub([self.mockApplication connectedScenes]).andReturn(connectedScenes); + + UIScene *result = [UIApplication + fir_findForegroundSceneWithDelegateRespondingToSelector:@selector(scene:continueUserActivity:) + onApplication:self.mockApplication]; + XCTAssertNil(result); +} + +- (void)testForegroundInactiveFallback { + // Scene A is background + id mockSceneA = OCMClassMock([UIScene class]); + OCMStub([mockSceneA activationState]).andReturn(UISceneActivationStateBackground); + id mockDelegateA = OCMClassMock([MockSceneDelegate class]); + OCMStub([mockSceneA delegate]).andReturn(mockDelegateA); + + // Scene B is foreground inactive (implements selector) + id mockSceneB = OCMClassMock([UIScene class]); + OCMStub([mockSceneB activationState]).andReturn(UISceneActivationStateForegroundInactive); + id mockDelegateB = OCMClassMock([MockSceneDelegate class]); + OCMStub([mockSceneB delegate]).andReturn(mockDelegateB); + + NSSet *connectedScenes = [NSSet setWithObjects:mockSceneA, mockSceneB, nil]; + OCMStub([self.mockApplication connectedScenes]).andReturn(connectedScenes); + + UIScene *result = [UIApplication + fir_findForegroundSceneWithDelegateRespondingToSelector:@selector(scene:continueUserActivity:) + onApplication:self.mockApplication]; + XCTAssertEqual(result, mockSceneB); +} + +- (void)testForegroundActivePriority { + // Scene A is foreground inactive + id mockSceneA = OCMClassMock([UIScene class]); + OCMStub([mockSceneA activationState]).andReturn(UISceneActivationStateForegroundInactive); + id mockDelegateA = OCMClassMock([MockSceneDelegate class]); + OCMStub([mockSceneA delegate]).andReturn(mockDelegateA); + + // Scene B is foreground active + id mockSceneB = OCMClassMock([UIScene class]); + OCMStub([mockSceneB activationState]).andReturn(UISceneActivationStateForegroundActive); + id mockDelegateB = OCMClassMock([MockSceneDelegate class]); + OCMStub([mockSceneB delegate]).andReturn(mockDelegateB); + + NSSet *connectedScenes = [NSSet setWithObjects:mockSceneA, mockSceneB, nil]; + OCMStub([self.mockApplication connectedScenes]).andReturn(connectedScenes); + + UIScene *result = [UIApplication + fir_findForegroundSceneWithDelegateRespondingToSelector:@selector(scene:continueUserActivity:) + onApplication:self.mockApplication]; + XCTAssertEqual(result, mockSceneB); +} + +@end + +#endif // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION