Clean(ish) mobile web deep linking for iOS and Android

Jesse Ditson
jesseditson
Published in
7 min readJul 29, 2013

Recently I needed to redirect a mobile web version of an app to an installed app.

This proved somewhat challenging, as there’s no standard for checking if a user has an app installed. You can of course just redirect to a custom scheme, but that would be a pretty crappy experience for users who actually want to use the mobile web version of the site, or don’t have the app installed.

Apple and Android each have their own distinctive way to handle custom schemes, which I won’t be covering in this article. There’s a wealth of resources on these topics, and as always, a good place to start is the docs:

For Android, these are handled as intent filters with the BROWSABLE category in the AndroidManifest.xml file. Here's the docs for intent filters.
On iOS, they are handled in your app's Info.plist file. Here's the docs for implementing custom URL schemes.

This article is about trying to create a smooth transition from mobile web to app, without breaking any existing user expectations and minimizing weirdness.

TL:DR;

If you’re just looking for some copy pasta, here’s a scoped function that you can drop in, change the variables at the top, and open your custom scheme.

(function(doc,win){
// success/failure functions
var success = function(){
// found the app
alert(‘Redirected to the app’)
}
var failure = function(){
// app not installed
alert(‘Failed to find app’)
}
var cookieName = “myapp_install_check”
var domainRegex = /myhostname/i
var scheme = “myapp”
if(domainRegex.test(document.referrer) && new RegExp(cookieName + ‘=’,’i’).test(document.cookie)) return
var __onload = win.onload
var done = function(found){
doc.cookie = cookieName + ‘=yes;path=/;’
;(found ? success : failure)()
}
win.onload = function(){
var iframe = doc.createElement(“iframe”)
iframe.style.border = “none”
iframe.style.width = “1px”
iframe.style.height = “1px”
var t = setTimeout(function() {
done(false)
}, 1000);
iframe.onload = function () { clearTimeout(t); done(true) }
iframe.src = scheme + ‘://’ + (doc.location.pathname.replace(/^\//,’’) || ‘/’)
doc.body.appendChild(iframe)
if(__onload) __onload.apply(this,arguments)
}
})(document,window)
  • replace myapp_install_check with a name of a cookie to use to not redirect a user we've already tried to redirect until they open a new window.
  • replace myhostname with your hostname. This will be something like mysite\.com
  • replace myapp with your custom scheme - something you've set in your mobile app to respond to, minus the ://.
  • add any code that you’d like to execute if the user has the app (and we’re doing a redirect to it) to the success function. For instance, if you don't want to leave the window open, this would be a good place for a window.close().
  • add any code that you want to execute if the user does not have the app installed to the failure function. For instance, this would be a good place to show a popup asking the user to install your app.

If you’re interested in a more customizable piece of code that can handle multiple schemes, or what this is doing, read on.

Backend

Very little backend is necessary for this — however, because this is a hardware based change, it’s one of the few cases where I’d consider it an OK practice to use a user-agent header to include or omit a javascript snippet. In my case, we already have UA detection in place, something like this should do the trick (this is untested node pseudocode, but you can do this in any backend):
var ua = request.headers[‘User-Agent’]
var mobile = /(ip(hone|od|ad)|android)/i.test(ua)
// mobile is now true for devices.

If you felt like it, you could also do this check on the client by testing window.navigator.userAgent.

Client Side

To start, we’ll add a new window.onload handler to do stuff when we load the page:

 var __onload = window.onload
window.onload = function(){
// this is where we'll attempt to open the app.
if(__onload) __onload.apply(this,arguments)
}

Next, we’ll want to write a handler that will prevent this script from running more than once on the same session. In my case, I’m defining a session as an open tab on a browser. I deal with this by enclosing my script in a Self Executing Anonymous Function (SEAF), and running a cookie check before we add the onload handler to make sure we don't add it more than once in the same session. We'll also check to see if the user is visiting from either a non-local domain, or an empty domain. If either of those are true, this is a new session, so we'll want to try to redirect it to the app.

To achieve these things, I’ll need a quick method for manipulating cookies, and a check. Here’s what our code looks like now:

(function(){ var cookieName = 'my_app_tried_redirect' var hostRegex = /myhostname/ // cookie reading method var readCookie = function(){ var parts = document.cookie.split(';') var i=0,len=parts.length for(i;i var cName = cookieName + '=' var part = parts[i] // trim leading whitespace while(part.charAt(0) == ' ') part = part.substring(1,part.length) if(part.indexOf(cName)) return part.substring(cName.length,part.length) } return null } // don't fire this twice in the same session if(hostRegex.test(document.referrer) && readCookie()) return // redirect on load var __onload = window.onload window.onload = function(){ if(/Android/i.test(navigator.userAgent)){ // this is where we'll do android specific stuff } // this is where we'll do stuff that works elsewhere if(__onload) __onload.apply(this,arguments) } })()

Now, we’ll want to write a function that we’ll call once we’ve completed a check for the app. This will set the cookie and perform any actions that you want to do depending on if the user has the app installed or not. This will look something like this:

var appFound var complete = function(found){ appFound = found ? true : appFound // set a cookie so we don't redirect again in this session doc.cookie = cookieName + '=yes;path=/;' if(appFound){ // user has app installed } else { // user does not have app } }

Put that somewhere in your SEAF. Now we’ll need to write something to call that function with a true or false callback. To try to detect if the user has the app, we’ll inject an iFrame pointing to a custom scheme url. Depending on if we get an onload callback from the frame, we’ll know if the app successfully opened or not. In case we want to do this with multiple schemes, we’ll put it in a function:

var injectiFrame = function(path,callback){ var iframe = document.createElement("iframe") iframe.style.border = "none" iframe.style.width = "1px" iframe.style.height = "1px" var t = setTimeout(function() { callback(false) }, 1000); iframe.onload = function () { clearTimeout(t); callback(true) } iframe.src = path document.body.appendChild(iframe) }

Basically after 1 second, if the frame hasn’t reported success, we assume failure. This approach is cleaner than others because it’s mostly invisible to the user (outside of an iOS caveat), and will leave them alone to continue their browsing session (or for instance, you could pop up a dismissable “install the app!” banner) without further disturbance.

We’ll now add calls to our various schemes inside the previously created window.onload handler. This is what that looks like:

// redirect on load var __onload = window.onload window.onload = function(){ // attempt to open a custom scheme url injectiFrame('yourcustomscheme://' + (document.location.pathname.replace(/^\//,'') || 'home'),complete) if(__onload) __onload.apply(this,arguments) }

You’ll notice I’m passing the pathname (sans leading slash) over to the app. If you handle similar urls in both the app and the mobile site, you should be able to pick up where the user left off in your app by responding to the same paths.

Lastly, I’ll want to make sure I don’t run the appFound conditional block more than once, as we could potentially annoy the user with multiple popups or similar if we try multiple schemes. So, I’ll add a global appChecks variable, and increment it whenever I call injectIFrame. Then, I'll add a line to the top of the complete function:

if(--appChecks > 0) return

This will skip the body of complete when we still have one or more outstanding iFrames to load.

Here’s an example of the full code to do a clean redirect to an app:

(function(doc,win){ // local vars var cookieName = 'my_app_tried_redirect' var domainRegex = /myhostname/ var scheme = "mycustomscheme" var appFound = false var appChecks = 0 // functions var injectiFrame = function(path,callback){ appChecks++ var iframe = doc.createElement("iframe") iframe.style.border = "none" iframe.style.width = "1px" iframe.style.height = "1px" var t = setTimeout(function() { callback(false) }, 1000); iframe.onload = function () { clearTimeout(t); callback(true) } iframe.src = path doc.body.appendChild(iframe) } var complete = function(found){ appFound = found ? true : appFound // wait for all checks to complete if(--appChecks > 0) return // completed all checks // set a cookie so we don't redirect again in this session doc.cookie = cookieName + '=yes;path=/;' if(appFound){ // user has app installed } else { // user does not have app } } var readCookie = function(name) { var nameEQ = name + "=" var ca = document.cookie.split(';') for(var i = 0; i < ca.length; i++) { var c = ca[i] while (c.charAt(0) == ' ') c = c.substring(1, c.length) if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length) } return null } // don't fire this twice in the same session if(domainRegex.test(document.referrer) &amp;&amp; readCookie(cookieName)) return // add some iframes to attempt to pop open the app var __onload = win.onload win.onload = function(){ injectiFrame(scheme + '://' + (doc.location.pathname.replace(/^\//,'') || 'home'),complete) if(__onload) __onload.apply(this,arguments) } })(document,window)

Caveats:

This should be somewhat straightforward and easily modifiable for your needs. The one caveat I know of using this approach is that when used in iOS and the user does not have the app installed, the user will get a popup saying “Cannot Open Page — Safari cannot open the page because the address is invalid.” The only option the user has is to dismiss the message, and once they do, they’ll be able to continue their session uninterrupted. This can be confusing for some users though, and I would expect a certain amount of CS issues associated with it. This could be treated by using a popup when the user doesn’t have the app addressing the fact that they just saw that notice. Some apps, like quora, just don’t acknowledge that you got the message at all, but prompt you to download the app. This is annoying, but unfortunately unavoidable in iOS as of this writing.

Originally published at jesseditson.com on July 29, 2013.

--

--