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 })}
+ >
+
+
+
+
+ );
+};
+
+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 }) => (
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+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 `