feat(mobile): redirection vers l'appli + page de téléchargement #93

Merged
kyuno merged 8 commits from feat/mobile-app-redirect into main 2026-09-07 15:05: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);
}