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"; /** * 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. * * **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. * * 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. * * Fails silently on any network/parsing error — a background version check * must never surface an error banner or block anything, same posture as * `ThemeContext`'s own preferences fetch. */ 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; } { const [availableVersion, setAvailableVersion] = useState(null); useEffect(() => { if (!Capacitor.isNativePlatform()) return; let cancelled = false; async function checkForUpdate() { try { const [{ App }, { Preferences }] = await Promise.all([ import("@capacitor/app"), 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 [{ version: installedVersion }, { version: latestTag }] = await Promise.all([ App.getInfo(), apiClient.getLatestMobileVersion(), ]); await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }); if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return; const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY }); if (dismissedVersion === latestTag) return; if (!cancelled) setAvailableVersion(latestTag); } 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 checkForUpdate(); // Re-check whenever the app comes back to the foreground — the listener // itself is cheap to keep registered, `checkForUpdate`'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 checkForUpdate(); }).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 }), ); } return { availableVersion, dismiss }; }