import { Capacitor } from "@capacitor/core"; import { useEffect, useState } from "react"; import { apiClient } from "../../api/client"; import { isNewerVersion } from "./version-compare"; /** How often a background check is allowed to actually hit the network — cold start and every resume-from-background otherwise re-checks needlessly. */ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt"; const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion"; /** Outcome of a single version comparison — shared by the silent background check and {@link useAppUpdateCheck}'s manual `checkNow`, so a caller showing immediate feedback (issue #86's "Vérifier les mises à jour" button) can switch on it directly instead of re-deriving it from `availableVersion`. */ export type AppUpdateCheckResult = | { status: "up-to-date" } | { status: "update-available"; version: string } | { status: "error" }; /** * Fetches the installed version (native `App.getInfo()`) and the latest * tagged release, and compares them — the one place this comparison * happens, shared by the background effect below and `checkNow`. No * throttling and no `Preferences` side effects of its own; callers decide * what to do with the result (persist a last-checked timestamp, filter * against a dismissed version, etc.). */ async function fetchLatestVersionResult(): Promise { try { const { App } = await import("@capacitor/app"); const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([ App.getInfo(), apiClient.getLatestMobileVersion(), ]); if (!latestTag || !isNewerVersion(installedVersion, latestTag)) { return { status: "up-to-date" }; } return { status: "update-available", version: latestTag }; } catch { return { status: "error" }; } } /** * Detects whether a newer version of the Android app has been tagged than * the one currently installed, and offers a way to dismiss the notice for * that specific version — plus, since issue #86, a manual `checkNow` for a * "Vérifier les mises à jour" button (`AppSettingsPage`) that surfaces this * same check's result immediately instead of waiting on the silent * background timer. * * **Native only** — on the web, "the app" is just whatever the server is * currently serving; there is no "installed version" to compare against, * and reloading the page always gets the latest build. `Capacitor`/ * `@capacitor/app`/`@capacitor/preferences` stay dynamically imported so * none of them enter the web bundle (same pattern as `main.tsx`'s own * `capacitor-token-store` bootstrap) — this hook is safe to call * unconditionally from `AppLayout`, which renders on both platforms. * `checkNow` itself still resolves on the web (`{ status: "error" }`, * nothing to check) rather than rejecting — callers gate the *button* that * would invoke it behind `Capacitor.isNativePlatform()` themselves * (`AppSettingsPage` does), so this never actually runs there in practice. * * No Play Store integration exists (this app isn't distributed there) — * Google's In-App Update API has no purchase here at all, so this is a * from-scratch check, not a wrapper around it. * * The background effect fails silently on any network/parsing error — it * must never surface an error banner or block anything, same posture as * `ThemeContext`'s own preferences fetch. `checkNow` is the opposite: it's * a direct response to an explicit click, so it returns its outcome * (including `"error"`) for the caller to actually display. */ export function useAppUpdateCheck(): { /** The newer version's tag (e.g. `"v1.4.2"`), or `null` when none is available (or none has been checked/parsed yet). */ availableVersion: string | null; /** Hides the banner for `availableVersion` specifically — it reappears only once an even newer version ships, never just on the next app start. */ dismiss: () => void; /** * Runs the check immediately, bypassing the background check's own * throttle — an explicit "Vérifier les mises à jour" click should always * hit the network, not silently no-op because a background check already * ran recently. Also bypasses the dismissed-version filter: re-asking is * itself a reason to show the result again, even for a version dismissed * earlier. Updates `availableVersion` on `"update-available"` so the * persistent {@link UpdateBanner} picks it up too, not just this call's * own return value. */ checkNow: () => Promise; } { const [availableVersion, setAvailableVersion] = useState(null); useEffect(() => { if (!Capacitor.isNativePlatform()) return; let cancelled = false; async function backgroundCheck() { try { const { Preferences } = await import("@capacitor/preferences"); const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY }); const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0; if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return; const result = await fetchLatestVersionResult(); await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }); if (result.status !== "update-available") return; const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY }); if (dismissedVersion === result.version) return; if (!cancelled) setAvailableVersion(result.version); } catch { // Silent — see this hook's own doc comment. Nothing to log to // (apps/web has no client-side LoggerService equivalent yet), and a // failed version check is never actionable for the visitor anyway. } } void backgroundCheck(); // Re-check whenever the app comes back to the foreground — the listener // itself is cheap to keep registered, `backgroundCheck`'s own throttle // above is what actually keeps this from hitting the network every time. let removeListener: (() => void) | undefined; void import("@capacitor/app").then(({ App }) => { if (cancelled) return; App.addListener("appStateChange", ({ isActive }) => { if (isActive) void backgroundCheck(); }).then((handle) => { removeListener = () => void handle.remove(); }); }); return () => { cancelled = true; removeListener?.(); }; }, []); function dismiss() { if (!availableVersion) return; const dismissed = availableVersion; setAvailableVersion(null); void import("@capacitor/preferences").then(({ Preferences }) => Preferences.set({ key: DISMISSED_VERSION_KEY, value: dismissed }), ); } async function checkNow(): Promise { try { const result = await fetchLatestVersionResult(); if (result.status === "update-available") setAvailableVersion(result.version); // Best-effort — a manual check shouldn't make the *next* background // check fire again immediately after. Not awaited: nothing the // caller needs to wait on, and native-only anyway (a no-op import on // the web build would just resolve to nothing useful). void import("@capacitor/preferences").then(({ Preferences }) => Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }), ); return result; } catch { // `fetchLatestVersionResult` already turns every failure it can hit // into `{ status: "error" }` rather than throwing — this only exists // to keep `checkNow`'s own contract (a result, never a rejection) // true in case that ever changes, per the repo's async/try-catch // convention (see specs/dev-conventions.md). return { status: "error" }; } } return { availableVersion, dismiss, checkNow }; }