This is an automated email from the ASF dual-hosted git repository.
GitToTheHub pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cordova-ios.git
The following commit(s) were added to refs/heads/master by this push:
new cbffdab9 fix(CDVSceneDelegate): deliver universal links on cold launch
(#1717)
cbffdab9 is described below
commit cbffdab9813c9eb39a6d8eecada82a9fd2ee5201
Author: Maxim Belov <[email protected]>
AuthorDate: Wed Sep 23 16:16:46 2026 +0200
fix(CDVSceneDelegate): deliver universal links on cold launch (#1717)
Buffer the user activity from scene connection options until
CDVPageDidLoadNotification, then forward it to plugin observers.
Clear pending activity when the scene disconnects.
Add tests for deferred delivery, empty activities, disconnect cleanup,
warm delivery, and combined URL and user activity launches.
Generated-By: Claude Opus 5
---
CordovaLib/Classes/Public/CDVSceneDelegate.m | 22 +++-
tests/CordovaLibTests/CDVSceneDelegateTests.m | 158 ++++++++++++++++++++++++++
2 files changed, 179 insertions(+), 1 deletion(-)
diff --git a/CordovaLib/Classes/Public/CDVSceneDelegate.m
b/CordovaLib/Classes/Public/CDVSceneDelegate.m
index 9df26697..81b40877 100644
--- a/CordovaLib/Classes/Public/CDVSceneDelegate.m
+++ b/CordovaLib/Classes/Public/CDVSceneDelegate.m
@@ -25,6 +25,7 @@
// URL contexts captured on a cold launch, held until the page loads so that
// CDVHandleOpenURL has registered its observer before we post the
notification.
@property (nonatomic, strong, nullable) NSSet<UIOpenURLContext *>
*launchURLContexts;
+@property (nonatomic, strong, nullable) NSUserActivity *launchUserActivity;
@property (nonatomic, weak, nullable) UIScene *launchScene;
@end
@@ -44,6 +45,18 @@
if (connectionOptions.URLContexts.count > 0) {
self.launchScene = scene;
self.launchURLContexts = connectionOptions.URLContexts;
+ }
+
+ // A universal link launch arrives as a user activity rather than a URL,
and
+ // connectionOptions.userActivities is the only place it appears:
-scene:continueUserActivity:
+ // is called for an app that is already running, never for one being
launched. Buffer it the
+ // same way, so a cold launch reaches the plugins that handle it.
+ if (connectionOptions.userActivities.count > 0) {
+ self.launchScene = scene;
+ self.launchUserActivity = connectionOptions.userActivities.anyObject;
+ }
+
+ if (self.launchURLContexts != nil || self.launchUserActivity != nil) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(pageDidLoad:)
name:CDVPageDidLoadNotification
@@ -57,22 +70,29 @@
UIScene *scene = self.launchScene;
NSSet<UIOpenURLContext *> *contexts = self.launchURLContexts;
+ NSUserActivity *userActivity = self.launchUserActivity;
self.launchScene = nil;
self.launchURLContexts = nil;
+ self.launchUserActivity = nil;
if (scene != nil && contexts != nil) {
[self scene:scene openURLContexts:contexts];
}
+
+ if (scene != nil && userActivity != nil) {
+ [self scene:scene continueUserActivity:userActivity];
+ }
}
- (void)sceneDidDisconnect:(UIScene *)scene
{
// If the scene disconnects before the page loaded, tear down the pending
// launch-URL observer so it cannot fire for a defunct scene.
- if (self.launchURLContexts != nil) {
+ if (self.launchURLContexts != nil || self.launchUserActivity != nil) {
[[NSNotificationCenter defaultCenter] removeObserver:self
name:CDVPageDidLoadNotification object:nil];
self.launchScene = nil;
self.launchURLContexts = nil;
+ self.launchUserActivity = nil;
}
}
diff --git a/tests/CordovaLibTests/CDVSceneDelegateTests.m
b/tests/CordovaLibTests/CDVSceneDelegateTests.m
index 2a106752..4f209e88 100644
--- a/tests/CordovaLibTests/CDVSceneDelegateTests.m
+++ b/tests/CordovaLibTests/CDVSceneDelegateTests.m
@@ -45,17 +45,30 @@
@interface CDVFakeSceneConnectionOptions : UISceneConnectionOptions
@property (nonatomic, strong) NSSet<UIOpenURLContext *> *fakeURLContexts;
+@property (nonatomic, strong) NSSet<NSUserActivity *> *fakeUserActivities;
+ (instancetype)optionsWithURLContexts:(NSSet<UIOpenURLContext *> *)contexts;
++ (instancetype)optionsWithUserActivities:(NSSet<NSUserActivity *>
*)userActivities;
++ (instancetype)optionsWithURLContexts:(NSSet<UIOpenURLContext *> *)contexts
userActivities:(NSSet<NSUserActivity *> *)userActivities;
@end
@implementation CDVFakeSceneConnectionOptions
+ (instancetype)optionsWithURLContexts:(NSSet<UIOpenURLContext *> *)contexts
+{
+ return [self optionsWithURLContexts:contexts userActivities:[NSSet set]];
+}
++ (instancetype)optionsWithUserActivities:(NSSet<NSUserActivity *>
*)userActivities
+{
+ return [self optionsWithURLContexts:[NSSet set]
userActivities:userActivities];
+}
++ (instancetype)optionsWithURLContexts:(NSSet<UIOpenURLContext *> *)contexts
userActivities:(NSSet<NSUserActivity *> *)userActivities
{
CDVFakeSceneConnectionOptions *options = class_createInstance(self, 0);
options.fakeURLContexts = contexts;
+ options.fakeUserActivities = userActivities;
return options;
}
- (NSSet<UIOpenURLContext *> *)URLContexts { return self.fakeURLContexts; }
+- (NSSet<NSUserActivity *> *)userActivities { return self.fakeUserActivities; }
@end
#pragma mark -
@@ -98,6 +111,21 @@
return [CDVFakeSceneConnectionOptions optionsWithURLContexts:[NSSet
setWithObject:context]];
}
+// A universal link is delivered as an NSUserActivity of type
+// NSUserActivityTypeBrowsingWeb, which — unlike the classes faked above — can
be
+// constructed directly.
+- (NSUserActivity *)browsingUserActivityForURL:(NSURL *)url
+{
+ NSUserActivity *userActivity = [[NSUserActivity alloc]
initWithActivityType:NSUserActivityTypeBrowsingWeb];
+ userActivity.webpageURL = url;
+ return userActivity;
+}
+
+- (CDVFakeSceneConnectionOptions
*)connectionOptionsForUserActivity:(NSUserActivity *)userActivity
+{
+ return [CDVFakeSceneConnectionOptions optionsWithUserActivities:[NSSet
setWithObject:userActivity]];
+}
+
// A cold-launch URL must NOT be posted during scene connection, because
// CDVHandleOpenURL has not registered its observer yet (issue #1671). It must
be
// buffered and only replayed once the page has loaded.
@@ -197,4 +225,134 @@
[self waitForExpectations:@[openURLFired] timeout:1.0];
}
+// A universal link that launches the app arrives in
connectionOptions.userActivities,
+// and -scene:continueUserActivity: is never called for it. Like the launch
URL it must
+// NOT be posted during scene connection, because the plugin observers do not
exist yet.
+- (void)testColdLaunchUserActivityIsBufferedUntilPageDidLoad
+{
+ NSUserActivity *launchUserActivity = [self
browsingUserActivityForURL:[NSURL
URLWithString:@"https://example.com/path?foo=bar"]];
+
+ XCTNSNotificationExpectation *notFiredYet = [[XCTNSNotificationExpectation
alloc]
+ initWithName:CDVPluginContinueUserActivityNotification];
+ notFiredYet.inverted = YES;
+
+ [self.sceneDelegate scene:self.placeholderScene
+ willConnectToSession:self.placeholderSession
+ options:[self
connectionOptionsForUserActivity:launchUserActivity]];
+
+ // Before the page loads, the continue-user-activity notification must not
have fired.
+ [self waitForExpectations:@[notFiredYet] timeout:0.3];
+}
+
+// Once CDVPageDidLoadNotification fires (the plugin observer now exists), the
+// buffered launch activity must be replayed as
CDVPluginContinueUserActivityNotification.
+- (void)testColdLaunchUserActivityIsReplayedAfterPageDidLoad
+{
+ NSUserActivity *launchUserActivity = [self
browsingUserActivityForURL:[NSURL
URLWithString:@"https://example.com/path?foo=bar"]];
+
+ [self.sceneDelegate scene:self.placeholderScene
+ willConnectToSession:self.placeholderSession
+ options:[self
connectionOptionsForUserActivity:launchUserActivity]];
+
+ XCTNSNotificationExpectation *userActivityFired =
[[XCTNSNotificationExpectation alloc]
+ initWithName:CDVPluginContinueUserActivityNotification];
+ userActivityFired.handler = ^BOOL(NSNotification *notification) {
+ return [notification.object isEqual:launchUserActivity];
+ };
+
+ [[NSNotificationCenter defaultCenter]
postNotificationName:CDVPageDidLoadNotification object:nil];
+
+ [self waitForExpectations:@[userActivityFired] timeout:1.0];
+}
+
+// A launch with no user activities must never post a continue-user-activity
+// notification, even after a page load (nothing to deliver).
+- (void)testLaunchWithoutUserActivityDoesNotPostContinueUserActivity
+{
+ CDVFakeSceneConnectionOptions *options = [CDVFakeSceneConnectionOptions
optionsWithUserActivities:[NSSet set]];
+
+ [self.sceneDelegate scene:self.placeholderScene
+ willConnectToSession:self.placeholderSession
+ options:options];
+
+ XCTNSNotificationExpectation *notFired = [[XCTNSNotificationExpectation
alloc]
+ initWithName:CDVPluginContinueUserActivityNotification];
+ notFired.inverted = YES;
+
+ [[NSNotificationCenter defaultCenter]
postNotificationName:CDVPageDidLoadNotification object:nil];
+
+ [self waitForExpectations:@[notFired] timeout:0.3];
+}
+
+// If the scene disconnects before the page loads, the pending observer must be
+// torn down so a later page-load cannot replay an activity for a defunct
scene.
+- (void)testSceneDisconnectBeforePageLoadCancelsPendingUserActivity
+{
+ NSUserActivity *launchUserActivity = [self
browsingUserActivityForURL:[NSURL
URLWithString:@"https://example.com/path?foo=bar"]];
+
+ [self.sceneDelegate scene:self.placeholderScene
+ willConnectToSession:self.placeholderSession
+ options:[self
connectionOptionsForUserActivity:launchUserActivity]];
+
+ [self.sceneDelegate sceneDidDisconnect:self.placeholderScene];
+
+ XCTNSNotificationExpectation *notFired = [[XCTNSNotificationExpectation
alloc]
+ initWithName:CDVPluginContinueUserActivityNotification];
+ notFired.inverted = YES;
+
+ [[NSNotificationCenter defaultCenter]
postNotificationName:CDVPageDidLoadNotification object:nil];
+
+ [self waitForExpectations:@[notFired] timeout:0.3];
+}
+
+// The warm path (app already running) posts immediately and is unaffected by
the
+// cold-launch buffering.
+- (void)testWarmContinueUserActivityPostsImmediately
+{
+ NSUserActivity *warmUserActivity = [self browsingUserActivityForURL:[NSURL
URLWithString:@"https://example.com/warm"]];
+
+ XCTNSNotificationExpectation *userActivityFired =
[[XCTNSNotificationExpectation alloc]
+ initWithName:CDVPluginContinueUserActivityNotification];
+ userActivityFired.handler = ^BOOL(NSNotification *notification) {
+ return [notification.object isEqual:warmUserActivity];
+ };
+
+ [self.sceneDelegate scene:self.placeholderScene
continueUserActivity:warmUserActivity];
+
+ [self waitForExpectations:@[userActivityFired] timeout:1.0];
+}
+
+// A launch can carry both a URL and a user activity; buffering one must not
+// discard the other.
+- (void)testColdLaunchWithBothURLAndUserActivityReplaysBoth
+{
+ NSURL *launchURL = [NSURL URLWithString:@"testscheme://path?foo=bar"];
+ NSUserActivity *launchUserActivity = [self
browsingUserActivityForURL:[NSURL
URLWithString:@"https://example.com/path?foo=bar"]];
+
+ CDVFakeOpenURLContext *context = [CDVFakeOpenURLContext
contextWithURL:launchURL];
+ CDVFakeSceneConnectionOptions *options = [CDVFakeSceneConnectionOptions
+ optionsWithURLContexts:[NSSet setWithObject:context]
+ userActivities:[NSSet setWithObject:launchUserActivity]];
+
+ [self.sceneDelegate scene:self.placeholderScene
+ willConnectToSession:self.placeholderSession
+ options:options];
+
+ XCTNSNotificationExpectation *openURLFired =
[[XCTNSNotificationExpectation alloc]
+ initWithName:CDVPluginHandleOpenURLNotification];
+ openURLFired.handler = ^BOOL(NSNotification *notification) {
+ return [notification.object isEqual:launchURL];
+ };
+
+ XCTNSNotificationExpectation *userActivityFired =
[[XCTNSNotificationExpectation alloc]
+ initWithName:CDVPluginContinueUserActivityNotification];
+ userActivityFired.handler = ^BOOL(NSNotification *notification) {
+ return [notification.object isEqual:launchUserActivity];
+ };
+
+ [[NSNotificationCenter defaultCenter]
postNotificationName:CDVPageDidLoadNotification object:nil];
+
+ [self waitForExpectations:@[openURLFired, userActivityFired] timeout:1.0];
+}
+
@end
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]