diff --git a/apps/web/android/app/src/main/AndroidManifest.xml b/apps/web/android/app/src/main/AndroidManifest.xml index ba2b7bc..67cf1a4 100644 --- a/apps/web/android/app/src/main/AndroidManifest.xml +++ b/apps/web/android/app/src/main/AndroidManifest.xml @@ -18,6 +18,17 @@ + + + + + + + { + afterEach(() => { + setUserAgent(window.navigator.userAgent); + }); + + it("is true for a real Android phone Chrome UA", () => { + setUserAgent( + "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36", + ); + expect(isAndroidPhoneBrowser()).to.be.true; + }); + + it("is false for an Android tablet UA (Android without Mobile)", () => { + setUserAgent( + "Mozilla/5.0 (Linux; Android 14; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", + ); + expect(isAndroidPhoneBrowser()).to.be.false; + }); + + it("is false for a desktop UA", () => { + setUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", + ); + expect(isAndroidPhoneBrowser()).to.be.false; + }); +}); + +describe("attemptOpenNativeApp", () => { + beforeEach(() => { + localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY); + setDocumentHidden(false); + }); + + afterEach(() => { + localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY); + setDocumentHidden(false); + }); + + it("calls onFallback immediately, with no delay, when the visitor opted to stay on web", () => { + localStorage.setItem(STAY_ON_WEB_PREFERENCE_KEY, "true"); + const onFallback = cy.stub().as("onFallback"); + + attemptOpenNativeApp({ onFallback }); + + cy.get("@onFallback").should("have.been.calledOnce"); + }); + + it("calls onFallback once the delay elapses if the page never got hidden (app not installed)", () => { + cy.clock(); + const onFallback = cy.stub().as("onFallback"); + + // `openAppUrl` stubbed to a no-op — real navigation to the custom + // scheme is only meaningful in a real browser with the app installed + // or not; here only the timer/visibility logic around it is under + // test (see the option's own doc comment in mobile-app-redirect.ts). + attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} }); + cy.get("@onFallback").should("not.have.been.called"); + + cy.tick(APP_OPEN_TIMEOUT_MS); + cy.get("@onFallback").should("have.been.calledOnce"); + }); + + it("never calls onFallback if the page is hidden before the delay elapses (handoff succeeded)", () => { + cy.clock(); + const onFallback = cy.stub().as("onFallback"); + + attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} }); + + cy.then(() => { + setDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + }); + + cy.tick(APP_OPEN_TIMEOUT_MS); + cy.get("@onFallback").should("not.have.been.called"); + }); +}); diff --git a/apps/web/cypress/e2e/download-page.cy.ts b/apps/web/cypress/e2e/download-page.cy.ts new file mode 100644 index 0000000..1e09f17 --- /dev/null +++ b/apps/web/cypress/e2e/download-page.cy.ts @@ -0,0 +1,26 @@ +// Pure layout spec, no Cucumber — a single static page with no user +// journey of its own (see layout.cy.ts's identical rationale for this +// 3-way test split). No `cy.intercept("**/auth/me", ...)` needed: this +// route renders outside RequireAuth entirely (see App.tsx's own doc +// comment on why), so it never calls that endpoint. + +describe("DownloadPage — the mobile-app-redirect fallback", () => { + it("renders the APK download link, a retry button, and a way back home", () => { + cy.visit("/telecharger"); + + cy.contains("h1", "Installer l'application Android").should("be.visible"); + + // API_BASE_URL is empty locally (relative-URL, same-origin default — + // see client.ts), so the real target collapses to this relative path. + cy.contains("a", "Télécharger l'APK").should("have.attr", "href", "/mobile/download"); + + cy.contains("button", "Réessayer d'ouvrir l'application").should("be.visible"); + + cy.contains("a", "Retour à l'accueil").should("have.attr", "href", "/"); + }); + + it("is reachable without being logged in — no bounce to /login", () => { + cy.visit("/telecharger"); + cy.url().should("include", "/telecharger"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1d02b42..8bc57ca 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,7 @@ import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage"; import { LoginPage } from "./pages/auth/LoginPage"; import { SignupPage } from "./pages/auth/SignupPage"; import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; +import { DownloadPage } from "./pages/download/DownloadPage"; import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage"; import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; @@ -53,6 +54,13 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage"; * created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`); * skipped otherwise, straight to `/onboarding/allergenes`. * + * `/telecharger` is the mobile-app-redirect chantier's fallback landing spot + * (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like + * `/login`/`/signup`, but deliberately *not* wrapped in + * `RedirectIfAuthenticated`: a signed-in visitor still has a legitimate + * reason to land here (logged in on the web, app just not installed on + * this particular device yet). + * * `/admin/*` is the internal admin surface (metrics, monitoring, correction * triage, off-catalog ingredient review). It's its own top-level group, * wrapped in {@link AdminAuthProvider} so the `GET /admin/api/auth/me` session @@ -140,6 +148,7 @@ export function App() { } /> + } /> diff --git a/apps/web/src/lib/mobile-app-redirect.ts b/apps/web/src/lib/mobile-app-redirect.ts new file mode 100644 index 0000000..5934dc3 --- /dev/null +++ b/apps/web/src/lib/mobile-app-redirect.ts @@ -0,0 +1,107 @@ +/** + * The Android app's own custom URL scheme — registered as an *additional*, + * additive `` in `android/app/src/main/AndroidManifest.xml` + * (the pre-existing `MAIN`/`LAUNCHER` filter is untouched). Attempting to + * navigate here from a browser hands off to the installed app; on a device + * without it installed, an unrecognized custom scheme is a safe no-op in + * every browser this app targets (Android Chrome/WebView) — the current + * page just keeps running, nothing throws, nothing navigates away. + */ +export const APP_OPEN_URL = "batchcooking://open"; + +/** + * `localStorage` key for the "toujours ouvrir dans l'appli / rester sur le + * web" preference (UI for it lands in a later ticket of the mobile/tablet + * UI chantier, `/parametres/application`) — exported so that settings page + * can read/write the exact same key rather than this module and that page + * silently drifting to two different strings. Absent (the default) means + * "always try to open the app" — {@link attemptOpenNativeApp} never + * refuses to try just because nothing has set this yet. + */ +export const STAY_ON_WEB_PREFERENCE_KEY = "batchcooking:stayOnWeb"; + +/** How long to wait for the OS to hand off to the app before assuming it isn't installed. Exported for tests, not meant to be tuned per call site. */ +export const APP_OPEN_TIMEOUT_MS = 1500; + +/** + * The "smart app banner" pattern: try to open the installed Android app via + * its custom scheme, and call `onFallback` if that doesn't visibly succeed + * within a short delay — the one signal available from a plain web page + * that a scheme navigation "worked" is that the OS backgrounds/replaces the + * tab (`document.hidden` flips `true`) to hand off to the other app; if the + * page is still the foreground document once the timer fires, nothing + * caught the scheme, so there's no app installed to open. + * + * Respects {@link STAY_ON_WEB_PREFERENCE_KEY}: when a visitor has + * explicitly asked to stay on the web, this skips the scheme attempt + * entirely and calls `onFallback` immediately — never a forced redirect a + * user has opted out of. + * + * `onFallback`'s job (typically: navigate to `/telecharger`) is entirely + * the caller's — this module has no opinion on routing. + */ +export function attemptOpenNativeApp({ + onFallback, + delayMs = APP_OPEN_TIMEOUT_MS, + openAppUrl = navigateToAppScheme, +}: { + onFallback: () => void; + /** Override for tests — production callers should always use the default. */ + delayMs?: number; + /** + * Override for tests only — production callers should never pass this. + * Real navigation to an unrecognized custom scheme is a safe no-op in + * every browser this app targets, but it's still a real, uncontrolled + * side effect a test shouldn't actually trigger; every other behavior + * here (the timer, the visibility check, the preference short-circuit) + * is exercised the same way regardless of what this does. + */ + openAppUrl?: () => void; +}): void { + if (localStorage.getItem(STAY_ON_WEB_PREFERENCE_KEY) === "true") { + onFallback(); + return; + } + + const timeoutId = window.setTimeout(() => { + if (!document.hidden) onFallback(); + }, delayMs); + + // If the handoff actually happens, this tab is about to be backgrounded + // — clear the pending fallback the moment that's detected so it can + // never fire later (e.g. once the visitor switches back to this tab + // after using the app), and stop listening either way once it has fired + // once. + document.addEventListener("visibilitychange", handleVisibilityChange); + + function handleVisibilityChange() { + if (!document.hidden) return; + window.clearTimeout(timeoutId); + document.removeEventListener("visibilitychange", handleVisibilityChange); + } + + openAppUrl(); +} + +function navigateToAppScheme(): void { + window.location.href = APP_OPEN_URL; +} + +/** + * Whether the current visitor is on an Android phone's browser — the one + * case {@link attemptOpenNativeApp} is worth trying at all, since the app + * only exists for Android (see the root README) and only in a phone form + * factor makes sense to redirect (a tablet browsing the site is a + * perfectly normal experience on its own, no app-open prompt needed). + * + * User-agent based, deliberately **not** {@link useIsMobileViewport} — that + * hook answers "is this viewport narrow" (a layout question, true for a + * resized desktop browser window too), this answers "is this genuinely an + * Android phone" (a capability question). Android's own UA convention + * drops "Mobile" from a *tablet's* UA string while keeping "Android" — the + * combination of both tokens is what actually narrows this to a phone. + */ +export function isAndroidPhoneBrowser(): boolean { + if (typeof navigator === "undefined") return false; + return /Android/.test(navigator.userAgent) && /Mobile/.test(navigator.userAgent); +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index e9a7f57..69d6f30 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1365,6 +1365,13 @@ "button": "Créer un compte" } }, + "download": { + "title": "Installer l'application Android", + "lede": "Téléchargez le fichier .apk, ou réessayez d'ouvrir l'application si elle est déjà installée sur cet appareil.", + "downloadButton": "Télécharger l'APK", + "retryButton": "Réessayer d'ouvrir l'application", + "backLink": "Retour à l'accueil" + }, "tour": { "controls": { "next": "Suivant", diff --git a/apps/web/src/pages/download/DownloadPage.tsx b/apps/web/src/pages/download/DownloadPage.tsx new file mode 100644 index 0000000..7823843 --- /dev/null +++ b/apps/web/src/pages/download/DownloadPage.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { API_BASE_URL } from "../../api/client"; +import { attemptOpenNativeApp } from "../../lib/mobile-app-redirect"; +import "./download-page.scss"; + +/** + * The fallback landing spot for {@link attemptOpenNativeApp} — reached when + * a visitor's phone didn't have the app installed to hand off to (or, + * directly, by anyone who just wants the APK — a plain link works + * regardless of how they arrived). Public, no `RequireAuth`/ + * `RedirectIfAuthenticated` gate: unlike `/login`/`/signup`, a signed-in + * visitor still has a legitimate reason to land here (they're logged in on + * the web but haven't installed the app on *this* device yet). + * + * The actual download reuses `${API_BASE_URL}/mobile/download` — + * `apps/api/src/modules/mobile/`'s existing same-origin proxy in front of + * Forgejo's release asset (see that module's own doc comment for why a + * direct Forgejo link doesn't work: no CORS headers, no GitHub-style + * `/releases/latest/download/` shortcut). Nothing new needed there. + */ +export function DownloadPage() { + const { t } = useTranslation(); + + return ( +
+
+

{t("download.title")}

+

{t("download.lede")}

+ + + {t("download.downloadButton")} + + + + +

{t("landing.hero.mobileHint")}

+ + + {t("download.backLink")} + +
+
+ ); +} diff --git a/apps/web/src/pages/download/download-page.scss b/apps/web/src/pages/download/download-page.scss new file mode 100644 index 0000000..993f87b --- /dev/null +++ b/apps/web/src/pages/download/download-page.scss @@ -0,0 +1,84 @@ +// DownloadPage — colocated with DownloadPage.tsx. A small centered card, +// same "full-viewport centering wrapper + raised card" language as +// features/auth/auth-form.scss's `.auth-page`/`.auth-card` (this page sits +// in the same "not logged in yet, or not on this device yet" context), but +// its own classes rather than reused ones — no real behavior in common, +// just a similar look. + +.download-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-md); + background: var(--color-background); +} + +.download-page__card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-sm); + width: 100%; + max-width: var(--max-width-form); + padding: var(--space-xl); + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + color: var(--color-text); + + h1 { + font-size: var(--font-size-lg); + margin: 0; + } +} + +.download-page__lede { + margin: 0; + color: var(--color-text-muted); +} + +.download-page__cta { + align-self: stretch; + text-align: center; + padding: var(--space-sm) var(--space-md); + margin-top: var(--space-sm); + border-radius: var(--radius-base); + background: var(--color-primary); + color: #fff; + font-weight: 600; + text-decoration: none; + + &:hover { + background: var(--color-primary-hover); + } +} + +.download-page__retry { + align-self: stretch; + text-align: center; + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + background: none; + color: var(--color-text); + font-family: var(--font-body); + font-weight: 600; + cursor: pointer; + + &:hover { + background: var(--color-surface-alt); + } +} + +.download-page__hint { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.download-page__back { + margin-top: var(--space-sm); + font-size: var(--font-size-sm); + color: var(--color-primary); +}