feat(mobile): redirection vers l'appli + page de téléchargement #93
8 changed files with 396 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
98
apps/web/cypress/component/mobile-app-redirect.cy.tsx
Normal file
98
apps/web/cypress/component/mobile-app-redirect.cy.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
26
apps/web/cypress/e2e/download-page.cy.ts
Normal file
26
apps/web/cypress/e2e/download-page.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
107
apps/web/src/lib/mobile-app-redirect.ts
Normal file
107
apps/web/src/lib/mobile-app-redirect.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
54
apps/web/src/pages/download/DownloadPage.tsx
Normal file
54
apps/web/src/pages/download/DownloadPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
apps/web/src/pages/download/download-page.scss
Normal file
84
apps/web/src/pages/download/download-page.scss
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue