Encouraging iOS users to install your Progressive Web Apps

September 27, 2017

PWA Phone

We have spent a lot of time over the past few months talking about Progressive Web Apps on our blog and how you can build them easily with Ember. It’s no secret that we see the value in PWAs and the business problems they can solve. However, we still have one hurdle to overcome with adoption: iOS.

Installing PWAs

If you use an Android device, you have probably noticed that PWAs automatically prompt you to add the application to your homescreen but iOS still doesn’t do this. If you’re unaware of the differences, mobile Safari does not support some key PWA features. This is changing quickly, however; the most recent release of iOS allows PWAs saved to your homescreen to be launched as a standalone application. This means your web app can launch without the cumbersome Safari UI. Unfortunately, one key thing is still missing: prompting the user to install the app.

Adding to Homescreen on iOS

The key to success for Progressive Web Apps is adoption—if you want to compete with native applications, you want the user to install and launch the app the same way. On a recent client project, we thought a lot about client adoption on iPhone (a large percentage of our user base) and how we could encourage users to install our app. We decided to go with a modal prompt with clear and easy steps to install the app.

Add to homescreen modal

When to prompt

The first issue we had to solve was when to show this modal. It goes without saying that this modal is not useful unless you are on an iPhone or iPad—so that should be the first thing we test. We can do this easily with the window.navigator API.

needsToSeePrompt() {
  return ['iPhone', 'iPad', 'iPod'].includes(navigator.platform);
}

We can then call this method when our application route activates—that way the user sees this modal no matter which route they load. However, you may not want to inundate your users with these prompts every time they launch the app (after all, they may not actually want your app on their homescreen). We decided to only show this prompt for logged in users (since our app required authentication) and to only show it once every two weeks.

We updated our function to be aware of the last time the user saw a prompt using ember-moment.

needsToSeePrompt(user) {
  let today = moment();
  let lastPrompt = Ember.get(user, 'lastSeenPrompt');
  let days = today.diff(lastPrompt, 'days'); // the number of days between now and the last prompt
  let isApple = ['iPhone', 'iPad', 'iPod'].includes(navigator.platform);
  return (isNaN(days) || days > 14) && isApple;
}

Now on our application route, we can check to see if we have a logged in user and if we do, we’ll perform this check:

activate() {
  let currentUser = Ember.get(this, 'currentUser'); // a service we have to fetch user
  if (currentUser) {
    if (this.needsToSeePrompt(currentUser)) {
      Ember.set(currentUser, 'lastSeenPrompt'. moment()); // set current time for prompt
      /* we had a specific route for showing the modal
      but this could be any action to prompt the user */
      this.transitionTo('add-to-homescreen');
    }
  }
}

Installing

When not to prompt

We’ve handled showing the prompt on iOS devices and not on desktop or Android. Now we have to solve the inverse case: not showing the prompt when the user has launched the app from their homescreen. It wouldn’t make much sense to keep showing them the prompt if they already followed our instructions. Unfortunately, there are two cases we need to handle: users with newer iOS versions and legacy users.

Standalone apps

On newer versions of iOS, PWAs can be launched as a standalone app (this is controlled by our app’s manifest where display is set to standalone). Standalone apps give a more “native” feel, as they are launched just like any app and not in the browser, hiding the Safari UI. Luckily, seeing if an app is standalone is fairly easy with the window.navigator API. We can update our prompt method to handle this case:

needsToSeePrompt(user) {
  if (navigator.standalone) {
    return false;
  }
  let today = moment();
  let lastPrompt = Ember.get(user, 'lastSeenPrompt');
  let days = today.diff(lastPrompt, 'days');
  let isApple = ['iPhone', 'iPad', 'iPod'].includes(navigator.platform);
  return (isNaN(days) || days > 14) && isApple;
}

window.navigator.standalone returns a boolean that can drive our logic. It will be undefined on older versions of Safari, which will resolve to a falsy value.

Non-standalone apps

Finally, we handle the case where our phone does not support standalone apps. window.navigator.standalone will return false when launching the app from our homescreen opens our browser. However, we can specify a start_url in our app’s manifest (in Ember, this is defined in config/manifest.js). We simply add a query param:

'use strict';

module.exports = function() {
  return {
    name: 'Mobile Dockyard',
    short_name: 'Mobile Dockyard',
    description: 'Dockyard - but mobile',
    start_url: '/?standalone', // our query param
    display: 'standalone',
    background_color: '#F6FEFF',
    theme_color: '#F6FEFF',
    icons: [...]
  };
}

Now we will know if the user launches the application into the browser from their home screen. We can make one last update to our needsToSeePrompt method to check this:

needsToSeePrompt(user, standalone) {
  // we pass in the result of our query-param to this method
  if (navigator.standalone || standalone) {
    return false;
  }
  let today = moment();
  let lastPrompt = Ember.get(user, 'lastSeenPrompt');
  let days = today.diff(lastPrompt, 'days');
  let isApple = ['iPhone', 'iPad', 'iPod'].includes(navigator.platform);
  return (isNaN(days) || days > 14) && isApple;
}

Testing this

You might be wondering how to test this behavior; especially considering window.navigator.platform is a read-only property. To get around this, you can invoke the __defineGetter__ method to stub your platform.

test('logging in for first time prompts add-to-homescreen on iPhone', function(assert) {
  window.navigator.__defineGetter__('platform', () => {
    return 'iPhone';
  });
  visit('/');

  andThen(function() {
    assert.equal(currentURL(), '/add-to-homescreen'); // the name of our modal route
  });
});

It is as simple as that to stub the platform call and test the behavior. However, be careful because this will update the platform for your entire test suite. To get around this, save the value of the original platform in your beforeEach hook and reset it in your afterEach.

let originalPlatform;

moduleForAcceptance('Acceptance | add to homescreen', {
  beforeEach() {
    originalPlatform = window.navigator.platform;
  },

  afterEach() {
    window.navigator.__defineGetter__('platform', () => {
      return originalPlatform;
    });
  }
});

That will reset it after each test in this module and shouldn’t interfere with the rest of your test suite.

Until iOS automatically recognizes and prompts the user when a PWA is detected, finding workarounds for engagement is your best bet. Luckily, modern browsers make it easy to see and track which users are running your app in standalone mode, allowing you to take the proper action.

Originally published by Scott Batson on DockYard's Blog.