From e01ae7ebf501c5a7c7f5e75b1ae5d375e9436419 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:33:27 +0000 Subject: [PATCH 1/3] feat: add native app deep links Co-Authored-By: peer@cal.com --- apps/mobile/app.json | 90 ++++++++++++++++++ apps/mobile/app/(tabs)/_layout.tsx | 6 +- apps/mobile/app/+native-intent.tsx | 5 + apps/mobile/utils/app-link-navigation.test.js | 54 +++++++++++ apps/mobile/utils/app-link-navigation.ts | 95 +++++++++++++++++++ .../utils/landing-page-navigation.test.js | 10 ++ apps/mobile/utils/landing-page-navigation.ts | 6 ++ 7 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/app/+native-intent.tsx create mode 100644 apps/mobile/utils/app-link-navigation.test.js create mode 100644 apps/mobile/utils/app-link-navigation.ts diff --git a/apps/mobile/app.json b/apps/mobile/app.json index aa02c3e..0ab2826 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -22,6 +22,7 @@ "appleTeamId": "ADWKXDJV75", "deploymentTarget": "18.0", "bundleIdentifier": "com.cal.companion", + "associatedDomains": ["applinks:app.cal.com", "applinks:app.cal.eu"], "userInterfaceStyle": "automatic", "infoPlist": { "ITSAppUsesNonExemptEncryption": false @@ -39,6 +40,95 @@ }, "edgeToEdgeEnabled": true, "package": "com.calcom.companion", + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [ + { + "scheme": "https", + "host": "app.cal.com", + "path": "/bookings" + }, + { + "scheme": "https", + "host": "app.cal.com", + "pathPrefix": "/bookings/" + }, + { + "scheme": "https", + "host": "app.cal.com", + "pathPrefix": "/booking/" + }, + { + "scheme": "https", + "host": "app.cal.com", + "path": "/event-types" + }, + { + "scheme": "https", + "host": "app.cal.com", + "pathPrefix": "/event-types/" + }, + { + "scheme": "https", + "host": "app.cal.com", + "path": "/availability" + }, + { + "scheme": "https", + "host": "app.cal.com", + "pathPrefix": "/availability/" + }, + { + "scheme": "https", + "host": "app.cal.com", + "path": "/more" + }, + { + "scheme": "https", + "host": "app.cal.eu", + "path": "/bookings" + }, + { + "scheme": "https", + "host": "app.cal.eu", + "pathPrefix": "/bookings/" + }, + { + "scheme": "https", + "host": "app.cal.eu", + "pathPrefix": "/booking/" + }, + { + "scheme": "https", + "host": "app.cal.eu", + "path": "/event-types" + }, + { + "scheme": "https", + "host": "app.cal.eu", + "pathPrefix": "/event-types/" + }, + { + "scheme": "https", + "host": "app.cal.eu", + "path": "/availability" + }, + { + "scheme": "https", + "host": "app.cal.eu", + "pathPrefix": "/availability/" + }, + { + "scheme": "https", + "host": "app.cal.eu", + "path": "/more" + } + ], + "category": ["BROWSABLE", "DEFAULT"] + } + ], "softwareKeyboardLayoutMode": "pan", "usesCleartextTraffic": false, "userInterfaceStyle": "automatic" diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index b2966a2..c8904ff 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; -import { Tabs, useRouter, useSegments, VectorIcon } from "expo-router"; +import { Tabs, useGlobalSearchParams, useRouter, useSegments, VectorIcon } from "expo-router"; import { NativeTabs } from "expo-router/unstable-native-tabs"; import { useEffect, useRef } from "react"; import type { ColorValue, ImageSourcePropType } from "react-native"; @@ -15,6 +15,7 @@ type VectorIconFamily = { export default function TabLayout() { const router = useRouter(); const segments = useSegments(); + const { source } = useGlobalSearchParams<{ source?: string }>(); const segmentKey = segments.join("/"); const { preferences, isLoading } = useUserPreferences(); const hasNavigated = useRef(false); @@ -39,6 +40,7 @@ export default function TabLayout() { const decision = getInitialLandingRedirectDecision({ landingPage: preferences.landingPage, segments: segmentKey ? segmentKey.split("/") : [], + isAppLink: source === "app-link", }); if (decision === "wait") { @@ -59,7 +61,7 @@ export default function TabLayout() { // biome-ignore lint/suspicious/noExplicitAny: expo-router types don't support dynamic route strings from preferences router.navigate(route as any); }, 50); - }, [isLoading, preferences.landingPage, router, segmentKey]); + }, [isLoading, preferences.landingPage, router, segmentKey, source]); if (Platform.OS === "web") { return ; diff --git a/apps/mobile/app/+native-intent.tsx b/apps/mobile/app/+native-intent.tsx new file mode 100644 index 0000000..77ff5f9 --- /dev/null +++ b/apps/mobile/app/+native-intent.tsx @@ -0,0 +1,5 @@ +import { getNativeRouteFromAppLink } from "@/utils/app-link-navigation"; + +export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { + return getNativeRouteFromAppLink(path); +} diff --git a/apps/mobile/utils/app-link-navigation.test.js b/apps/mobile/utils/app-link-navigation.test.js new file mode 100644 index 0000000..f1709c9 --- /dev/null +++ b/apps/mobile/utils/app-link-navigation.test.js @@ -0,0 +1,54 @@ +import { describe, expect, test } from "@jest/globals"; +import { getNativeRouteFromAppLink } from "@/utils/app-link-navigation"; + +describe("getNativeRouteFromAppLink", () => { + test.each([ + ["https://app.cal.com/bookings", "/(tabs)/(bookings)?source=app-link"], + [ + "https://app.cal.com/bookings/unconfirmed", + "/(tabs)/(bookings)?filter=unconfirmed&source=app-link", + ], + [ + "https://app.cal.com/bookings/recurring", + "/(tabs)/(bookings)?filter=recurring&source=app-link", + ], + [ + "https://app.cal.com/bookings/cancelled", + "/(tabs)/(bookings)?filter=cancelled&source=app-link", + ], + [ + "https://app.cal.com/bookings/upcoming?uid=booking-uid", + "/(tabs)/(bookings)/booking-detail?uid=booking-uid&source=app-link", + ], + [ + "https://app.cal.com/booking/booking-uid", + "/(tabs)/(bookings)/booking-detail?uid=booking-uid&source=app-link", + ], + ["https://app.cal.com/event-types", "/(tabs)/(event-types)?source=app-link"], + [ + "https://app.cal.com/event-types/123", + "/(tabs)/(event-types)/event-type-detail?id=123&source=app-link", + ], + ["https://app.cal.com/availability", "/(tabs)/(availability)?source=app-link"], + [ + "https://app.cal.com/availability/456", + "/(tabs)/(availability)/availability-detail?id=456&source=app-link", + ], + ["https://app.cal.com/more", "/(tabs)/(more)?source=app-link"], + ["https://app.cal.eu/bookings/past", "/(tabs)/(bookings)?filter=past&source=app-link"], + ])("maps %s to %s", (appLink, nativeRoute) => { + expect(getNativeRouteFromAppLink(appLink)).toBe(nativeRoute); + }); + + test.each([ + "https://cal.com/peer", + "https://app.cal.com/apps", + "https://app.cal.com/bookings/invalid", + "https://app.cal.com/event-types/not-an-id", + "https://example.com/bookings", + "calcom://bookings", + "/bookings", + ])("leaves unsupported links unchanged: %s", (path) => { + expect(getNativeRouteFromAppLink(path)).toBe(path); + }); +}); diff --git a/apps/mobile/utils/app-link-navigation.ts b/apps/mobile/utils/app-link-navigation.ts new file mode 100644 index 0000000..d9cee6b --- /dev/null +++ b/apps/mobile/utils/app-link-navigation.ts @@ -0,0 +1,95 @@ +import { CAL_APP_HOSTNAMES } from "@/utils/region"; + +const BOOKING_FILTERS = new Set(["upcoming", "unconfirmed", "recurring", "past", "cancelled"]); +const APP_LINK_SOURCE = "app-link"; + +function routeWithParams(pathname: string, params: Record = {}): string { + const searchParams = new URLSearchParams({ ...params, source: APP_LINK_SOURCE }); + return `${pathname}?${searchParams.toString()}`; +} + +function isNumericId(value: string | undefined): value is string { + return value !== undefined && /^\d+$/.test(value); +} + +function decodePathSegment(segment: string): string | null { + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +export function getNativeRouteFromAppLink(path: string): string { + let url: URL; + + try { + url = new URL(path); + } catch { + return path; + } + + if (url.protocol !== "https:" || !CAL_APP_HOSTNAMES.has(url.hostname.toLowerCase())) { + return path; + } + + const segments = url.pathname.split("/").filter(Boolean); + const [section, detail, extra] = segments; + + if (section === "bookings" && extra === undefined) { + const isBookingsIndex = detail === undefined; + const isBookingFilter = detail !== undefined && BOOKING_FILTERS.has(detail); + + if (!isBookingsIndex && !isBookingFilter) { + return path; + } + + const uid = url.searchParams.get("uid"); + if (uid) { + return routeWithParams("/(tabs)/(bookings)/booking-detail", { uid }); + } + + return routeWithParams("/(tabs)/(bookings)", detail ? { filter: detail } : {}); + } + + if (section === "booking" && detail && extra === undefined) { + const uid = decodePathSegment(detail); + if (!uid) { + return path; + } + + return routeWithParams("/(tabs)/(bookings)/booking-detail", { + uid, + }); + } + + if (section === "event-types" && extra === undefined) { + if (detail === undefined) { + return routeWithParams("/(tabs)/(event-types)"); + } + + if (isNumericId(detail)) { + return routeWithParams("/(tabs)/(event-types)/event-type-detail", { id: detail }); + } + + return path; + } + + if (section === "availability" && extra === undefined) { + if (detail === undefined) { + return routeWithParams("/(tabs)/(availability)"); + } + + if (isNumericId(detail)) { + return routeWithParams("/(tabs)/(availability)/availability-detail", { id: detail }); + } + + return path; + } + + if (section === "more" && detail === undefined) { + return routeWithParams("/(tabs)/(more)"); + } + + return path; +} diff --git a/apps/mobile/utils/landing-page-navigation.test.js b/apps/mobile/utils/landing-page-navigation.test.js index 3b5430b..4486f83 100644 --- a/apps/mobile/utils/landing-page-navigation.test.js +++ b/apps/mobile/utils/landing-page-navigation.test.js @@ -47,6 +47,16 @@ describe("getInitialLandingRedirectDecision", () => { ).toBe("skip"); }); + test("skips redirect for app links to the bookings index", () => { + expect( + getInitialLandingRedirectDecision({ + landingPage: "event-types", + segments: ["(tabs)", "(bookings)"], + isAppLink: true, + }) + ).toBe("skip"); + }); + test("waits while segments are empty or outside the tabs layout", () => { expect( getInitialLandingRedirectDecision({ diff --git a/apps/mobile/utils/landing-page-navigation.ts b/apps/mobile/utils/landing-page-navigation.ts index c360500..a7b494d 100644 --- a/apps/mobile/utils/landing-page-navigation.ts +++ b/apps/mobile/utils/landing-page-navigation.ts @@ -5,6 +5,7 @@ export type InitialLandingRedirectDecision = "wait" | "skip" | "redirect"; type InitialLandingRedirectDecisionInput = { landingPage: LandingPage; segments: readonly string[]; + isAppLink?: boolean; }; const TABS_SEGMENT = "(tabs)"; @@ -26,11 +27,16 @@ function isDefaultBookingsIndex(segments: readonly string[]): boolean { export function getInitialLandingRedirectDecision({ landingPage, segments, + isAppLink = false, }: InitialLandingRedirectDecisionInput): InitialLandingRedirectDecision { if (segments.length === 0 || segments[0] !== TABS_SEGMENT) { return "wait"; } + if (isAppLink) { + return "skip"; + } + if (!isTabsRoot(segments) && !isDefaultBookingsIndex(segments)) { return "skip"; } From 7f8c3b6b750488a381eb356a5a329b564d856101 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:38:51 +0000 Subject: [PATCH 2/3] fix: allow native app link host configuration Co-Authored-By: peer@cal.com --- apps/mobile/scripts/check-no-cal-hostnames.sh | 7 +-- apps/mobile/utils/app-link-navigation.test.js | 45 +++++++++---------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/apps/mobile/scripts/check-no-cal-hostnames.sh b/apps/mobile/scripts/check-no-cal-hostnames.sh index 6e8fa05..6996e98 100755 --- a/apps/mobile/scripts/check-no-cal-hostnames.sh +++ b/apps/mobile/scripts/check-no-cal-hostnames.sh @@ -14,13 +14,14 @@ # - Lines whose content starts with `//`, `*`, or `/*` are filtered, since # documentation prose / docstring @example URLs don't cause runtime bugs. # - `ALLOWLIST` exempts files that legitimately reference the bare hostnames: -# the region helpers themselves, the env template, the hostname match-set -# used for video-call URL detection, and this script. +# the region helpers themselves, native app-link configuration, the env +# template, the hostname match-set used for video-call URL detection, and +# this script. set -euo pipefail cd "$(dirname "$0")/.." PATTERN='\bcal\.(com|eu)\b' -ALLOWLIST='^(\./)?(utils/region\.ts|utils/booking\.ts|\.env\.example|scripts/check-no-cal-hostnames\.sh):' +ALLOWLIST='^(\./)?(utils/region\.ts|utils/booking\.ts|app\.json|\.env\.example|scripts/check-no-cal-hostnames\.sh):' raw=$(rg -n --no-heading "$PATTERN" . 2>/dev/null || true) hits=$(echo "$raw" | grep -Ev "$ALLOWLIST" | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*(//|\*|/\*)' || true) diff --git a/apps/mobile/utils/app-link-navigation.test.js b/apps/mobile/utils/app-link-navigation.test.js index f1709c9..b1ffb28 100644 --- a/apps/mobile/utils/app-link-navigation.test.js +++ b/apps/mobile/utils/app-link-navigation.test.js @@ -1,50 +1,45 @@ import { describe, expect, test } from "@jest/globals"; import { getNativeRouteFromAppLink } from "@/utils/app-link-navigation"; +import { getCalAppUrl, getCalWebUrl } from "@/utils/region"; describe("getNativeRouteFromAppLink", () => { + const usAppUrl = getCalAppUrl("us"); + const euAppUrl = getCalAppUrl("eu"); + test.each([ - ["https://app.cal.com/bookings", "/(tabs)/(bookings)?source=app-link"], - [ - "https://app.cal.com/bookings/unconfirmed", - "/(tabs)/(bookings)?filter=unconfirmed&source=app-link", - ], - [ - "https://app.cal.com/bookings/recurring", - "/(tabs)/(bookings)?filter=recurring&source=app-link", - ], - [ - "https://app.cal.com/bookings/cancelled", - "/(tabs)/(bookings)?filter=cancelled&source=app-link", - ], + [`${usAppUrl}/bookings`, "/(tabs)/(bookings)?source=app-link"], + [`${usAppUrl}/bookings/unconfirmed`, "/(tabs)/(bookings)?filter=unconfirmed&source=app-link"], + [`${usAppUrl}/bookings/recurring`, "/(tabs)/(bookings)?filter=recurring&source=app-link"], + [`${usAppUrl}/bookings/cancelled`, "/(tabs)/(bookings)?filter=cancelled&source=app-link"], [ - "https://app.cal.com/bookings/upcoming?uid=booking-uid", + `${usAppUrl}/bookings/upcoming?uid=booking-uid`, "/(tabs)/(bookings)/booking-detail?uid=booking-uid&source=app-link", ], [ - "https://app.cal.com/booking/booking-uid", + `${usAppUrl}/booking/booking-uid`, "/(tabs)/(bookings)/booking-detail?uid=booking-uid&source=app-link", ], - ["https://app.cal.com/event-types", "/(tabs)/(event-types)?source=app-link"], + [`${usAppUrl}/event-types`, "/(tabs)/(event-types)?source=app-link"], [ - "https://app.cal.com/event-types/123", + `${usAppUrl}/event-types/123`, "/(tabs)/(event-types)/event-type-detail?id=123&source=app-link", ], - ["https://app.cal.com/availability", "/(tabs)/(availability)?source=app-link"], + [`${usAppUrl}/availability`, "/(tabs)/(availability)?source=app-link"], [ - "https://app.cal.com/availability/456", + `${usAppUrl}/availability/456`, "/(tabs)/(availability)/availability-detail?id=456&source=app-link", ], - ["https://app.cal.com/more", "/(tabs)/(more)?source=app-link"], - ["https://app.cal.eu/bookings/past", "/(tabs)/(bookings)?filter=past&source=app-link"], + [`${usAppUrl}/more`, "/(tabs)/(more)?source=app-link"], + [`${euAppUrl}/bookings/past`, "/(tabs)/(bookings)?filter=past&source=app-link"], ])("maps %s to %s", (appLink, nativeRoute) => { expect(getNativeRouteFromAppLink(appLink)).toBe(nativeRoute); }); test.each([ - "https://cal.com/peer", - "https://app.cal.com/apps", - "https://app.cal.com/bookings/invalid", - "https://app.cal.com/event-types/not-an-id", + `${getCalWebUrl("us")}/peer`, + `${usAppUrl}/apps`, + `${usAppUrl}/bookings/invalid`, + `${usAppUrl}/event-types/not-an-id`, "https://example.com/bookings", "calcom://bookings", "/bookings", From bc077a285780be5a4b1d983e3f9c257d4ab1ac9c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:45:05 +0000 Subject: [PATCH 3/3] fix: isolate app link host constants Co-Authored-By: peer@cal.com --- apps/mobile/scripts/check-no-cal-hostnames.sh | 2 +- apps/mobile/utils/app-link-navigation.test.js | 7 +++---- apps/mobile/utils/app-link-navigation.ts | 2 +- apps/mobile/utils/cal-app-url.ts | 8 ++++++++ apps/mobile/utils/region.ts | 17 +++++++++-------- 5 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/utils/cal-app-url.ts diff --git a/apps/mobile/scripts/check-no-cal-hostnames.sh b/apps/mobile/scripts/check-no-cal-hostnames.sh index 6996e98..938e697 100755 --- a/apps/mobile/scripts/check-no-cal-hostnames.sh +++ b/apps/mobile/scripts/check-no-cal-hostnames.sh @@ -21,7 +21,7 @@ set -euo pipefail cd "$(dirname "$0")/.." PATTERN='\bcal\.(com|eu)\b' -ALLOWLIST='^(\./)?(utils/region\.ts|utils/booking\.ts|app\.json|\.env\.example|scripts/check-no-cal-hostnames\.sh):' +ALLOWLIST='^(\./)?(utils/region\.ts|utils/cal-app-url\.ts|utils/booking\.ts|app\.json|\.env\.example|scripts/check-no-cal-hostnames\.sh):' raw=$(rg -n --no-heading "$PATTERN" . 2>/dev/null || true) hits=$(echo "$raw" | grep -Ev "$ALLOWLIST" | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*(//|\*|/\*)' || true) diff --git a/apps/mobile/utils/app-link-navigation.test.js b/apps/mobile/utils/app-link-navigation.test.js index b1ffb28..56e2dd6 100644 --- a/apps/mobile/utils/app-link-navigation.test.js +++ b/apps/mobile/utils/app-link-navigation.test.js @@ -1,10 +1,9 @@ import { describe, expect, test } from "@jest/globals"; import { getNativeRouteFromAppLink } from "@/utils/app-link-navigation"; -import { getCalAppUrl, getCalWebUrl } from "@/utils/region"; +import { CAL_APP_URLS } from "@/utils/cal-app-url"; describe("getNativeRouteFromAppLink", () => { - const usAppUrl = getCalAppUrl("us"); - const euAppUrl = getCalAppUrl("eu"); + const { us: usAppUrl, eu: euAppUrl } = CAL_APP_URLS; test.each([ [`${usAppUrl}/bookings`, "/(tabs)/(bookings)?source=app-link"], @@ -36,7 +35,7 @@ describe("getNativeRouteFromAppLink", () => { }); test.each([ - `${getCalWebUrl("us")}/peer`, + `${usAppUrl.replace("://app.", "://")}/peer`, `${usAppUrl}/apps`, `${usAppUrl}/bookings/invalid`, `${usAppUrl}/event-types/not-an-id`, diff --git a/apps/mobile/utils/app-link-navigation.ts b/apps/mobile/utils/app-link-navigation.ts index d9cee6b..5414924 100644 --- a/apps/mobile/utils/app-link-navigation.ts +++ b/apps/mobile/utils/app-link-navigation.ts @@ -1,4 +1,4 @@ -import { CAL_APP_HOSTNAMES } from "@/utils/region"; +import { CAL_APP_HOSTNAMES } from "@/utils/cal-app-url"; const BOOKING_FILTERS = new Set(["upcoming", "unconfirmed", "recurring", "past", "cancelled"]); const APP_LINK_SOURCE = "app-link"; diff --git a/apps/mobile/utils/cal-app-url.ts b/apps/mobile/utils/cal-app-url.ts new file mode 100644 index 0000000..1601ea8 --- /dev/null +++ b/apps/mobile/utils/cal-app-url.ts @@ -0,0 +1,8 @@ +export const CAL_APP_URLS = { + us: "https://app.cal.com", + eu: "https://app.cal.eu", +} as const; + +export const CAL_APP_HOSTNAMES: ReadonlySet = new Set( + Object.values(CAL_APP_URLS).map((url) => new URL(url).hostname) +); diff --git a/apps/mobile/utils/region.ts b/apps/mobile/utils/region.ts index cda3a4c..604acc4 100644 --- a/apps/mobile/utils/region.ts +++ b/apps/mobile/utils/region.ts @@ -16,8 +16,16 @@ import { Platform } from "react-native"; +import { CAL_APP_URLS } from "./cal-app-url"; import { generalStorage, isChromeStorageAvailable } from "./storage"; +/** + * Returns the set of Cal.com app hostnames the app talks to across regions. + * Useful for code that needs to recognize any Cal.com app URL regardless of + * the user's current region. + */ +export { CAL_APP_HOSTNAMES } from "./cal-app-url"; + export type CalRegion = "us" | "eu"; const REGION_STORAGE_KEY = "cal_region"; @@ -123,7 +131,7 @@ function notify(): void { /** Fully-qualified origin of the Cal.com web app for the current region. */ export function getCalAppUrl(region: CalRegion = currentRegion): string { - return region === "eu" ? "https://app.cal.eu" : "https://app.cal.com"; + return CAL_APP_URLS[region]; } /** Fully-qualified origin of the Cal.com API for the current region. */ @@ -153,10 +161,3 @@ export function getCalHelpUrl(slug: string): string { const trimmed = slug.replace(/^\/+/, ""); return `https://cal.com/help/${trimmed}`; } - -/** - * Returns the set of Cal.com app hostnames the app talks to across regions. - * Useful for code (e.g. `appendStandaloneParam`) that needs to recognize any - * Cal.com app URL regardless of the user's current region. - */ -export const CAL_APP_HOSTNAMES: ReadonlySet = new Set(["app.cal.com", "app.cal.eu"]);