Home iOS Deferred Deep Link Implementation (Swift)
Post
Cancel

iOS Deferred Deep Link Implementation (Swift)

Create an uninterrupted app transition process that adapts to all scenarios

[2022/07/22] Update iOS 16 Upcoming Changes

Starting from iOS ≥ 16, when the app actively reads the clipboard without user-initiated paste action, a prompt will appear asking for user permission. The app can only read the clipboard information if the user allows it.

[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"}

UIPasteBoard’s privacy change in iOS 16

[2020/07/02] Update

Irrelevant

After graduating and completing military service, I have been working for almost three years. Growth has plateaued, and I have entered a comfort zone. Fortunately, I decided to resign and start anew.

While reading Designing Your Life to reorganize my life plan, I reviewed my work and life. Although my technical skills are not very strong, writing on Medium and sharing with everyone allows me to enter a “flow” state and gain a lot of energy. Recently, a friend asked about Deep Link issues, so I organized my research and supplemented my energy!

Scenario

First, let’s explain the actual application scenario.

  1. When a user clicks on a URL link (Google search source, FB post, Line link, etc.) with the app installed, it directly opens the app to display the target screen. If not installed, it redirects to the App Store to install the app; After installation, opening the app should reproduce the screen the user intended to visit.

iOS Deferred Deep Link Demo

  1. Tracking app download and open data, we want to know how many people actually downloaded and opened the app from the promotional link.

  2. Special event entry, such as receiving rewards by opening the app after downloading through a specific URL.

    Support:

iOS ≥ 9

As you can see, the iOS Deep Link mechanism only determines whether the app is installed. If installed, it opens the app; if not, it does nothing.

First, we need to add a “redirect to the App Store” prompt if the app is not installed:

The URL Scheme part is controlled by the system and is generally used for internal app calls and rarely made public. If the trigger point is in an area you cannot control (e.g., Line link), it cannot be handled.

If the trigger point is on your own webpage, you can use some small tricks. Please refer to here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<html>
<head>
  <title>Redirect...</title>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  <script>
    var appurl = 'marry://open';
    var appstore = 'https://apps.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329';

    var timeout;
    function start() {
      window.location = appurl;
      timeout = setTimeout(function(){
        if(confirm('Install the Marry app now?')){
          document.location = appstore;
        }
      }, 1000);
    }

    window.onload = function() {
      start()
    }
  </script>
</head>
<body>

</body>
</html>

The general logic is to call the URL Scheme, set a Timeout, and if the page hasn’t redirected after the timeout, assume the app isn’t installed and redirect to the App Store page (though the experience is still not great as it will show a URL error prompt, but it adds automatic redirection).

Universal Link itself is a webpage, and if no redirection occurs, it defaults to being displayed in the web browser. If you have a web service, you can choose to redirect to the web page directly; if not, redirect to the App Store page.

Websites with web services can add the following in the <head></head> section:

1
<meta name="apple-itunes-app" content="app-id=APPID, app-argument=page parameters">

When browsing the web version on iPhone Safari, an APP installation prompt and a button to open the page in the APP will appear at the top; the app-argument parameter is used to pass page values and transmit them to the APP.

Flowchart with "Redirect to APP Store if not installed"

Flowchart with “Redirect to APP Store if not installed”

We want not only “if the user has the APP installed, open the APP,” but also to link the source information with the APP, so that the APP automatically displays the target page when opened.

The URL Scheme method can be handled in the AppDelegate’s func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool:

1
2
3
4
5
6
7
8
9
func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool {
    if url.scheme == "marry", let params = url.queryParameters {
      if params["type"] == "topic" {
        let VC = TopicViewController(topicID: params["id"])
        UIApplication.shared.keyWindow?.rootViewController?.present(VC, animated: true)
      }    
    }
    return true
}

The Universal Link method is handled in the AppDelegate’s func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
extension URL {
    /// test=1&a=b&c=d => ["test":"1","a":"b","c":"d"]
    /// Parse URL query into a [String: String] dictionary
    public var queryParameters: [String: String]? {
        guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true), let queryItems = components.queryItems else {
            return nil
        }
        
        var parameters = [String: String]()
        for item in queryItems {
            parameters[item.name] = item.value
        }
        
        return parameters
    }
    
}

Here is an extension method for URL, queryParameters, to conveniently convert URL queries into a Swift Dictionary.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
        
  if userActivity.activityType == NSUserActivityTypeBrowsingWeb, let webpageURL = userActivity.webpageURL {
    /// If the source is a universal link URL...
    let params = webpageURL.queryParameters
    
    if params["type"] == "topic" {
      let VC = TopicViewController(topicID: params["id"])
      UIApplication.shared.keyWindow?.rootViewController?.present(VC, animated: true)
    }
  }
  
  return true  
}

