Compare commits

...

9 commits

Author SHA1 Message Date
e45d92b333 Merge pull request 'feat(mobile): redirection vers l'appli + page de téléchargement' (#93) from feat/mobile-app-redirect into main
Some checks failed
CI / lint (push) Successful in 3m9s
CI / build (push) Successful in 3m11s
CI / intent-service-test (push) Successful in 5m46s
CI / e2e (push) Successful in 7m17s
CI / test (push) Failing after 26m42s
CI / build-android (push) Has been skipped
2026-09-07 15:05:14 +02:00
0447c68b69 merge: rapatrie le correctif de serialisation CI (#96)
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build (push) Successful in 4m3s
CI / intent-service-test (push) Successful in 6m48s
CI / e2e (push) Successful in 13m34s
CI / test (push) Successful in 30m44s
CI / build-android (push) Has been skipped
2026-09-07 14:31:05 +02:00
022b8ed4f9 chore: relance la CI (6e collision - FK violee au seed sous contention)
Some checks failed
CI / lint (push) Successful in 3m43s
CI / intent-service-test (push) Successful in 5m52s
CI / build (push) Successful in 3m18s
CI / test (push) Failing after 13m37s
CI / e2e (push) Successful in 13m53s
CI / build-android (push) Has been skipped
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.
2026-09-07 13:39:09 +02:00
3cdc863774 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m20s
CI / intent-service-test (push) Successful in 4m46s
CI / build (push) Successful in 4m43s
CI / test (push) Failing after 10m16s
CI / e2e (push) Has been cancelled
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.
2026-09-07 13:24:46 +02:00
952abe8dda chore: relance la CI (4e collision infra sur le runner partage)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m33s
CI / build (push) Successful in 4m10s
CI / intent-service-test (push) Successful in 9m43s
CI / test (push) Failing after 15m14s
CI / e2e (push) Successful in 14m51s
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.
2026-09-07 13:04:04 +02:00
200e343cc7 chore: relance la CI (Postgres partage coupe en cours de run)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / intent-service-test (push) Failing after 36s
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / e2e (push) Has been cancelled
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.
2026-09-07 13:02:24 +02:00
bb5fc825ae chore: relance la CI (collision de cache actions sur le runner)
Some checks failed
CI / lint (push) Successful in 3m22s
CI / build (push) Successful in 3m12s
CI / intent-service-test (push) Successful in 4m48s
CI / test (push) Failing after 9m15s
CI / e2e (push) Successful in 12m52s
CI / build-android (push) Has been skipped
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.
2026-09-07 12:41:30 +02:00
14690134f7 chore: relance la CI (collision infra sur le Postgres partage)
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / build (push) Failing after 48s
CI / e2e (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / test (push) Has been cancelled
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.
2026-09-07 12:38:33 +02:00
d6cf4aa63d feat(mobile): redirection vers l'appli + page de telechargement
Some checks failed
CI / lint (push) Successful in 3m35s
CI / build (push) Successful in 4m33s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 15m5s
CI / test (push) Failing after 15m28s
CI / build-android (push) Has been skipped
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 <noreply@anthropic.com>
2026-09-07 12:20:16 +02:00
8 changed files with 396 additions and 0 deletions

View file

@ -18,6 +18,17 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Custom scheme for the web app's "open the installed app"
redirect (see apps/web/src/lib/mobile-app-redirect.ts) —
additive, does not touch the launcher filter above. No
android:host/android:path restriction, so any
batchcooking://... URL matches. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="batchcooking" />
</intent-filter>
</activity>
<provider

View file

@ -0,0 +1,98 @@
import {
APP_OPEN_TIMEOUT_MS,
attemptOpenNativeApp,
isAndroidPhoneBrowser,
STAY_ON_WEB_PREFERENCE_KEY,
} from "../../src/lib/mobile-app-redirect";
// Pure-ish logic (timers/localStorage/document.hidden, no React component to
// mount) — same posture as version-compare.cy.tsx: reuses the component-test
// runner purely for `expect`/`cy.clock()`, no `cy.mount`. `.cy.tsx` (not
// `.cy.ts`) only because that's what cypress.config.ts's component
// `specPattern` looks for.
function setUserAgent(ua: string) {
Object.defineProperty(window.navigator, "userAgent", { value: ua, configurable: true });
}
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", { value: hidden, configurable: true });
}
describe("isAndroidPhoneBrowser", () => {
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");
});
});

View file

@ -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");
});
});

View file

@ -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() {
</RedirectIfAuthenticated>
}
/>
<Route path="/telecharger" element={<DownloadPage />} />
<Route
element={
<AdminAuthProvider>

View file

@ -0,0 +1,107 @@
/**
* The Android app's own custom URL scheme — registered as an *additional*,
* additive `<intent-filter>` 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);
}

View file

@ -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",

View file

@ -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/<file>` shortcut). Nothing new needed there.
*/
export function DownloadPage() {
const { t } = useTranslation();
return (
<main className="download-page">
<div className="download-page__card">
<h1>{t("download.title")}</h1>
<p className="download-page__lede">{t("download.lede")}</p>
<a className="download-page__cta" href={`${API_BASE_URL}/mobile/download`}>
{t("download.downloadButton")}
</a>
<button
type="button"
className="download-page__retry"
// Already on the fallback page — a failed retry needs no further
// fallback of its own, the download button right above already
// covers it.
onClick={() => attemptOpenNativeApp({ onFallback: () => {} })}
>
{t("download.retryButton")}
</button>
<p className="download-page__hint">{t("landing.hero.mobileHint")}</p>
<Link className="download-page__back" to="/">
{t("download.backLink")}
</Link>
</div>
</main>
);
}

View file

@ -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);
}