Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion app/javascript/packages/device/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* Returns true if the device is an iPad, or false otherwise.
*
* iPadOS devices no longer list the correct user agent. As a proxy, we check for the incorrect
* one (Macintosh) then test the number of touchpoints, which for iPads will be 5.
*
* @return {boolean}
*/
export function isIPad() {
const { userAgent, maxTouchPoints } = window.navigator;
return /ipad/i.test(userAgent) || (/macintosh/i.test(userAgent) && maxTouchPoints === 5);
}

/**
* Returns true if the device is likely a mobile device, or false otherwise. This is a rough
* approximation, using device user agent sniffing.
*
* @return {boolean}
*/
export function isLikelyMobile() {
return /ip(hone|ad|od)|android/i.test(window.navigator.userAgent);
return isIPad() || /iphone|android/i.test(window.navigator.userAgent);
}

/**
Expand Down
6 changes: 5 additions & 1 deletion app/services/idv/steps/upload_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class UploadStep < DocAuthBaseStep
def call
@flow.irs_attempts_api_tracker.document_upload_method_selected(upload_method: params[:type])

# See the simple_form_for in
# app/views/idv/doc_auth/upload.html.erb
if params[:type] == 'desktop'
handle_desktop_selection
else
Expand Down Expand Up @@ -65,7 +67,9 @@ def bypass_send_link_steps
end

def mobile_device?
BrowserCache.parse(request.user_agent).mobile?
# See app/javascript/packs/document-capture-welcome.js
# And app/services/idv/steps/agreement_step.rb
!!flow_session[:skip_upload_step]
end

def form_response(destination:)
Expand Down
1 change: 1 addition & 0 deletions spec/features/idv/doc_auth/email_sent_step_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
include DocAuthHelper

before do
allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(true)
sign_in_and_2fa_user
complete_doc_auth_steps_before_email_sent_step
end
Expand Down
5 changes: 5 additions & 0 deletions spec/features/idv/doc_auth/upload_step_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

before do
sign_in_and_2fa_user
allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(true)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is some stubbing of BrowserCache in this file that I think we should remove now that the step is no longer referencing it.

complete_doc_auth_steps_before_upload_step
allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics)
allow_any_instance_of(ApplicationController).to receive(:irs_attempts_api_tracker).
Expand Down Expand Up @@ -55,6 +56,10 @@
end

context 'on a desktop device' do
before do
allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(false)
end

it 'is on the correct page' do
expect(page).to have_current_path(idv_doc_auth_upload_step)
expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id'))
Expand Down
79 changes: 77 additions & 2 deletions spec/javascripts/packages/device/index-spec.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,101 @@
import { isLikelyMobile, hasMediaAccess, isCameraCapableMobile } from '@18f/identity-device';
import {
isLikelyMobile,
hasMediaAccess,
isCameraCapableMobile,
isIPad,
} from '@18f/identity-device';

describe('isIPad', () => {
let originalUserAgent;
let originalTouchPoints;

beforeEach(() => {
originalUserAgent = navigator.userAgent;
originalTouchPoints = navigator.maxTouchPoints;
navigator.maxTouchPoints = 0;
Object.defineProperty(navigator, 'userAgent', {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec predates the addition of the useDefineProperty test helper, which can help deal with some of the boilerplate around defining and restoring properties this way.

import { useDefineProperty } from '@18f/identity-test-helpers';

describe('isIPad', () => {
  const defineProperty = useDefineProperty();

  context('with ipad (old user agent format)', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        value:
          'Mozilla/5.0(iPad; U; CPU iPhone OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B314 Safari/531.21.10',
      });
    });

    it('returns true', () => {
      expect(isIPad()).to.be.true();
    });
  });
});

configurable: true,
writable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
writable: true,
});
});

afterEach(() => {
navigator.userAgent = originalUserAgent;
navigator.maxTouchPoints = originalTouchPoints;
});

it('returns true if ipad is in the user agent string (old format)', () => {
navigator.userAgent =
'Mozilla/5.0(iPad; U; CPU iPhone OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B314 Safari/531.21.10';

expect(isIPad()).to.be.true();
});

it('returns false if the user agent is Macintosh but with 0 maxTouchPoints', () => {
navigator.userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36';

expect(isIPad()).to.be.false();
});

it('returns true if the user agent is Macintosh but with 5 maxTouchPoints', () => {
navigator.userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36';
navigator.maxTouchPoints = 5;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to my prior comment, I don't see that we're restoring the original value after this test is run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone ahead and updated the whole file to use the test helper (f24deca). LMK what you think

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After playing around with the test helper for a couple of hours this afternoon, it seems like it won't play nice with navigator and the need to set write permissions on the object (and then turn them off).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there were two issues with the previous commit:

  1. You were manipulating some global properties outside test lifecycle (particularly this one)
  2. Redefining the properties required using the configurable option

I was able to get it to pass after those were fixed up.

import {
  isLikelyMobile,
  hasMediaAccess,
  isCameraCapableMobile,
  isIPad,
} from '@18f/identity-device';
import { useDefineProperty } from '@18f/identity-test-helpers';

describe('isIPad', () => {
  const defineProperty = useDefineProperty();

  context('iPad is in the user agent string (old format)', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0(iPad; U; CPU iPhone OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B314 Safari/531.21.10',
      });
    });

    it('returns true', () => {
      expect(isIPad()).to.be.true();
    });
  });

  context('The user agent is Macintosh but with 0 maxTouchPoints', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36',
      });
    });

    it('returns false', () => {
      expect(isIPad()).to.be.false();
    });
  });

  context('The user agent is Macintosh but with 5 maxTouchPoints', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36',
      });
      defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 });
    });

    it('returns true', () => {
      expect(isIPad()).to.be.true();
    });
  });

  context('Non-Apple userAgent, even with 5 maxTouchPoints', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.5195.58 Mobile Safari/537.36',
      });
      defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 });
    });
    it('returns false', () => {
      expect(isIPad()).to.be.false();
    });
  });
});

describe('isLikelyMobile', () => {
  const defineProperty = useDefineProperty();

  context('Is not mobile and has no touchpoints', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36',
        writable: true,
      });
      defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 0 });
    });

    it('returns false', () => {
      expect(isLikelyMobile()).to.be.false();
    });
  });

  context('There is an Apple user agent and 5 maxTouchPoints', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36',
      });
      defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 });
    });

    it('returns true', () => {
      expect(isLikelyMobile()).to.be.true();
    });
  });

  context('There is an explicit iPhone user agent', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
      });
    });

    it('returns true', () => {
      expect(isLikelyMobile()).to.be.true();
    });
  });
});

describe('hasMediaAccess', () => {
  const defineProperty = useDefineProperty();

  context('No media device API access', () => {
    beforeEach(() => {
      defineProperty(navigator, 'mediaDevices', { configurable: true, value: undefined });
    });

    it('returns false', () => {
      expect(hasMediaAccess()).to.be.false();
    });
  });

  it('Has media device API access', () => {
    beforeEach(() => {
      defineProperty(navigator, 'mediaDevices', { configurable: true, value: {} });
    });

    it('returns true', () => {
      expect(hasMediaAccess()).to.be.true();
    });
  });
});

describe('isCameraCapableMobile', () => {
  const defineProperty = useDefineProperty();

  context('Is not mobile', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36',
      });
      defineProperty(navigator, 'mediaDevices', { configurable: true, value: {} });
    });

    it('returns false', () => {
      expect(isCameraCapableMobile()).to.be.false();
    });
  });

  context('No media device API access', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
      });
      defineProperty(navigator, 'mediaDevices', { configurable: true, value: undefined });
    });

    it('returns false', () => {
      expect(isCameraCapableMobile()).to.be.false();
    });
  });

  context('Is likely mobile and media device API access', () => {
    beforeEach(() => {
      defineProperty(navigator, 'userAgent', {
        configurable: true,
        value:
          'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
      });
      defineProperty(navigator, 'mediaDevices', {});
    });

    it('returns true', () => {
      expect(isCameraCapableMobile()).to.be.true();
    });
  });
});


expect(isIPad()).to.be.true();
});

it('returns false for non-Apple userAgent, even with 5 macTouchPoints', () => {
navigator.userAgent =
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.5195.58 Mobile Safari/537.36';
navigator.maxTouchPoints = 5;

expect(isIPad()).to.be.false();
});
});

describe('isLikelyMobile', () => {
let originalUserAgent;
let originalTouchPoints;

beforeEach(() => {
originalUserAgent = navigator.userAgent;
originalTouchPoints = navigator.maxTouchPoints;
navigator.maxTouchPoints = 0;
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
writable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
writable: true,
});
});

afterEach(() => {
navigator.userAgent = originalUserAgent;
navigator.maxTouchPoints = originalTouchPoints;
});

it('returns false if not mobile', () => {
it('returns false if not mobile and has no touchpoints', () => {
navigator.userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36';
navigator.maxTouchPoints = 0;

expect(isLikelyMobile()).to.be.false();
});

it('returns true if there is an Apple user agent and 5 maxTouchPoints', () => {
navigator.userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36';
navigator.maxTouchPoints = 5;

expect(isLikelyMobile()).to.be.true();
});

it('returns true if likely mobile', () => {
navigator.userAgent =
'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148';
Expand Down