Done!

What’s Missing?

It looks perfect so far, we’ve handled all the situations we might encounter, so what’s missing?

As shown in the picture, if it’s Uninstalled -> APP Store Install -> APP Store Open, the data brought by the source will be interrupted, and the APP won’t know the source, so it will only display the homepage; the user has to go back to the previous webpage and click open again for the APP to drive the page jump.

Although this is not impossible, considering the dropout rate, an extra step means an extra layer of dropout, and the user experience is not smooth; moreover, users may not be that smart.

Entering the Main Point

What is Deferred Deep Link? Deferred deep link means that our Deep Link can retain the source data even after the APP Store installation.

According to Android engineers, Android itself has this feature, but it is not supported on iOS, and the method to achieve this feature is not friendly. Please keep reading.

If you don’t want to spend time doing it yourself, you can directly use branch.io or Firebase Dynamic Links. The method introduced in this article is the way Firebase uses.

There are two ways to achieve the effect of Deferred Deep Link on the internet:

One is to calculate a hash value through user device, IP, environment, etc., and store the data on the server on the web end; when the APP is installed and opened, calculate it in the same way, and if the values are the same, retrieve the data (branch.io’s approach).

The other method is the one introduced in this article, the same as Firebase’s approach; using the iPhone clipboard and Safari and APP Cookie sharing mechanism, which means storing the data in the clipboard or Cookie, and reading it out after the APP installation is completed.

1
After clicking "Open", your clipboard will be automatically overwritten by JavaScript with the jump-related information: https://XXX.app.goo.gl/?link=https://XXX.net/topicID=1&type=topic

Those who have used Firebase Dynamic Links must be familiar with this jump page. After understanding the principle, you will know that this page cannot be removed in the process!

In addition, Firebase does not provide style modification.

Support

First, let’s talk about the pitfall, the support issue; as mentioned earlier, “unfriendly”!

If the APP only considers iOS ≥ 10, it is much easier. The APP implements clipboard access, and the Web uses JavaScript to overwrite the information to the clipboard, then jumps to the APP Store for download.

iOS = 9 does not support JavaScript automatic clipboard but supports Safari and APP SFSafariViewController “Cookie Intercommunication Method”

In addition, the APP needs to secretly add SFSafariViewController in the background to load the Web, and then obtain the Cookie information stored when the link was clicked from the Web.

Cumbersome steps & link clicks are limited to Safari browser.

[SFSafariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller){:target="_blank"}

SFSafariViewController

According to the official documentation, iOS 11 can no longer obtain the user’s Safari Cookie. If you need this, you can use SFAuthenticationSession, but this method cannot be executed secretly in the background, and the following inquiry window will pop up every time before loading:

_SFAuthenticationSession Prompt Window_

SFAuthenticationSession Prompt Window

Also, APP review does not allow placing SFSafariViewController in a location where the user cannot see it. (Triggering it programmatically and then adding it as a subview is not easily detected)

Hands-on

Let’s start with the simple part, considering only users with iOS ≥ 10, simply using the iPhone clipboard to transfer information.

Web Side:

