Skip to content

Commit 0c84eee

Browse files
authored
fix(redirect): browser-aware mobile fallback so regular browsers go to App Store (#23)
When a mobile click reaches the redirect handler in LinkForty's setup, the OS-level Universal Link / App Link check has already run and didn't fire (otherwise we'd never see the request). For a regular browser (Safari, Chrome) that means the app isn't installed, so the App Store / Play Store URL is the right destination. The previous priority order sent regular browsers to web_fallback_url first, leaving users complaining that iOS visitors were getting a desktop landing page instead of the App Store. For in-app browsers (Gmail, GSA, Outlook, Facebook, Instagram, etc.) the story is different: those browsers bypass UL/App Link regardless of install state, so we don't actually know whether the app is installed. Web fallback URL is preferred there because it gives UL a second chance to fire if the fallback is hosted on the app's UL/App-Link domain — this is the path the email-marketing flow depends on. Changes: - Added isAndroidInAppBrowser() (sibling of the existing isIOSInAppBrowser which was defined but never wired up). Detects FB/Instagram/Line/ KakaoTalk/Twitter/LinkedIn/WeChat/Outlook/WhatsApp/Pinterest/Telegram/ Snapchat plus the generic Android WebView marker. - Added pickMobileFallbackUrl() helper that branches on browser class and returns { url, reason } with the right priority for each. - Refactored all three priority call sites (async tracking redirect resolution, regular redirect path, and interstitial fallback) to use the shared helper. Path A (interstitial) and Path B (regular) now agree on priority — they were inconsistent before. - Made isIOSInAppBrowser exported alongside the new helpers. Tests: 32 new unit tests in redirect.test.ts covering both helpers and the resolver across iOS/Android × regular/in-app × all permutations of configured URLs. All 113 core tests pass (81 existing + 32 new). Refs: SIT-163
1 parent e6efcf6 commit 0c84eee

2 files changed

Lines changed: 283 additions & 42 deletions

File tree

src/routes/redirect.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
isIOSInAppBrowser,
4+
isAndroidInAppBrowser,
5+
pickMobileFallbackUrl,
6+
} from './redirect.js';
7+
8+
// Real-world UA strings (truncated where helpful) for use across test cases.
9+
const UA = {
10+
// Regular browsers — should NOT be detected as in-app
11+
iosSafari:
12+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
13+
iosChrome:
14+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1',
15+
androidChrome:
16+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
17+
androidFirefox:
18+
'Mozilla/5.0 (Android 14; Mobile; rv:125.0) Gecko/125.0 Firefox/125.0',
19+
20+
// iOS in-app browsers
21+
iosGmail:
22+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 GSA/322.0.616052181 Safari/604.1',
23+
iosFacebook:
24+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/450.0.0.0]',
25+
iosInstagram:
26+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 320.0.0.0',
27+
iosOutlook:
28+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Outlook-iOS/2.0',
29+
iosTwitter:
30+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Twitter for iPhone/10.0',
31+
32+
// Android in-app browsers
33+
androidWebview:
34+
'Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36',
35+
androidFacebook:
36+
'Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/450.0.0.0]',
37+
androidInstagram:
38+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Instagram 320.0.0.0',
39+
androidLine:
40+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Line/13.0.0',
41+
androidWhatsapp:
42+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 WhatsApp/2.24.0',
43+
};
44+
45+
const URLS = {
46+
iosStore: 'https://apps.apple.com/app/example/id123',
47+
androidStore: 'https://play.google.com/store/apps/details?id=com.example',
48+
webFallback: 'https://example.com/landing',
49+
};
50+
51+
describe('isIOSInAppBrowser', () => {
52+
it('detects Gmail (GSA token)', () => {
53+
expect(isIOSInAppBrowser(UA.iosGmail)).toBe(true);
54+
});
55+
it('detects Facebook (FBAN/FBAV)', () => {
56+
expect(isIOSInAppBrowser(UA.iosFacebook)).toBe(true);
57+
});
58+
it('detects Instagram', () => {
59+
expect(isIOSInAppBrowser(UA.iosInstagram)).toBe(true);
60+
});
61+
it('detects Outlook', () => {
62+
expect(isIOSInAppBrowser(UA.iosOutlook)).toBe(true);
63+
});
64+
it('detects Twitter', () => {
65+
expect(isIOSInAppBrowser(UA.iosTwitter)).toBe(true);
66+
});
67+
it('does not flag Safari as in-app', () => {
68+
expect(isIOSInAppBrowser(UA.iosSafari)).toBe(false);
69+
});
70+
it('does not flag Chrome on iOS as in-app', () => {
71+
expect(isIOSInAppBrowser(UA.iosChrome)).toBe(false);
72+
});
73+
});
74+
75+
describe('isAndroidInAppBrowser', () => {
76+
it('detects generic Android WebView via wv) marker', () => {
77+
expect(isAndroidInAppBrowser(UA.androidWebview)).toBe(true);
78+
});
79+
it('detects Facebook (FB_IAB/FBAN/FBAV)', () => {
80+
expect(isAndroidInAppBrowser(UA.androidFacebook)).toBe(true);
81+
});
82+
it('detects Instagram', () => {
83+
expect(isAndroidInAppBrowser(UA.androidInstagram)).toBe(true);
84+
});
85+
it('detects Line', () => {
86+
expect(isAndroidInAppBrowser(UA.androidLine)).toBe(true);
87+
});
88+
it('detects WhatsApp', () => {
89+
expect(isAndroidInAppBrowser(UA.androidWhatsapp)).toBe(true);
90+
});
91+
it('does not flag Chrome as in-app', () => {
92+
expect(isAndroidInAppBrowser(UA.androidChrome)).toBe(false);
93+
});
94+
it('does not flag Firefox as in-app', () => {
95+
expect(isAndroidInAppBrowser(UA.androidFirefox)).toBe(false);
96+
});
97+
});
98+
99+
describe('pickMobileFallbackUrl — iOS regular browser (Safari/Chrome)', () => {
100+
it('prefers iOS store URL over web fallback', () => {
101+
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
102+
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
103+
});
104+
it('falls back to web fallback when iOS store URL is absent', () => {
105+
const r = pickMobileFallbackUrl('ios', UA.iosSafari, null, URLS.androidStore, URLS.webFallback);
106+
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
107+
});
108+
it('uses iOS store URL when web fallback is absent', () => {
109+
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, null);
110+
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
111+
});
112+
it('returns null when both iOS store URL and web fallback are absent', () => {
113+
expect(pickMobileFallbackUrl('ios', UA.iosSafari, null, URLS.androidStore, null)).toBeNull();
114+
});
115+
});
116+
117+
describe('pickMobileFallbackUrl — iOS in-app browser (Gmail/FB/Instagram/etc.)', () => {
118+
it('prefers web fallback over iOS store URL', () => {
119+
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, URLS.webFallback);
120+
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
121+
});
122+
it('falls back to iOS store URL when web fallback is absent', () => {
123+
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, null);
124+
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
125+
});
126+
it('returns null when both iOS store URL and web fallback are absent', () => {
127+
expect(pickMobileFallbackUrl('ios', UA.iosGmail, null, URLS.androidStore, null)).toBeNull();
128+
});
129+
});
130+
131+
describe('pickMobileFallbackUrl — Android regular browser (Chrome/Firefox)', () => {
132+
it('prefers Android store URL over web fallback', () => {
133+
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, URLS.webFallback);
134+
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
135+
});
136+
it('falls back to web fallback when Android store URL is absent', () => {
137+
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, null, URLS.webFallback);
138+
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
139+
});
140+
it('uses Android store URL when web fallback is absent', () => {
141+
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, null);
142+
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
143+
});
144+
it('returns null when both Android store URL and web fallback are absent', () => {
145+
expect(pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, null, null)).toBeNull();
146+
});
147+
});
148+
149+
describe('pickMobileFallbackUrl — Android in-app browser (FB/Instagram/Line/WebView)', () => {
150+
it('prefers web fallback over Android store URL', () => {
151+
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, URLS.webFallback);
152+
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
153+
});
154+
it('detects WebView via wv) marker and prefers web fallback', () => {
155+
const r = pickMobileFallbackUrl('android', UA.androidWebview, URLS.iosStore, URLS.androidStore, URLS.webFallback);
156+
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
157+
});
158+
it('falls back to Android store URL when web fallback is absent', () => {
159+
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, null);
160+
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
161+
});
162+
});
163+
164+
describe('pickMobileFallbackUrl — reporter scenario regression test', () => {
165+
// Reproduces SIT-163: user creates a link with iOS, Android, and web fallback URLs;
166+
// mobile visitor expects the App/Play Store; previously got the web fallback.
167+
it('iOS Safari → iOS App Store (was: web fallback)', () => {
168+
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
169+
expect(r?.url).toBe(URLS.iosStore);
170+
expect(r?.reason).toBe('ios_app_store_url');
171+
});
172+
it('Android Chrome → Play Store (was: web fallback)', () => {
173+
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, URLS.webFallback);
174+
expect(r?.url).toBe(URLS.androidStore);
175+
expect(r?.reason).toBe('android_app_store_url');
176+
});
177+
// And the email-marketing flow that the prior fix protected stays correct:
178+
it('iOS Gmail in-app → web fallback (preserves UL second-chance)', () => {
179+
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, URLS.webFallback);
180+
expect(r?.url).toBe(URLS.webFallback);
181+
});
182+
it('Android Facebook in-app → web fallback (preserves UL second-chance)', () => {
183+
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, URLS.webFallback);
184+
expect(r?.url).toBe(URLS.webFallback);
185+
});
186+
});

