diff --git a/__tests__/unit/app/components/mobileapplink/MobileAppLink.test.js b/__tests__/unit/app/components/mobileapplink/MobileAppLink.test.js new file mode 100644 index 0000000000..c64f70e482 --- /dev/null +++ b/__tests__/unit/app/components/mobileapplink/MobileAppLink.test.js @@ -0,0 +1,209 @@ +/* global jest */ +/* global expect */ +/* global describe */ +/* global it */ +/* global beforeEach */ +/* global afterEach */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import MobileAppLink, { + ANDROID_APP_URL, + APP_STORE_URL, + IOS_APP_URL, + IOS_UNIVERSAL_LINK_URL, + PLAY_STORE_URL, + getIosAppUrl, +} from '@app/components/mobileapplink/MobileAppLink'; +import utils from '@app/core/utils'; + +const OPEN_APP_LABEL = 'Open the Tidepool Mobile app'; +const STORE_BADGE_LABEL = 'Download Tidepool Mobile'; + +describe('MobileAppLink', () => { + let getMobilePlatform; + let trackMetric; + + beforeEach(() => { + getMobilePlatform = jest.spyOn(utils, 'getMobilePlatform'); + trackMetric = jest.fn(); + // The iosLink override persists to localStorage, so tests must not leak state into each other + window.localStorage.clear(); + }); + + afterEach(() => { + getMobilePlatform.mockRestore(); + }); + + describe('on a desktop user agent', () => { + it('should render nothing, since the link cannot work there', () => { + getMobilePlatform.mockReturnValue(null); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('on iOS', () => { + beforeEach(() => { + getMobilePlatform.mockReturnValue('ios'); + }); + + it('should link to the app with the plain custom scheme', () => { + render(); + + expect(screen.getByRole('link', { name: OPEN_APP_LABEL })) + .toHaveAttribute('href', IOS_APP_URL); + expect(IOS_APP_URL).toBe('org.tidepool.mobile://signup-complete'); + }); + + it('should fall back to the App Store listing', () => { + render(); + + expect(screen.getByRole('link', { name: STORE_BADGE_LABEL })) + .toHaveAttribute('href', APP_STORE_URL); + }); + + it('should track a metric when the app link is clicked', async () => { + render(); + await userEvent.click(screen.getByRole('link', { name: OPEN_APP_LABEL })); + + expect(trackMetric).toHaveBeenCalledWith('Clicked Open Tidepool Mobile App', { platform: 'ios' }); + }); + }); + + describe('getIosAppUrl', () => { + it('should default to the custom scheme', () => { + expect(getIosAppUrl('')).toBe(IOS_APP_URL); + }); + + it('should return the universal link when overridden via the query string', () => { + expect(getIosAppUrl('?iosLink=universal')).toBe(IOS_UNIVERSAL_LINK_URL); + }); + + it('should return the custom scheme when explicitly overridden', () => { + expect(getIosAppUrl('?iosLink=scheme')).toBe(IOS_APP_URL); + }); + + it('should ignore an unrecognised override', () => { + expect(getIosAppUrl('?iosLink=nonsense')).toBe(IOS_APP_URL); + }); + + it('should point at the current origin when testing the same-host case', () => { + expect(getIosAppUrl('?iosLink=universal&linkHost=same', 'https://qa1.development.tidepool.org')) + .toBe('https://qa1.development.tidepool.org/mobile-app'); + }); + + it('should ignore linkHost when the scheme strategy is selected', () => { + expect(getIosAppUrl('?iosLink=scheme&linkHost=same', 'https://qa1.development.tidepool.org')) + .toBe(IOS_APP_URL); + }); + + // The value renders into an href, so an attacker-supplied host would repoint the button off-site. + it('should not honour an arbitrary host supplied in the query string', () => { + expect(getIosAppUrl('?iosLink=universal&linkHost=evil.example.com', 'https://qa1.development.tidepool.org')) + .toBe(IOS_UNIVERSAL_LINK_URL); + }); + + // Safari opens same-host universal links in the browser rather than the app, so pointing this + // at the host serving the page would silently defeat the whole mechanism. + it('should point the universal link at a dedicated host', () => { + expect(IOS_UNIVERSAL_LINK_URL).toMatch(/^https:\/\/[^/]+\/mobile-app$/); + }); + + // Editing query params on a phone keyboard is painful, so an override sticks across visits. + describe('override persistence', () => { + it('should keep applying an override on later visits without the query param', () => { + getIosAppUrl('?iosLink=universal'); + + expect(getIosAppUrl('')).toBe(IOS_UNIVERSAL_LINK_URL); + }); + + it('should persist the same-host variant, including the host choice', () => { + getIosAppUrl('?iosLink=universal&linkHost=same', 'https://qa3.development.tidepool.org'); + + expect(getIosAppUrl('', 'https://qa3.development.tidepool.org')) + .toBe('https://qa3.development.tidepool.org/mobile-app'); + }); + + it('should drop a persisted linkHost when a later override omits it', () => { + getIosAppUrl('?iosLink=universal&linkHost=same', 'https://qa3.development.tidepool.org'); + getIosAppUrl('?iosLink=universal', 'https://qa3.development.tidepool.org'); + + expect(getIosAppUrl('', 'https://qa3.development.tidepool.org')).toBe(IOS_UNIVERSAL_LINK_URL); + }); + + it('should return to the default after ?iosLink=reset', () => { + getIosAppUrl('?iosLink=universal'); + getIosAppUrl('?iosLink=reset'); + + expect(getIosAppUrl('')).toBe(IOS_APP_URL); + }); + + it('should not persist an unrecognised override', () => { + getIosAppUrl('?iosLink=nonsense'); + + expect(window.localStorage.getItem('mobileAppLink.iosLink')).toBeNull(); + }); + }); + }); + + describe('override indicator', () => { + beforeEach(() => { + getMobilePlatform.mockReturnValue('ios'); + }); + + it('should not render while no override is active', () => { + render(); + + expect(screen.queryByText(/Link override active/)).not.toBeInTheDocument(); + }); + + it('should name the active override and how to clear it', () => { + getIosAppUrl('?iosLink=universal&linkHost=same', 'https://qa3.development.tidepool.org'); + render(); + + expect(screen.getByText('Link override active: universal (same host) — ?iosLink=reset clears it')) + .toBeInTheDocument(); + }); + }); + + describe('on Android', () => { + beforeEach(() => { + getMobilePlatform.mockReturnValue('android'); + }); + + it('should link to the app with the intent:// scheme, so a missing app falls through to the Play Store', () => { + render(); + + expect(screen.getByRole('link', { name: OPEN_APP_LABEL })) + .toHaveAttribute('href', ANDROID_APP_URL); + expect(ANDROID_APP_URL).toBe('intent://signup-complete#Intent;scheme=org.tidepool.mobile;package=io.tidepool.urchin;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dio.tidepool.urchin;end'); + }); + + it('should fall back to the Play Store listing', () => { + render(); + + expect(screen.getByRole('link', { name: STORE_BADGE_LABEL })) + .toHaveAttribute('href', PLAY_STORE_URL); + }); + + // The visible target is a Button nested inside the anchor, so a real tap lands on the button + // and relies on the click bubbling up to trigger the link. + it('should trigger the app link when the button itself is tapped', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: OPEN_APP_LABEL })); + + expect(trackMetric).toHaveBeenCalledWith('Clicked Open Tidepool Mobile App', { platform: 'android' }); + }); + + it('should track a metric when the app link is clicked', async () => { + render(); + await userEvent.click(screen.getByRole('link', { name: OPEN_APP_LABEL })); + + expect(trackMetric).toHaveBeenCalledWith('Clicked Open Tidepool Mobile App', { platform: 'android' }); + }); + }); +}); diff --git a/__tests__/unit/app/pages/mobileapp/MobileApp.test.js b/__tests__/unit/app/pages/mobileapp/MobileApp.test.js new file mode 100644 index 0000000000..da41b86a85 --- /dev/null +++ b/__tests__/unit/app/pages/mobileapp/MobileApp.test.js @@ -0,0 +1,61 @@ +/* global jest */ +/* global expect */ +/* global describe */ +/* global it */ +/* global beforeEach */ +/* global afterEach */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import MobileApp from '@app/pages/mobileapp/MobileApp'; +import { APP_STORE_URL, PLAY_STORE_URL } from '@app/components/mobileapplink/MobileAppLink'; +import utils from '@app/core/utils'; + +const BADGE_LABEL = 'Download Tidepool Mobile'; + +describe('MobileApp', () => { + let getMobilePlatform; + + beforeEach(() => { + getMobilePlatform = jest.spyOn(utils, 'getMobilePlatform'); + }); + + afterEach(() => { + getMobilePlatform.mockRestore(); + }); + + it('should show only the App Store badge on iOS', () => { + getMobilePlatform.mockReturnValue('ios'); + render(); + + const badges = screen.getAllByRole('link', { name: BADGE_LABEL }); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveAttribute('href', APP_STORE_URL); + }); + + it('should show only the Play Store badge on Android', () => { + getMobilePlatform.mockReturnValue('android'); + render(); + + const badges = screen.getAllByRole('link', { name: BADGE_LABEL }); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveAttribute('href', PLAY_STORE_URL); + }); + + // Unlike the Welcome page button, this page must still render on desktop — it is a real + // destination that a universal link resolves to when the app isn't installed. + it('should show both badges on desktop rather than rendering nothing', () => { + getMobilePlatform.mockReturnValue(null); + render(); + + expect(screen.getAllByRole('link', { name: BADGE_LABEL })).toHaveLength(2); + }); + + it('should offer a manual scheme link on iOS as a retry path', () => { + getMobilePlatform.mockReturnValue('ios'); + render(); + + expect(screen.getByText('Open the Tidepool Mobile app')).toBeInTheDocument(); + }); +}); diff --git a/app/components/mobileapplink/MobileAppLink.js b/app/components/mobileapplink/MobileAppLink.js new file mode 100644 index 0000000000..91874f031b --- /dev/null +++ b/app/components/mobileapplink/MobileAppLink.js @@ -0,0 +1,196 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withTranslation } from 'react-i18next'; +import { Box, Flex, Image, Link } from 'theme-ui'; + +import Button from '../elements/Button'; +import { Paragraph1 } from '../elements/FontStyles'; +import utils from '../../core/utils'; + +import AppStoreBadge from './images/appstore-badge.svg'; +import GooglePlayBadge from './images/google-play-badge.png'; + +export const APP_STORE_URL = 'https://apps.apple.com/us/app/tidepool-mobile/id1026395200'; +export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=io.tidepool.urchin'; + +// The link only foregrounds the app — it carries no parameters and the app does no routing on it. +export const IOS_APP_URL = 'org.tidepool.mobile://signup-complete'; + +export const IOS_UNIVERSAL_LINK_PATH = '/mobile-app'; + +// Host serving /.well-known/apple-app-site-association. iOS suppresses universal links that point +// at the current page's own host (opens them in Safari instead of the app), so this must be a host +// other than the one serving the page. Suppression is same-host only — a sibling subdomain works — +// both verified on hardware 2026-09-03 (see docs/mobile-app-signup/). This is a QA test value; +// shipping requires the real link host here (e.g. link.tidepool.org), with a matching entry in the +// iOS app's associated-domains entitlement. +export const IOS_UNIVERSAL_LINK_HOST = 'qa4.development.tidepool.org'; +export const IOS_UNIVERSAL_LINK_URL = `https://${IOS_UNIVERSAL_LINK_HOST}${IOS_UNIVERSAL_LINK_PATH}`; + +// Which link form iOS gets. 'scheme' is the shipped behaviour. 'universal' is fully verified +// (opens the app when installed, lands on IOS_UNIVERSAL_LINK_URL when not, never errors) but +// shipping it needs production infrastructure — see docs/mobile-app-signup/ — so the default stays +// 'scheme' until that decision is made. Override with ?iosLink=universal|scheme (sticky, see below) +// to compare both on a single build. +export const IOS_LINK_STRATEGY = 'scheme'; + +export const IOS_LINK_STORAGE_KEY = 'mobileAppLink.iosLink'; +export const IOS_LINK_HOST_STORAGE_KEY = 'mobileAppLink.linkHost'; + +// localStorage can throw (private browsing, storage disabled); a failed read/write just means the +// override doesn't stick, which only degrades the test workflow, never the shipped behaviour. +const storage = { + get: (key) => { try { return window.localStorage.getItem(key); } catch (e) { return null; } }, + set: (key, value) => { try { window.localStorage.setItem(key, value); } catch (e) { /* noop */ } }, + remove: (key) => { try { window.localStorage.removeItem(key); } catch (e) { /* noop */ } }, +}; + +/** + * Resolve the iOS link strategy for the current page load. + * + * ?iosLink=universal|scheme selects the link form + * ?linkHost=same points the universal link at the current origin (the step 1 control — + * it confirmed iOS suppresses same-host universal links) + * ?iosLink=reset clears a persisted override + * + * A query-string override is persisted on the device and keeps applying on later visits until it + * is replaced or reset — editing query params by hand on a phone keyboard is painful, so the URL + * only ever needs to be typed (or a prepared link tapped) once per mode. Each iosLink visit + * re-persists linkHost according to its presence, so the stored state always mirrors the last + * override URL used. An on-page indicator shows when an override is active. + * + * linkHost only accepts 'same' rather than an arbitrary host: this renders into an href, and + * honouring a caller-supplied domain would let a crafted URL repoint the button off-site. + */ +export const resolveIosLinkStrategy = (search = '') => { + const params = new URLSearchParams(search); + const override = params.get('iosLink'); + + if (override === 'reset') { + storage.remove(IOS_LINK_STORAGE_KEY); + storage.remove(IOS_LINK_HOST_STORAGE_KEY); + } else if (['scheme', 'universal'].includes(override)) { + storage.set(IOS_LINK_STORAGE_KEY, override); + + if (params.get('linkHost') === 'same') { + storage.set(IOS_LINK_HOST_STORAGE_KEY, 'same'); + } else { + storage.remove(IOS_LINK_HOST_STORAGE_KEY); + } + } + + const stored = storage.get(IOS_LINK_STORAGE_KEY); + const isOverride = ['scheme', 'universal'].includes(stored); + const strategy = isOverride ? stored : IOS_LINK_STRATEGY; + + return { + strategy, + sameHost: strategy === 'universal' && storage.get(IOS_LINK_HOST_STORAGE_KEY) === 'same', + isOverride, + }; +}; + +export const getIosAppUrl = (search = '', origin = '') => { + const { strategy, sameHost } = resolveIosLinkStrategy(search); + + if (strategy !== 'universal') return IOS_APP_URL; + + return sameHost && origin + ? `${origin}${IOS_UNIVERSAL_LINK_PATH}` + : IOS_UNIVERSAL_LINK_URL; +}; + +// Chrome's intent:// syntax, so that a missing app falls through to the Play Store listing instead +// of failing silently. iOS has no equivalent fallback — Safari shows an "address is invalid" alert — +// which is why the store badge sits directly below the button. +export const ANDROID_APP_URL = [ + 'intent://signup-complete#Intent', + 'scheme=org.tidepool.mobile', + 'package=io.tidepool.urchin', + `S.browser_fallback_url=${encodeURIComponent(PLAY_STORE_URL)}`, + 'end', +].join(';'); + +const platformConfig = { + ios: { + getAppUrl: getIosAppUrl, + storeUrl: APP_STORE_URL, + badgeImage: AppStoreBadge, + badgeMetric: 'Clicked App Store Badge', + }, + android: { + getAppUrl: () => ANDROID_APP_URL, + storeUrl: PLAY_STORE_URL, + badgeImage: GooglePlayBadge, + badgeMetric: 'Clicked Play Store Badge', + }, +}; + +/** + * Renders a link back to the Tidepool Mobile app, for users who arrived on the web to complete + * signup and would otherwise be stranded here. Renders nothing outside of iOS and Android, where + * the link cannot work. + */ +export const MobileAppLink = (props) => { + const { t, trackMetric } = props; + const platform = utils.getMobilePlatform(); + + if (!platform) return null; + + const { getAppUrl, storeUrl, badgeImage, badgeMetric } = platformConfig[platform]; + const appUrl = getAppUrl(window.location.search, window.location.origin); + const iosLink = platform === 'ios' ? resolveIosLinkStrategy(window.location.search) : null; + + return ( + + trackMetric('Clicked Open Tidepool Mobile App', { platform })} + sx={{ textDecoration: 'none' }} + > + + + + {/* Test scaffolding, so deliberately untranslated: visible only while a persisted + ?iosLink override is active, so the device's current mode is never a mystery. */} + {iosLink?.isOverride && ( + + {`Link override active: ${iosLink.strategy}${iosLink.sameHost ? ' (same host)' : ''} — ?iosLink=reset clears it`} + + )} + + + {t('Don\'t have the app yet?')} + + + + trackMetric(badgeMetric, { platform })} + > + {t('Download + + + + ); +}; + +MobileAppLink.propTypes = { + trackMetric: PropTypes.func.isRequired, +}; + +export default withTranslation()(MobileAppLink); diff --git a/app/components/browserwarning/images/appstore-badge.svg b/app/components/mobileapplink/images/appstore-badge.svg similarity index 100% rename from app/components/browserwarning/images/appstore-badge.svg rename to app/components/mobileapplink/images/appstore-badge.svg diff --git a/app/components/browserwarning/images/google-play-badge.png b/app/components/mobileapplink/images/google-play-badge.png similarity index 100% rename from app/components/browserwarning/images/google-play-badge.png rename to app/components/mobileapplink/images/google-play-badge.png diff --git a/app/components/mobileapplink/index.js b/app/components/mobileapplink/index.js new file mode 100644 index 0000000000..cadf293725 --- /dev/null +++ b/app/components/mobileapplink/index.js @@ -0,0 +1,3 @@ +import MobileAppLink from './MobileAppLink'; + +export default MobileAppLink; diff --git a/app/core/utils.js b/app/core/utils.js index f0e3329d94..6161599257 100644 --- a/app/core/utils.js +++ b/app/core/utils.js @@ -109,6 +109,24 @@ utils.isMobile = () => { return (userAgent.indexOf('mobi') > -1); }; +/** + * Identify the mobile platform from the user agent, for cases where a viewport-width media query + * (see MobileOnly) isn't sufficient because the behaviour differs per platform. + * + * Note that iPadOS 13+ requests desktop sites by default and reports itself as a Mac, so an iPad + * will generally return null here. + * + * @return {String|null} 'ios', 'android', or null when neither + */ +utils.getMobilePlatform = () => { + const userAgent = navigator.userAgent.toLowerCase(); + + if (/iphone|ipad|ipod/.test(userAgent)) return 'ios'; + if (userAgent.indexOf('android') > -1) return 'android'; + + return null; +}; + utils.validateEmail = email => { var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return re.test(email); diff --git a/app/keycloak.js b/app/keycloak.js index 300bdde89b..cea0208a68 100644 --- a/app/keycloak.js +++ b/app/keycloak.js @@ -72,8 +72,11 @@ export const onKeycloakEvent = (store) => (event, error) => { } case 'onAuthSuccess': { const isOauthRedirectRoute = /^\/oauth\//.test(window?.location?.pathname); + // The mobile-app page is a universal-link landing target: like the oauth redirect pages, it + // must not trigger the login flow, whose redirect would immediately navigate away from it + const isMobileAppLandingRoute = /^\/mobile-app\/?$/.test(window?.location?.pathname); // We don't trigger the login (and subsequent redirects) on the oauth redirect landing page - if (!isOauthRedirectRoute) { + if (!isOauthRedirectRoute && !isMobileAppLandingRoute) { store.dispatch(sync.keycloakAuthSuccess(event, error)); api.user.saveSession( keycloak?.tokenParsed?.sub, diff --git a/app/pages/mobileapp/MobileApp.js b/app/pages/mobileapp/MobileApp.js new file mode 100644 index 0000000000..08e229c90d --- /dev/null +++ b/app/pages/mobileapp/MobileApp.js @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withTranslation } from 'react-i18next'; +import { Box, Flex, Image, Link } from 'theme-ui'; + +import { Title, Paragraph1 } from '../../components/elements/FontStyles'; +import { + APP_STORE_URL, + PLAY_STORE_URL, + IOS_APP_URL, +} from '../../components/mobileapplink/MobileAppLink'; +import utils from '../../core/utils'; + +import AppStoreBadge from '../../components/mobileapplink/images/appstore-badge.svg'; +import GooglePlayBadge from '../../components/mobileapplink/images/google-play-badge.png'; + +/** + * Landing page for the universal link target (see static/.well-known/apple-app-site-association). + * + * When the app is installed, iOS intercepts the link and this page is never rendered. It is only + * reached when the app is missing, or when the link is opened somewhere universal links don't + * fire (desktop, some in-app browsers), so it exists purely to route the user to the right store. + */ +export const MobileApp = (props) => { + const { t } = props; + const platform = utils.getMobilePlatform(); + + const badges = [ + { key: 'ios', storeUrl: APP_STORE_URL, image: AppStoreBadge }, + { key: 'android', storeUrl: PLAY_STORE_URL, image: GooglePlayBadge }, + ].filter(({ key }) => !platform || key === platform); + + return ( + + {t('Get the Tidepool Mobile app')} + + + {t('If you already have the Tidepool Mobile app installed, it should have opened automatically. Otherwise, download it below.')} + + + {platform === 'ios' && ( + + {t('Open the Tidepool Mobile app')} + + )} + + + {badges.map(({ key, storeUrl, image }) => ( + + + {t('Download + + + ))} + + + ); +}; + +MobileApp.propTypes = { + t: PropTypes.func, +}; + +export default withTranslation()(MobileApp); diff --git a/app/pages/mobileapp/index.js b/app/pages/mobileapp/index.js new file mode 100644 index 0000000000..b719ae6fdd --- /dev/null +++ b/app/pages/mobileapp/index.js @@ -0,0 +1,3 @@ +import MobileApp from './MobileApp'; + +export default MobileApp; diff --git a/app/pages/patientdata/patientdata.js b/app/pages/patientdata/patientdata.js index 22428e47ad..01ef2f7871 100644 --- a/app/pages/patientdata/patientdata.js +++ b/app/pages/patientdata/patientdata.js @@ -59,6 +59,7 @@ import Checkbox from '../../components/elements/Checkbox'; import PopoverLabel from '../../components/elements/PopoverLabel'; import { Paragraph1, Paragraph2 } from '../../components/elements/FontStyles'; import Card from '../../components/elements/Card'; +import MobileAppLink from '../../components/mobileapplink'; import UploaderBanner from '../../components/elements/Card/Banners/Uploader.png'; import ShareBanner from '../../components/elements/Card/Banners/Share.png'; import DataConnectionsBanner from '../../components/elements/Card/Banners/DataConnections.png'; @@ -338,6 +339,8 @@ export const PatientDataClass = createReactClass({ + {isUserPatient && } + { ()} /> ()} /> ()} /> + {/* Universal link target — must stay unauthenticated, since it is reached from the app store flow */} + ()} /> ()} /> ()} /> ()} /> diff --git a/docs/mobile-app-signup/blip-signup-return-handoff.md b/docs/mobile-app-signup/blip-signup-return-handoff.md new file mode 100644 index 0000000000..d0f44c30fe --- /dev/null +++ b/docs/mobile-app-signup/blip-signup-return-handoff.md @@ -0,0 +1,80 @@ +# Handoff: "Return to the Tidepool Mobile app" button after web signup + +**Audience**: the agent/developer implementing the web (blip) half of this feature. +**Mobile half**: already implemented in `mobile-remix` (branch `improve-signup-flow`) — see *What the mobile app already does* below. The two halves are independently shippable; the button is useless-but-harmless until the mobile release with the scheme registration is in the field (the store-badge fallback covers those users). + +## Goal + +Mobile-app signup currently dead-ends in the browser: the app opens `https://{env}/signup` externally, the user verifies their email (which continues signup in whatever browser the email client opens), walks the signup steps (data donation → "connect a device account" / "share your data"), and is then stranded on the web with no path back to the app. Add a **"Open the Tidepool Mobile app"** button to the final signup page that returns the user to the app. + +**Repo scoping note**: the onboarding pages (data donation, "connect a device account" / "share your data") are blip, and the button lives on a blip page — all changes in this doc are blip changes. But Tidepool auth is Keycloak-based, so the credential-creation form and quite possibly the verification email + its link target may be served by Keycloak, not blip. Nothing in this doc requires touching those — but if anything about the verification email needs changing, look in Keycloak config/themes first, not blip. + +## Design decision (already settled — do not redesign) + +**Custom URL scheme**, not iOS universal links / Android app links. Reasons, for context: + +- Universal links don't fire when tapped from a page on the same domain they point to — and this button lives on a Tidepool page. They also don't fire from most email-client in-app browsers, which is exactly where post-email-verification traffic lands. +- App links need domain verification files (`apple-app-site-association` / `assetlinks.json`) served on **every** environment host (prod + QA/dev) plus per-domain app entitlements; the custom scheme works identically on all environments with zero server config. +- The link carries nothing sensitive (it only foregrounds the app), so the classic custom-scheme weakness (any app can claim a scheme) has no payload to leak. + +## The link + +``` +org.tidepool.mobile://signup-complete +``` + +- **Do not add query parameters** — the app ignores them (and its OAuth intent handling is scheme-guarded), but nothing reads them, so they'd be dead weight. +- **Never put this link in an email.** Email clients strip or refuse custom-scheme links. It belongs on the final signup web page only. + +## What the mobile app already does + +- **iOS** registers the `org.tidepool.mobile` scheme (`CFBundleURLTypes` in `Info.plist`). Tapping the link launches/foregrounds the app. There is deliberately no URL routing — a signed-out app lands on its Sign In screen, which is the desired destination (the user's web session does not transfer; they authenticate in-app via OAuth). +- **Android** registers an intent filter for `org.tidepool.mobile://signup-complete` on `MainActivity` (`io.tidepool.urchin`). Same behavior: foreground only, no routing. `MainActivity.onNewIntent` only treats `org.tidepool.mobile.auth://` URIs as OAuth callbacks, so this link can't be misparsed. +- The existing `org.tidepool.mobile.auth://redirect` scheme is the OAuth callback — **do not use it for this button.** + +## What to build on blip + +On the final signup page (the "connect a device account" / "share your data" step), add a **user-agent-gated** section: + +1. **Gate by mobile UA.** Render the section only for iOS/Android user agents. Desktop users (common after the email-verification hop — many people open the verification link on a laptop) must not see a button that can't work; show them nothing, or just the store badges. + +2. **The button, per platform:** + - **Android UAs** — use Chrome's `intent://` syntax so "app not installed" falls through to the Play Store instead of failing silently: + ``` + intent://signup-complete#Intent;scheme=org.tidepool.mobile;package=io.tidepool.urchin;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dio.tidepool.urchin;end + ``` + - **iOS UAs** — a plain link to `org.tidepool.mobile://signup-complete`. iOS has no fallback mechanism: if the app isn't installed, Safari shows an "address is invalid" alert, which is why the store badges below must be visually adjacent. Do **not** use the old JS trick of racing the scheme against a `setTimeout` App Store redirect — it's unreliable on modern iOS and the error alert fires anyway. + +3. **Store badges** (both platforms, below the button) as the not-installed fallback: + - Play Store: `https://play.google.com/store/apps/details?id=io.tidepool.urchin` + - App Store: link to the **Tidepool Mobile** listing (iOS bundle `org.tidepool.blipnotes`). ⚠️ **Verify the numeric App Store ID yourself** — it was not resolvable from the mobile repo or its network sandbox. Look it up with: + ``` + curl "https://itunes.apple.com/lookup?bundleId=org.tidepool.blipnotes" + ``` + and use `trackId`/`trackViewUrl` from the response. Do not guess the ID. (It's also visible in App Store Connect → the app → App Information → "Apple ID". Note it is NOT any ID found in the mobile repo's CI config: `ASC_KEY_ID` there is an App Store Connect API-key credential and `app_identifier` is the bundle ID — the Smart App Banner needs the numeric store ID, e.g. the `id123456789` in the listing URL.) + +4. **Smart App Banner** (iOS, optional but recommended): add to the page ``: + ```html + + ``` + Safari renders a native banner — "Open" when the app is installed, "View" (→ App Store) when not. Safari-only (it won't render in Chrome-on-iOS or email in-app browsers), so it complements the button rather than replacing it. + +## Environments + +Nothing environment-specific: the same scheme link works against prod and every QA/dev host. No server config, no per-domain files. + +## Known imperfections (accepted in the design) + +- Some email-client in-app webviews block unknown schemes silently; the store badges are the recovery path. +- iOS user without the app who taps the button gets the "address is invalid" alert before noticing the badges. Rare: the page is the tail of signup, and mobile signups almost always originate from the app. +- A user who started in the app but opens the verification email on another phone (without the app) hits the fallback path — expected. + +## Test checklist + +- [x] iOS Safari, app installed → tap opens Tidepool Mobile (foregrounds; Sign In if signed out) — verified 2026-09-03 on a dev build; Safari shows its `Open in "Tidepool Mobile"?` confirmation first, which is standard for custom schemes +- [x] iOS Safari, app NOT installed → **confirmed 2026-09-03**: "Safari cannot open the page because the address is invalid." alert, as predicted. (Store badge adjacency/function and Smart App Banner not yet verified; the banner was never implemented — see step 3 options in `blip-universal-link-next-steps.md`) +- [x] Android Chrome, app installed → `intent://` link opens the app — verified on real hardware +- [x] Android Chrome, app NOT installed → `intent://` link lands on the Play Store listing — verified on real hardware +- [ ] Gmail in-app browser (both platforms) after tapping a verification email → button visible and functional (or store badge path works) +- [ ] Desktop browser → no dead button rendered +- [ ] Works identically on a QA environment host (no prod-only assumptions) diff --git a/docs/mobile-app-signup/blip-universal-link-next-steps.md b/docs/mobile-app-signup/blip-universal-link-next-steps.md new file mode 100644 index 0000000000..c4a855c5fa --- /dev/null +++ b/docs/mobile-app-signup/blip-universal-link-next-steps.md @@ -0,0 +1,454 @@ +# Next steps: iOS universal link prototype + +**Companion to** `blip-signup-return-handoff.md` (the original spec for the "return to the app" +button). That doc's design decision — custom URL scheme, universal links rejected — is the thing +being re-examined here. Read it first for background. + +**Branch**: `mobile-signup-workflow` +**Repo**: blip. The iOS half lives in `mobile-remix` (branch `improve-signup-flow`). + +> ## Status 2026-09-03: PARKED — shipping the custom scheme instead +> +> Decision: ship step 3, option 1 (custom scheme, unchanged from what's committed on +> `mobile-signup-workflow`). The iOS not-installed case keeps the "address is invalid" alert, with +> the App Store badge directly below the button as the recovery path. Rationale: step 1 confirmed +> same-host suppression, so universal links require a dedicated link host + a mobile entitlement +> release + cross-team host coordination — real infrastructure for a marginal UX gain on a rare +> path. +> +> The prototype is **complete through step 1** and preserved on the `universal-link-prototype` +> branch (this doc travels with it). Step 1's result (suppression confirmed, setup validated by +> the Notes control) means resuming is cheap: the only open question left is step 2 +> (cross-subdomain). +> +> **Update, same day**: qa3 **and qa4** turned out to be free, so step 2 ran before parking — +> qa3/qa4 are siblings under `development.tidepool.org`, the strict analog of the production +> `app`/`link.tidepool.org` pair. `IOS_UNIVERSAL_LINK_HOST` now points at qa4. +> +> **EXPERIMENT COMPLETE, both steps ✅**: same-host suppression is real (step 1), sibling-subdomain +> links work perfectly (step 2) — app opens when installed, landing page when not, never an error. +> The technique is proven; what remains to ship it is purely infrastructure and process (see +> step 3): a real link host serving the AASA, a production entitlement (without `?mode=developer`, +> so a mobile release), and validating once on the real host pair since Apple's CDN — bypassed by +> developer mode — sits in the production fetch path. Whether that cost is paid now or later is a +> product decision; the custom scheme remains the shipped behaviour until it's made. + +--- + +## Where things stand + +### Shipped and working (committed in `1bc1d4157`) + +The "Open the Tidepool Mobile app" button on the Welcome page. + +| Piece | Location | +|---|---| +| Component | `app/components/mobileapplink/MobileAppLink.js` | +| Render gate | `app/pages/patientdata/patientdata.js:342` — `{isUserPatient && }` | +| UA detection | `app/core/utils.js` — `utils.getMobilePlatform()` returns `'ios' \| 'android' \| null` | +| Tests | `__tests__/unit/app/components/mobileapplink/MobileAppLink.test.js` | + +The page is `/patients/:userid/data`, rendered by `renderNoData` (`patientdata.js:261`) when the +patient has no device data. It requires all three of: viewing your *own* record (`isUserPatient`), +no device data, and an iOS/Android user agent. + +**Verified on real hardware:** +- ✅ Android Chrome, app installed → `intent://` opens the app +- ✅ Android Chrome, app uninstalled → falls through to the Play Store +- ✅ iOS Safari, app installed (dev build from Xcode), custom scheme → `Open in "Tidepool Mobile"?` + dialog → app opens to Sign In (2026-09-03, full signup flow walked end-to-end) +- ✅ iOS Safari, app **removed**, custom scheme → *"Safari cannot open the page because the address + is invalid."* (2026-09-03) — the degradation this experiment exists to fix, now reproduced + first-hand rather than assumed + +**Not yet tested:** the App Store badge link working from a real device (the artwork has been seen +rendering on the landing page), Gmail in-app browser (both platforms). Steps 1 and 2 are **both +done** — see the result sections below: suppression is same-host only, and the two-host design +works completely. + +### Uncommitted — the universal link prototype + +All changes are in the working tree, unstaged. + +``` + M __tests__/unit/app/components/mobileapplink/MobileAppLink.test.js + M app/components/mobileapplink/MobileAppLink.js + M app/routes.js + M server.js + M webpack.config.js +?? __tests__/unit/app/pages/mobileapp/ +?? app/pages/mobileapp/ +?? static/.well-known/ +``` + +| File | Purpose | +|---|---| +| `static/.well-known/apple-app-site-association` | The association file. `.well-known/` is **required** for developer mode | +| `server.js:128-139` | Production route forcing `application/json` (the file is extensionless, so `express.static` can't infer the type). Serves from memory — read once at startup — after a CodeQL alert about unratelimited per-request filesystem access | +| `webpack.config.js` → `devServer.setupMiddlewares` | Same for dev; also stops `historyApiFallback` swallowing the path | +| `app/pages/mobileapp/MobileApp.js` | Landing page the universal link resolves to when the app isn't installed | +| `app/routes.js:468` | Unauthenticated route `/mobile-app` | +| `MobileAppLink.js:25`, `:31`, `:43` | `IOS_UNIVERSAL_LINK_HOST`, `IOS_LINK_STRATEGY`, `getIosAppUrl()` | + +Status: 20 tests passing, lint clean. AASA verified served locally over a real dev server — +`HTTP 200`, `Content-Type: application/json; charset=utf-8`, `num_redirects=0`. + +**Default behaviour is unchanged.** iOS still gets the custom scheme. The universal link only +activates via an explicit query param, so none of this regresses the working Android path. + +--- + +## Why we're doing this + +iOS currently degrades badly. With the app not installed, `org.tidepool.mobile://signup-complete` +makes Safari show an **"address is invalid" alert** — whereas Android falls through cleanly to the +Play Store via `S.browser_fallback_url`. The goal is iOS parity: open the app, or land somewhere +useful, but never error. + +A universal link would fix this — it resolves to a real page when the app is missing. The blocker +in the original doc is that **iOS suppresses universal links pointing at the current page's own +host**, opening them in Safari instead. Apple documents this in the App Search Programming Guide. +Sibling subdomains reportedly count as different hosts, so `app.tidepool.org` → `link.tidepool.org` +should work. + +**That suppression claim is the premise of the entire design, and it has not been verified for our +setup. Verifying it is step 1.** + +--- + +## Step 1 — Does same-host suppression actually happen? + +If it doesn't, no link subdomain is needed, the design collapses to a single host, and most of the +complexity disappears. Worth knowing before building anything else. + +### The trap + +A bare negative test is worthless. "The app didn't open" is equally consistent with suppression, a +malformed AASA, a wrong Team ID, a bundle-ID mismatch, or the device toggle being off. **You need a +positive control.** Two work on a single host: + +1. **`swcutil verify`** — proves the file is fetched and the path pattern matches: + ```bash + swcutil verify -d qa3.development.tidepool.org \ + -j \ + -u https://qa3.development.tidepool.org/mobile-app + ``` + +2. **Long-press the link** (the decisive one). If the context menu offers **"Open in Tidepool + Mobile"**, iOS has recognised it as a valid universal link — so a plain tap staying in Safari is + deliberate suppression, not broken config. + +### Setup + +> **Update 2026-09-03**: the branch is actually deployed to **`qa3`**, not qa1 — substitute +> `qa3.development.tidepool.org` for `qa1` throughout this doc. The AASA on qa3 is verified in +> full: `status=200 redirects=0 type=application/json`, and the body carries the real appID +> `75U4X84TEG.org.tidepool.blipnotes` claiming `/mobile-app`. The server half needs nothing more. + +1. ✅ Placeholders filled: the AASA carries `75U4X84TEG.org.tidepool.blipnotes`. + +2. ✅ Deployed to `qa3` and verified: + ```bash + curl -i https://qa3.development.tidepool.org/.well-known/apple-app-site-association + ``` + `200`, `application/json`, **no redirects** — Apple rejects redirects. + +3. In `mobile-remix`, add **both** hosts to the associated-domains entitlement now, even though + step 1 only uses one. Costs nothing and avoids a rebuild for step 2 (full instructions for the + mobile side: `mobile-remix-applinks-handoff.md`). ⚠️ Earlier drafts said qa1/qa2 — a build + carrying only those will silently never match on qa3: + ``` + applinks:qa3.development.tidepool.org?mode=developer + applinks:qa4.development.tidepool.org?mode=developer + ``` + (`qa4` is the second host — qa1/qa2 turned out to be in use by others; if the + step 2 deploy lands on a different host instead, update both the constant and the entitlement.) + +4. Build to a physical device from Xcode. `?mode=developer` requires a **development-signed** build + — TestFlight and Ad Hoc cannot use it. Enable **Settings → Developer → Associated Domains + Development** on the device. + + `?mode=developer` isn't strictly required for a public QA host, but Apple's CDN caches AASA + files; developer mode fetches direct from the host so edits take effect immediately. + +### Run it + +Load the Welcome page on `qa3` as a patient with no data, then: + +``` +/patients//data?iosLink=universal&linkHost=same +``` + +`linkHost=same` points the link at `window.location.origin`. + +**The override is sticky (added 2026-09-03, needs the redeploy).** Typing query params on a phone +is painful, so an `?iosLink` override persists on the device (localStorage) and keeps applying on +every later visit until replaced or cleared with `?iosLink=reset`. While one is active, a caption +under the button says so (e.g. *"Link override active: universal (same host)"*), so the device's +mode is never a mystery. Practical upshot: prepare the full URLs once on the Mac and get them to +the phone without typing — AirDrop/iMessage the link, or make a QR code +(`qrencode -o mode.png 'https://qa3.development.tidepool.org/patients//data?iosLink=universal&linkHost=same'`) +and scan it with the camera. After that one tap, plain navigation stays in that mode. One constraint: the +override only persists when the button actually renders — so the override URL must be the Welcome +page itself (mobile UA, own record, no data), which the protocol's URL already is. + +### Findings from the first device run (2026-09-03) + +- **Bug found and fixed: the landing page bounced authenticated users.** Tapping the button + navigated to `/mobile-app`, which rendered — then the page redirected back to the Welcome page. + Cause: `keycloak.js` `onAuthSuccess` dispatches `async.login(api)` on every authenticated page + load, and that action always ends in a `push()` to the user's home route. Fixed by excluding + `/mobile-app` from the login trigger, the same carve-out the oauth landing pages already use + (`app/keycloak.js`, tested in `test/unit/keycloak.test.js`). **Needs a redeploy to qa3** before + the landing page can be evaluated again. This mattered beyond the test: real post-signup users + are authenticated too. +- **Long-pressing the button produces no callout menu at all** — not even the standard link + preview. Likely the markup: the anchor wraps a `