We mimicked Firebase Dynamic Links to create our own page, using the clipboard.js library to copy the information we want to send to the APP to the clipboard when the user clicks “Go Now” (marry://topicID=1&type=topic), and then use location.href to redirect to the APP Store page.

APP Side:

Read the clipboard value in AppDelegate or the main UIViewController:

let pasteData = UIPasteboard.general.string

It is recommended to package the information using URL Scheme for easy identification and data parsing:

1
2
3
4
5
6
if let pasteData = UIPasteboard.general.string, let url = URL(string: pasteData), url.scheme == "marry", let params = url.queryParameters {
    if params["type"] == "topic" {
      let VC = TopicViewController(topicID: params["id"])
      UIApplication.shared.keyWindow?.rootViewController?.present(VC, animated: true)
    }
}

Finally, after processing the action, use UIPasteboard.general.string = “” to clear the information in the clipboard.

Hands-on — Supporting iOS 9 Version

Here comes the trouble, supporting iOS 9 version. As mentioned earlier, since clipboard is not supported, we need to use Cookie Communication Method.

Web Side:

The web side is also easy to handle, just change it so that when the user clicks “Go Now”, the information we want to send to the APP is stored in a Cookie (marry://topicID=1&type=topic), and then use location.href to redirect to the APP Store page.

Here are two encapsulated JavaScript methods for handling Cookies to speed up development:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// name: Cookie name
/// val: Cookie value
/// day: Cookie expiration date, default is 1 day
/// EX1: setcookie("iosDeepLinkData", "marry://topicID=1&type=topic")
/// EX2: setcookie("hey", "hi", 365) = valid for one year
function setcookie(name, val, day) {
    var exdate = new Date();
    day = day || 1;
    exdate.setDate(exdate.getDate() + day);
    document.cookie = "" + name + "=" + val + ";expires=" + exdate.toGMTString();
}

/// getCookie("iosDeepLinkData") => marry://topicID=1&type=topic
function getCookie(name) {
    var arr = document.cookie.match(new RegExp("(^| )" + name + "=([^;]*)(;|$)"));
    if (arr != null) return decodeURI(arr[2]);
    return null;
}

APP Side:

Here comes the most troublesome part of this article.

As mentioned earlier, we need to programmatically load an SFSafariViewController in the background on the main UIViewController without the user noticing.

Another pitfall: Secretly loading this, if the size of the View of SFSafariViewController in iOS ≥ 10 is set to less than 1, the opacity is set to less than 0.05, or it is set to isHidden, SFSafariViewController will not load.

p.s iOS = 10 supports both Cookies and Clipboard.

[https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788](https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788){:target="_blank"}

https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788

My approach here is to place a UIView with any height above the UIViewController of the main page, but align the bottom to the top of the main page UIView, then connect an IBOutlet (sharedCookieView) to the Class; in viewDidLoad( ) initialize SFSafariViewController and add its View to sharedCookieView, so it actually displays and loads, but it runs off-screen, and the user can’t see it 🌝.

Where should the URL of SFSafariViewController point to?

Same as the web sharing page, we need to create another page for reading Cookies and place both pages under the same domain to avoid cross-domain Cookie issues. The page content will be provided later.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@IBOutlet weak var SharedCookieView: UIView!

override func viewDidLoad() {
    super.viewDidLoad()
    
    let url = URL(string:"http://app.marry.com.tw/loadCookie.html")
    let sharedCookieViewController = SFSafariViewController(url: url)
    VC.view.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
    sharedCookieViewController.delegate = self
    
    self.addChildViewController(sharedCookieViewController)
    self.SharedCookieView.addSubview(sharedCookieViewController.view)
    
    sharedCookieViewController.beginAppearanceTransition(true, animated: false)
    sharedCookieViewController.didMove(toParentViewController: self)
    sharedCookieViewController.endAppearanceTransition()
}

sharedCookieViewController.delegate = self

class HomeViewController: UIViewController, SFSafariViewControllerDelegate

You need to add this Delegate to capture the CallBack processing after loading is complete.

We can do it in:

func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {

Capture the load completion event in the method.

At this step, you might think that reading the cookies within the webpage in didCompleteInitialLoad would complete the task!

Here, I couldn’t find a method to read SFSafariViewController cookies, and the methods found online return empty results.

Or you might need to use JavaScript to interact with the page content, asking JavaScript to read the cookies and return them to the UIViewController.

Tricky URL Scheme Method

Since iOS doesn’t know how to obtain shared cookies, we will directly let the “cookie reading page” help us “read the cookies.”

The getCookie() method in the JavaScript handling cookies provided earlier is used here. Our “cookie reading page” content is a blank page (since the user can’t see it), but in the JavaScript part, we need to read the cookies after body onload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<html>
<head>
  <title>Load iOS Deep Link Saved Cookie...</title>
  <script>
  function checkCookie() {
    var iOSDeepLinkData = getCookie("iOSDeepLinkData");
    if (iOSDeepLinkData && iOSDeepLinkData != '') {
        setcookie("iOSDeepLinkData", "", -1);
        window.location.href = iOSDeepLinkData; /// marry://topicID=1&type=topic
    }
  }
  </script>
</head>

<body onload="checkCookie();">

</body>

</html>

The actual principle summary is: in HomeViewController viewDidLoad, add SFSafariViewController to stealthily load the loadCookie.html page. The loadCookie.html page reads and checks the previously stored cookies, if any, reads and clears them, then uses window.location.href to call, triggering the URL Scheme mechanism.

So the corresponding callback handling will return to AppDelegate in func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool for processing.

Done! Summary:

If you find it cumbersome, you can directly use branch.io or Firebase Dynamic without reinventing the wheel. Here, due to customized interfaces and some complex requirements, we had to build it ourselves.

iOS=9 users are already very rare, so unless necessary, you can directly ignore them; using the clipboard method is fast and efficient, and with the clipboard, the link doesn’t have to be opened with Safari!

If you have any questions or comments, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here


This post is licensed under CC BY 4.0 by the author.

Using 'Shortcuts' Automation with Mi Home Smart Home on iOS ≥ 13.1

iOS UIViewController Transition Techniques