src/routes/redirect.ts

Lines changed: 97 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { emitClickEvent } from '../lib/event-emitter.js';
99
* Detect iOS in-app browsers where Universal Links don't fire.
1010
* These browsers use WKWebView which bypasses the Universal Links mechanism.
1111
*/
12-
function isIOSInAppBrowser(userAgent: string): boolean {
12+
export function isIOSInAppBrowser(userAgent: string): boolean {
1313
const inAppPatterns = [
1414
/GSA\//i, // Google Search App (Gmail in-app browser)
1515
/Gmail\//i, // Gmail
@@ -24,6 +24,68 @@ function isIOSInAppBrowser(userAgent: string): boolean {
2424
return inAppPatterns.some(pattern => pattern.test(userAgent));
2525
}
2626

27+
/**
28+
* Detect Android in-app browsers where App Links don't fire.
29+
* These browsers use Android WebView (or app-specific webviews) that bypass
30+
* the App Link / Digital Asset Link mechanism.
31+
*/
32+
export function isAndroidInAppBrowser(userAgent: string): boolean {
33+
const inAppPatterns = [
34+
/FB_IAB|FBAN|FBAV/i, // Facebook in-app browser
35+
/Instagram/i,
36+
/Line\//i,
37+
/KAKAOTALK/i,
38+
/Twitter/i,
39+
/LinkedIn/i,
40+
/MicroMessenger/i, // WeChat
41+
/Outlook-Android/i,
42+
/WhatsApp/i,
43+
/Pinterest/i,
44+
/Telegram/i,
45+
/Snapchat/i,
46+
/\swv\)/, // Generic Android WebView marker (e.g. "Mobile Safari/537.36; wv)")
47+
];
48+
return inAppPatterns.some(pattern => pattern.test(userAgent));
49+
}
50+
51+
/**
52+
* Pick the destination URL for a mobile click that has fallen through the
53+
* Universal Link / App Link / app_scheme priority steps. The choice depends on
54+
* whether the click is from an in-app browser:
55+
*
56+
* - Regular browser (Safari, Chrome): the OS-level UL/App Link check ran and
57+
* didn't fire, so the app must not be installed → prefer the App/Play Store URL.
58+
*
59+
* - In-app browser (Gmail, GSA, FB, Instagram, Outlook, etc.): UL is bypassed
60+
* regardless of install state, so we don't know if the app is installed →
61+
* prefer the web fallback URL, which gives the OS another chance to fire UL
62+
* if the fallback is on the app's UL/App-Link domain.
63+
*
64+
* Returns null if no URL is available (caller should fall back to original_url).
65+
*/
66+
export function pickMobileFallbackUrl(
67+
device: 'ios' | 'android',
68+
userAgent: string,
69+
iosUrl: string | null,
70+
androidUrl: string | null,
71+
webFallbackUrl: string | null,
72+
): { url: string; reason: string } | null {
73+
const inApp = device === 'ios'
74+
? isIOSInAppBrowser(userAgent)
75+
: isAndroidInAppBrowser(userAgent);
76+
const storeUrl = device === 'ios' ? iosUrl : androidUrl;
77+
const storeReason = device === 'ios' ? 'ios_app_store_url' : 'android_app_store_url';
78+
79+
if (inApp) {
80+
if (webFallbackUrl) return { url: webFallbackUrl, reason: 'web_fallback_url' };
81+
if (storeUrl) return { url: storeUrl, reason: storeReason };
82+
} else {
83+
if (storeUrl) return { url: storeUrl, reason: storeReason };
84+
if (webFallbackUrl) return { url: webFallbackUrl, reason: 'web_fallback_url' };
85+
}
86+
return null;
87+
}
88+
2789
/**
2890
* Generate an interstitial HTML page that tries to open the app via custom scheme,
2991
* then falls back to the App Store / Play Store.
@@ -273,15 +335,12 @@ export async function redirectRoutes(fastify: FastifyInstance) {
273335
} else if (link.app_scheme && link.deep_link_path) {
274336
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
275337
redirectReason = 'app_scheme';
276-
} else if (webFallback) {
277-
// Web fallback takes priority over store URL — if the fallback is on
278-
// the app's Universal Link domain, iOS will open the app directly.
279-
// If the app isn't installed, the fallback page shows store links.
280-
redirectUrl = webFallback;
281-
redirectReason = 'web_fallback_url';
282-
} else if (iosStoreUrl) {
283-
redirectUrl = iosStoreUrl;
284-
redirectReason = 'ios_app_store_url';
338+
} else {
339+
const fb = pickMobileFallbackUrl('ios', userAgent, iosStoreUrl, androidStoreUrl, webFallback);
340+
if (fb) {
341+
redirectUrl = fb.url;
342+
redirectReason = fb.reason;
343+
}
285344
}
286345
} else if (deviceType === 'android') {
287346
if (link.android_app_link) {
@@ -290,12 +349,12 @@ export async function redirectRoutes(fastify: FastifyInstance) {
290349
} else if (link.app_scheme && link.deep_link_path) {
291350
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
292351
redirectReason = 'app_scheme';
293-
} else if (webFallback) {
294-
redirectUrl = webFallback;
295-
redirectReason = 'web_fallback_url';
296-
} else if (androidStoreUrl) {
297-
redirectUrl = androidStoreUrl;
298-
redirectReason = 'android_app_store_url';
352+
} else {
353+
const fb = pickMobileFallbackUrl('android', userAgent, iosStoreUrl, androidStoreUrl, webFallback);
354+
if (fb) {
355+
redirectUrl = fb.url;
356+
redirectReason = fb.reason;
357+
}
299358
}
300359
} else if (deviceType === 'web' && webFallback) {
301360
redirectUrl = webFallback;
@@ -393,44 +452,37 @@ export async function redirectRoutes(fastify: FastifyInstance) {
393452

394453
if (device === 'ios') {
395454
// iOS Priority:
396-
// 1. Universal Link (HTTPS URL with AASA file) - if app installed, opens app
397-
// 2. URI scheme (myapp://path) - fallback when Universal Links fail
398-
// 3. Web fallback URL - if on the app's Universal Link domain, iOS opens the app;
399-
// if app isn't installed, the page shows store download links
400-
// 4. App Store URL (link → template → workspace) - direct store redirect
401-
// 5. Original URL - ultimate fallback
402-
455+
// 1. Universal Link (HTTPS URL with AASA file) — if app installed, OS opens app
456+
// (this branch only runs when UL didn't fire upstream, e.g. in-app browser)
457+
// 2. URI scheme (myapp://path) — explicit deep link
458+
// 3. Mobile fallback (browser-aware):
459+
// - regular browser: App Store URL > web fallback URL
460+
// (UL would have fired if app installed, so app is not installed)
461+
// - in-app browser: web fallback URL > App Store URL
462+
// (UL was bypassed; web fallback gives UL a second chance to fire)
463+
// 4. Original URL — ultimate fallback
403464
if (link.ios_universal_link) {
404465
redirectUrl = link.ios_universal_link;
405466
} else if (link.app_scheme && link.deep_link_path) {
406467
// Build URI scheme URL: myapp://product/123
407468
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
408469
useSchemeUrl = true;
409-
} else if (webFallbackUrl) {
410-
redirectUrl = webFallbackUrl;
411-
} else if (iosUrl) {
412-
redirectUrl = iosUrl;
470+
} else {
471+
const fb = pickMobileFallbackUrl('ios', userAgent, iosUrl, androidUrl, webFallbackUrl);
472+
if (fb) redirectUrl = fb.url;
413473
}
414474

415475
} else if (device === 'android') {
416-
// Android Priority:
417-
// 1. App Link (HTTPS URL with Digital Asset Links) - if app installed, opens app
418-
// 2. URI scheme (myapp://path) - fallback when App Links fail
419-
// 3. Web fallback URL - if on the app's App Link domain, Android opens the app;
420-
// if app isn't installed, the page shows store download links
421-
// 4. Play Store URL (link → template → workspace) - direct store redirect
422-
// 5. Original URL - ultimate fallback
423-
476+
// Android Priority — same logic as iOS, with android_app_link in place of UL
424477
if (link.android_app_link) {
425478
redirectUrl = link.android_app_link;
426479
} else if (link.app_scheme && link.deep_link_path) {
427480
// Build URI scheme URL: myapp://product/123
428481
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
429482
useSchemeUrl = true;
430-
} else if (webFallbackUrl) {
431-
redirectUrl = webFallbackUrl;
432-
} else if (androidUrl) {
433-
redirectUrl = androidUrl;
483+
} else {
484+
const fb = pickMobileFallbackUrl('android', userAgent, iosUrl, androidUrl, webFallbackUrl);
485+
if (fb) redirectUrl = fb.url;
434486
}
435487

436488
} else if (device === 'web') {
@@ -483,9 +535,12 @@ export async function redirectRoutes(fastify: FastifyInstance) {
483535
const schemeUrl = link.custom_scheme_url
484536
|| `${link.app_scheme}://${deepPath}`;
485537

486-
const storeFallback = device === 'ios'
487-
? (iosUrl || webFallbackUrl || link.original_url)
488-
: (androidUrl || webFallbackUrl || link.original_url);
538+
// The interstitial JS tries the scheme first; storeFallback is what we
539+
// navigate to if the scheme doesn't open the app within ~1.5s. Pick it
540+
// browser-aware: regular browsers prefer the store URL, in-app browsers
541+
// prefer the web fallback (gives UL a second chance to fire).
542+
const fb = pickMobileFallbackUrl(device, userAgent, iosUrl, androidUrl, webFallbackUrl);
543+
const storeFallback = fb?.url || link.original_url;
489544

490545
if (storeFallback) {
491546
let fullSchemeUrl = schemeUrl;

0 commit comments

Comments
 (0)