From d6cf4aa63d2821eeeb7e1f5e645b38c0c9285216 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 12:20:16 +0200 Subject: [PATCH 1/7] feat(mobile): redirection vers l'appli + page de telechargement Ticket #84 du chantier UI mobile & tablette. Aucune infra de deep-link n'existait (verifie en amont : pas de scheme personnalise, pas d'App Links, pas d'assetlinks.json, pas de listener appUrlOpen). - lib/mobile-app-redirect.ts : attemptOpenNativeApp() (pattern "smart app banner" classique - tente le scheme batchcooking://, bascule sur onFallback si la page est toujours visible passe un court delai) et isAndroidPhoneBrowser() (UA Android+Mobile, deliberement distinct de useIsMobileViewport() - une question de plateforme, pas de largeur). Respecte une preference "rester sur le web" (localStorage, cle exportee pour que l'onglet Parametres d'application - ticket #86 - la reutilise) sans jamais forcer la redirection. - AndroidManifest.xml : nouvel intent-filter additif (scheme batchcooking, sans toucher au filtre MAIN/LAUNCHER existant). - Nouvelle page /telecharger (publique, hors RequireAuth - un visiteur deja connecte sur le web mais sans l'appli sur cet appareil a une raison legitime d'y atterrir) : lien direct vers le proxy existant ${API_BASE_URL}/mobile/download (rien a changer cote API), bouton "reessayer d'ouvrir l'appli". Deliberement pas de changement sur la landing page ici (lien discret actuel laisse tel quel) - c'est le ticket #85, separe pour rester petit/revuable. Verifie en local : tsc -b --force, biome check, build de prod, suite Cypress e2e complete (141/141 sur cette branche - #91 n'est pas encore merge, layout.cy.ts y est donc encore dans son etat pre-#91) et composants (69/69, incluant le nouveau mobile-app-redirect.cy.tsx). Note technique : le premier essai de test du composant tentait un vrai window.location.href vers le scheme personnalise depuis Cypress/ Electron, ce qui bloquait le runner indefiniment (protocole inconnu mal gere par Electron) - corrige en rendant ce seul appel injectable (openAppUrl, reserve aux tests) sans toucher au comportement reel. Co-Authored-By: Claude Sonnet 5 --- .../android/app/src/main/AndroidManifest.xml | 11 ++ .../component/mobile-app-redirect.cy.tsx | 98 ++++++++++++++++ apps/web/cypress/e2e/download-page.cy.ts | 26 +++++ apps/web/src/App.tsx | 9 ++ apps/web/src/lib/mobile-app-redirect.ts | 107 ++++++++++++++++++ apps/web/src/locales/fr/translation.json | 7 ++ apps/web/src/pages/download/DownloadPage.tsx | 54 +++++++++ .../web/src/pages/download/download-page.scss | 84 ++++++++++++++ 8 files changed, 396 insertions(+) create mode 100644 apps/web/cypress/component/mobile-app-redirect.cy.tsx create mode 100644 apps/web/cypress/e2e/download-page.cy.ts create mode 100644 apps/web/src/lib/mobile-app-redirect.ts create mode 100644 apps/web/src/pages/download/DownloadPage.tsx create mode 100644 apps/web/src/pages/download/download-page.scss 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 9468a1d..bdc5c45 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1364,6 +1364,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); +} -- 2.45.2 From 14690134f79faeca29461e86c33fe190fbfc3708 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 12:38:33 +0200 Subject: [PATCH 2/7] chore: relance la CI (collision infra sur le Postgres partage) PrismaClientInitializationError "Can't reach database server at postgres:5432" pendant test, alors que ce ticket ne touche aucun fichier apps/api - signature connue de contention sur le runner partage (voir ci-forgejo-runner-constraints), pas une regression. -- 2.45.2 From bb5fc825ae7269031c7074afd4e59d729dcb01b0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 12:41:30 +0200 Subject: [PATCH 3/7] chore: relance la CI (collision de cache actions sur le runner) Non-terminating error while running 'git clone' puis Cannot find module '.../pnpm-action-setup@v4/dist/index.js' - signature connue de collision entre deux jobs act demarrant en meme temps sur le cache d'actions partage du runner (voir ci-forgejo-runner-constraints), pas une regression. -- 2.45.2 From 200e343cc775473291cdc30767f8038e7c9801d8 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 13:02:24 +0200 Subject: [PATCH 4/7] chore: relance la CI (Postgres partage coupe en cours de run) Can't reach database server at postgres:5432 / Server has closed the connection - meme signature connue de contention runner que les relances precedentes (voir ci-forgejo-runner-constraints), pas une regression. -- 2.45.2 From 952abe8dda6f80aed264dfb813b02636d01ec8a6 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 13:04:04 +0200 Subject: [PATCH 5/7] chore: relance la CI (4e collision infra sur le runner partage) intent-service-test a echoue cette fois sur actions/checkout lui-meme ("Non-terminating error while running 'git clone': some refs were not updated" puis "Cannot find module '.../actions-checkout@v4/dist/index.js'") - meme classe de collision de cache d'actions que les echecs precedents, juste une action differente touchee cette fois (voir ci-forgejo-runner-constraints). Ce ticket ne touche aucun fichier lie a ces actions. -- 2.45.2 From 3cdc863774d94e172452d5739763fc3e327f09d1 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 13:24:46 +0200 Subject: [PATCH 6/7] chore: relance la CI (Postgres partage coupe en cours de run) Can't reach database server at postgres:5432 / Server has closed the connection - meme signature connue de contention runner que les relances precedentes (voir ci-forgejo-runner-constraints), pas une regression. -- 2.45.2 From 022b8ed4f978d64113cbeb07544909fca52f917d Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 13:39:09 +0200 Subject: [PATCH 7/7] chore: relance la CI (6e collision - FK violee au seed sous contention) Cette fois : PrismaClientKnownRequestError "Foreign key constraint violated: tour_scenario_step_target_key_fkey" dans le beforeAll global (seedTourScenarios). Nouvelle variante de la meme famille connue (plusieurs process de test partageant une seule base Postgres CI, seed concurrent) plutot qu'un nouveau bug reel - ce ticket ne touche ni reference-seed-data.ts ni quoi que ce soit lie aux tours. Log complet aussi montre un timeout Docker au nettoyage du conteneur ("context deadline exceeded"), signe supplementaire de charge severe sur le runner en ce moment. -- 2.45.2