capgo and adk autoupdates. card tags

This commit is contained in:
azykov@mail.ru
2026-09-29 19:36:08 +03:00
parent 1981d976eb
commit b6fb2fe40f
43 changed files with 1329 additions and 54 deletions
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import { apkInstaller, type PublishedApk } from './updater.js';
import { ConfirmDialog } from './ui.js';
type State =
| { step: 'idle' }
| { step: 'permission' }
| { step: 'downloading'; downloaded: number; total: number }
| { step: 'installing' }
| { step: 'confirm' }
| { step: 'error'; message: string };
const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
/** "App update available" on the card list: downloads the APK and hands it to Android's installer. */
export function ApkUpdateBanner({ apk }: { apk: PublishedApk }) {
const [state, setState] = useState<State>({ step: 'idle' });
const install = async () => {
try {
if (!(await apkInstaller.canInstall())) {
setState({ step: 'permission' });
return;
}
setState({ step: 'downloading', downloaded: 0, total: apk.size });
await apkInstaller.install(apk, (e) => {
if (e.kind === 'progress') setState({ step: 'downloading', downloaded: e.downloaded, total: e.total > 0 ? e.total : apk.size });
else if (e.status === 'installing') setState({ step: 'installing' });
else if (e.status === 'confirm') setState({ step: 'confirm' });
else setState({ step: 'error', message: e.message ?? 'Installation failed' });
});
} catch (e) {
setState({ step: 'error', message: (e as Error).message });
}
};
let text: string;
let action: string | undefined = 'Install';
switch (state.step) {
case 'downloading':
text = `Downloading the update… ${mb(state.downloaded)} / ${mb(state.total)} MB`;
action = undefined;
break;
case 'installing':
text = 'Installing… the app will restart.';
action = undefined;
break;
case 'confirm':
text = 'Confirm the installation in the Android dialog.';
action = 'Retry';
break;
case 'error':
text = `Update failed: ${state.message}`;
action = 'Retry';
break;
default:
text = `A new version of the app is available (${apk.versionName}, ${mb(apk.size)} MB).`;
}
return (
<>
<div className="banner info update-banner" role="status">
<span>{text}</span>
{action && (
<button className="text-button strong" onClick={install}>
{action}
</button>
)}
</div>
{state.step === 'permission' && (
<ConfirmDialog
title="Allow app updates"
message="Android needs your permission for Cardy to install its own updates. Turn on “Allow from this source” on the next screen, come back and tap Install again."
confirmLabel="Open settings"
onCancel={() => setState({ step: 'idle' })}
onConfirm={() => {
setState({ step: 'idle' });
void apkInstaller.openInstallSettings();
}}
/>
)}
</>
);
}
+47 -3
View File
@@ -5,6 +5,14 @@ import { useNavigation, type LoginPrefill } from './navigation.js';
import { onBackButton, onDeepLink, onResumeAfter } from './platform.js';
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
import { refreshCardList, syncImages } from './sync.js';
import {
applyPendingUpdate,
checkForApkUpdate,
checkForUpdate,
consumeUpdateReload,
markAppReady,
type PublishedApk,
} from './updater.js';
import { CardScreen } from './screens/CardScreen.js';
import { CardsScreen, type SyncState } from './screens/CardsScreen.js';
import { EditScreen } from './screens/EditScreen.js';
@@ -29,13 +37,17 @@ export function App() {
);
}, []);
if (!boot) return <div className="splash" />;
return <Main boot={boot} />;
return <Main boot={boot} justUpdated={JUST_UPDATED} />;
}
function Main({ boot }: { boot: Boot }) {
/** Read once per page load (render functions may run twice). */
const JUST_UPDATED = consumeUpdateReload();
function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
const [session, setSession] = useState(boot.session);
const [pin, setPin] = useState(boot.pin);
const [locked, setLocked] = useState(!!boot.pin);
// Not locked after a reload that applied an update: the user was unlocked a moment ago.
const [locked, setLocked] = useState(() => !!boot.pin && !justUpdated);
const [cards, setCards] = useState(boot.cards);
const [usage, setUsage] = useState(boot.usage);
const [imageEpoch, setImageEpoch] = useState(0);
@@ -184,6 +196,37 @@ function Main({ boot }: { boot: Boot }) {
useEffect(() => onResumeAfter(RELOCK_AFTER_MS, () => pin && setLocked(true)), [pin]);
// ---- live updates (Android app only) ----
// The app rendered: confirm this bundle works (a bundle that never gets here is rolled back).
useEffect(() => void markAppReady(), []);
// Reloading is safe only on the unlocked card list with no download running: never while a card is
// shown at the till, while editing, or on the lock screen (the reload skips the PIN).
const safeToReload = !locked && !lockedLogin && route?.name === 'cards' && !sync.busy;
const safeRef = useRef(safeToReload);
safeRef.current = safeToReload;
// A newer APK (native part changed) is offered on the card list; installing it needs the user's OK.
const [apkUpdate, setApkUpdate] = useState<PublishedApk>();
const lastApkCheck = useRef(0);
useEffect(() => {
const check = () => {
void checkForUpdate(() => safeRef.current);
if (Date.now() - lastApkCheck.current >= 60_000) {
lastApkCheck.current = Date.now();
void checkForApkUpdate().then(setApkUpdate);
}
};
check();
return onResumeAfter(0, check);
}, []);
useEffect(() => {
if (safeToReload) void applyPendingUpdate();
}, [safeToReload]);
// Android back button = history back; on the lock screen and at the root it exits the app.
const lockedRef = useRef({ locked, lockedLogin });
lockedRef.current = { locked, lockedLogin };
@@ -261,6 +304,7 @@ function Main({ boot }: { boot: Boot }) {
imageEpoch={imageEpoch}
sync={sync}
session={session}
apkUpdate={apkUpdate}
onDismissSync={() => setSync({ busy: false })}
onRefresh={refresh}
onOpen={(id) => push({ name: 'card', id })}
+2 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { PIN_LENGTH } from './pin.js';
import { Icon } from './ui.js';
import { Icon, VersionLabel } from './ui.js';
/**
* Four-dot PIN entry with an on-screen keypad (a hardware keyboard works too).
@@ -81,6 +81,7 @@ export function PinPad({
</button>
</div>
<div className="pin-footer">{footer}</div>
<VersionLabel />
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Version of a web build, derived from the build time (UTC): "YYYY.MDD.Hmmss", e.g. 2026.929.151230
* for 2026-09-29 15:12:30. Valid semver (no leading zeros) and ordered like the build time.
*/
export function buildVersionFromDate(date: Date): string {
const md = (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
const hms = date.getUTCHours() * 10000 + date.getUTCMinutes() * 100 + date.getUTCSeconds();
return `${date.getUTCFullYear()}.${md}.${hms}`;
}
/** Numeric comparison of dot-separated versions; non-numeric parts count as 0. */
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map((p) => Number.parseInt(p, 10) || 0);
const pb = b.split('.').map((p) => Number.parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return Math.sign(diff);
}
return 0;
}
+4
View File
@@ -0,0 +1,4 @@
/** Build version, "YYYY.MDD.Hmmss" from the build time (UTC); "dev" on the dev server. */
declare const __BUILD_VERSION__: string;
/** Base URL of Android live updates (version.json and bundle zips), without a trailing slash. */
declare const __UPDATE_URL__: string;
+17
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { Card } from '@cardy/shared';
import { normalizeServerUrl } from './api.js';
import { buildVersionFromDate, compareVersions } from './buildVersion.js';
import { detectedFormatToCodeType } from './codeFormats.js';
import { checkPin, createPinRecord } from './pin.js';
import { cardsNeedingImages, runPool, sortCards } from './sync.js';
@@ -12,6 +13,7 @@ const card = (id: string, name: string, extra: Partial<Card> = {}): Card => ({
code: '1',
codeType: 'QR',
comment: '',
tags: [],
logo: null,
images: [],
imagesVersion: 0,
@@ -78,6 +80,21 @@ describe('PIN', () => {
});
});
describe('build versions', () => {
it('derive a semver-compatible version from the build time (UTC)', () => {
expect(buildVersionFromDate(new Date('2026-09-29T15:12:30Z'))).toBe('2026.929.151230');
expect(buildVersionFromDate(new Date('2026-01-05T00:00:07Z'))).toBe('2026.105.7');
});
it('orders versions like build times', () => {
const times = ['2026-01-05T00:00:07Z', '2026-01-05T09:59:59Z', '2026-01-05T10:00:00Z', '2026-09-29T15:12:30Z', '2026-10-01T00:00:00Z', '2027-01-01T00:00:00Z'];
const versions = times.map((t) => buildVersionFromDate(new Date(t)));
for (let i = 1; i < versions.length; i++) expect(compareVersions(versions[i]!, versions[i - 1]!)).toBe(1);
expect(compareVersions('2026.929.151230', '2026.929.151230')).toBe(0);
expect(compareVersions('2026.929.1', 'dev')).toBe(1);
});
});
describe('helpers', () => {
it('normalizes the server address', () => {
expect(normalizeServerUrl(' dev.aggtaa.com/api/ ')).toBe('https://dev.aggtaa.com/api');
+2 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { CODE_TYPE_LABELS, textColorOn, type Card } from '@cardy/shared';
import { setMaxBrightness } from '../platform.js';
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, useStoredImage } from '../ui.js';
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, TagList, useStoredImage } from '../ui.js';
export function CardScreen({
card,
@@ -64,6 +64,7 @@ export function CardScreen({
<span className="muted">{CODE_TYPE_LABELS[card.codeType]}</span>
</div>
{card.tags?.length > 0 && <TagList tags={card.tags} />}
{card.comment && <p className="card-comment">{card.comment}</p>}
{card.images.length > 0 && (
+15 -9
View File
@@ -1,8 +1,10 @@
import { useMemo, useRef, useState } from 'react';
import { textColorOn, type Card } from '@cardy/shared';
import { cardMatchesQuery, textColorOn, type Card } from '@cardy/shared';
import { ApkUpdateBanner } from '../ApkUpdateBanner.js';
import type { Session, UsageMap } from '../storage.js';
import type { PublishedApk } from '../updater.js';
import { sortCards } from '../sync.js';
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen } from '../ui.js';
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
export interface SyncState {
busy: boolean;
@@ -17,6 +19,7 @@ export function CardsScreen({
imageEpoch,
sync,
session,
apkUpdate,
onDismissSync,
onRefresh,
onOpen,
@@ -28,6 +31,7 @@ export function CardsScreen({
imageEpoch: number;
sync: SyncState;
session: Session;
apkUpdate?: PublishedApk;
onDismissSync: () => void;
onRefresh: () => void;
onOpen: (id: string) => void;
@@ -38,14 +42,12 @@ export function CardsScreen({
const [confirmKey, setConfirmKey] = useState(false);
const filterInput = useRef<HTMLInputElement>(null);
const visible = useMemo(() => {
const q = filter.trim().toLowerCase();
const sorted = sortCards(cards, usage);
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted;
}, [cards, usage, filter]);
// Search by name, tags and comment.
const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]);
return (
<Screen
className="cards-screen"
title="Cards"
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
@@ -59,8 +61,8 @@ export function CardsScreen({
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
placeholder="Filter by name"
aria-label="Filter by name"
placeholder="Search by name, tag or comment"
aria-label="Search by name, tag or comment"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
@@ -78,6 +80,8 @@ export function CardsScreen({
)}
</div>
{apkUpdate && <ApkUpdateBanner apk={apkUpdate} />}
{sync.busy && sync.progress && sync.progress.total > 0 && (
<Banner kind="info">
Downloading images {sync.progress.done}/{sync.progress.total}
@@ -108,6 +112,8 @@ export function CardsScreen({
</div>
)}
<VersionLabel />
<button className="fab" onClick={onAdd} aria-label="Add card">
<Icon name="plus" size={28} />
</button>
+21 -3
View File
@@ -4,6 +4,8 @@ import {
CODE_TYPES,
CODE_TYPE_LABELS,
cardFieldsSchema,
formatTags,
parseTags,
randomCardColor,
type Card,
type CardFields,
@@ -14,7 +16,7 @@ import { ApiError, type Api } from '../api.js';
import { compressImage, fileNameFor } from '../imageTools.js';
import { ScannerView, scanImageFile } from '../ScannerView.js';
import { images } from '../storage.js';
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, useBlobUrl, useStoredImage } from '../ui.js';
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, TagList, useBlobUrl, useStoredImage } from '../ui.js';
type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob };
interface NewImage {
@@ -45,6 +47,8 @@ export function EditScreen({
? { name: card.name, color: card.color, code: card.code, codeType: card.codeType, comment: card.comment ?? '' }
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
);
// Edited as typed ("a, b"); parsed into the tag list on save.
const [tagsText, setTagsText] = useState(formatTags(card?.tags));
// Set once the card exists on the server (also after a partially failed first save).
const [savedId, setSavedId] = useState(card?.id);
const [hadLogo, setHadLogo] = useState<ImageRef | null>(card?.logo ?? null);
@@ -93,7 +97,7 @@ export function EditScreen({
});
const save = async () => {
const parsed = cardFieldsSchema.safeParse(fields);
const parsed = cardFieldsSchema.safeParse({ ...fields, tags: tagsText });
if (!parsed.success) {
setError(parsed.error.issues.map((i) => i.message).join('. '));
return;
@@ -108,7 +112,9 @@ export function EditScreen({
let addedDone = false;
try {
const form = new FormData();
for (const [k, v] of Object.entries(parsed.data)) form.append(k, v);
for (const [k, v] of Object.entries(parsed.data)) {
if (v !== undefined) form.append(k, Array.isArray(v) ? v.join(',') : v);
}
if (logo.kind === 'new') form.append('logo', logo.blob, fileNameFor(logo.blob, 'logo'));
if (logo.kind === 'remove' && hadLogo) form.append('removeLogo', 'true');
current = savedId ? await api.updateCard(savedId, form) : await api.createCard(form);
@@ -272,6 +278,18 @@ export function EditScreen({
/>
</label>
<label className="field">
<span>Tags</span>
<input
value={tagsText}
onChange={(e) => setTagsText(e.target.value)}
placeholder="Comma-separated, e.g. food, pharmacy"
autoCapitalize="off"
aria-label="Tags"
/>
{parseTags(tagsText).length > 0 && <TagList tags={parseTags(tagsText)} />}
</label>
<div className="field">
<span>Photos</span>
<div className="photo-grid">
+2 -1
View File
@@ -5,7 +5,7 @@ import type { LoginPrefill } from '../navigation.js';
import { defaultServerUrl } from '../platform.js';
import { ScannerView } from '../ScannerView.js';
import type { Session } from '../storage.js';
import { Banner, Icon, IconButton, Screen } from '../ui.js';
import { Banner, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
export type { LoginPrefill };
@@ -136,6 +136,7 @@ export function LoginScreen({
{busy ? 'Checking…' : 'Sign in'}
</button>
</form>
<VersionLabel />
</Screen>
);
}
+72
View File
@@ -112,6 +112,29 @@ textarea:focus {
border-color: var(--accent);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.tag {
padding: 3px 10px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.8rem;
overflow-wrap: anywhere;
}
.card-screen .tags {
margin: 0 0 14px;
}
.card-comment {
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -275,6 +298,15 @@ textarea:focus {
color: var(--accent);
}
.update-banner {
align-items: center;
}
.update-banner .text-button {
padding: 4px 8px;
white-space: nowrap;
}
.banner-close {
background: none;
border: none;
@@ -291,6 +323,8 @@ textarea:focus {
/* ---- login ---- */
.login .screen-body {
/* No floating button here: the version label sits at the very bottom. */
padding-bottom: calc(12px + var(--safe-bottom));
display: flex;
flex-direction: column;
gap: 14px;
@@ -413,6 +447,44 @@ textarea:focus {
min-height: 44px;
}
/* Pinned to the bottom of the login and PIN screens. */
.version-label {
margin-top: auto;
padding-top: 16px;
text-align: center;
color: var(--muted);
font-size: 0.75rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
/* Card list: the label is the footer below the tiles, level with the floating button. */
.cards-screen .screen-body {
display: flex;
flex-direction: column;
padding-bottom: calc(40px + var(--safe-bottom));
}
.cards-screen .version-label {
padding-top: 48px;
padding-right: 72px;
padding-left: 72px;
}
/* The PIN screen is centered vertically: keep the label out of the flow. */
.pin {
position: relative;
}
.pin .version-label {
position: absolute;
left: 0;
right: 0;
bottom: calc(10px + var(--safe-bottom));
margin: 0;
padding: 0;
}
/* ---- cards list ---- */
.filter {
+17
View File
@@ -41,6 +41,23 @@ export function IconButton({ icon, label, onClick, disabled, spinning }: { icon:
);
}
export function TagList({ tags }: { tags: readonly string[] }) {
return (
<ul className="tags" aria-label="Tags">
{tags.map((tag) => (
<li key={tag} className="tag">
{tag}
</li>
))}
</ul>
);
}
/** Version of the running web build (built-in or live-updated bundle). */
export function VersionLabel() {
return <div className="version-label">Version {__BUILD_VERSION__}</div>;
}
// ---- layout ----
export function Screen({ title, left, right, children, className }: { title?: ReactNode; left?: ReactNode; right?: ReactNode; children: ReactNode; className?: string }) {
+183
View File
@@ -0,0 +1,183 @@
import { App } from '@capacitor/app';
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import { CapacitorUpdater } from '@capgo/capacitor-updater';
import { compareVersions } from './buildVersion.js';
import { isNative } from './platform.js';
/**
* Live updates of the Android app's web part (Capgo updater in manual mode, own server):
* the app always runs a local bundle (built-in or downloaded), so everything keeps working offline.
* When dev.aggtaa.com is reachable and publishes a newer version, the bundle is downloaded in the
* background and applied right away on a safe screen, otherwise via next() (on backgrounding).
*/
export const BUILD_VERSION = __BUILD_VERSION__;
const UPDATE_URL = __UPDATE_URL__;
const CHECK_TIMEOUT_MS = 10_000;
/** Do not check more often than this (launch + every return to the foreground). */
const MIN_CHECK_INTERVAL_MS = 60_000;
/** Set right before an immediate reload so the fresh bundle does not ask for the PIN again. */
const RELOAD_MARK = 'cardy:update-reload';
interface PublishedVersion {
version: string;
/** Bundle zip, relative to version.json or absolute. */
url: string;
/** SHA-256 of the zip, hex. */
checksum: string;
/** Lowest native build (android versionCode) the bundle works with. */
minNativeBuild?: number;
}
// Imported statically on purpose: a Capacitor plugin object is a Proxy that answers any property,
// including `then`, so it must never be the resolved value of a Promise (the Promise would call
// CapacitorUpdater.then() -> "not implemented on android").
const updater = CapacitorUpdater;
let checking = false;
let lastCheck = 0;
/** Downloaded bundle waiting for a safe moment (also queued with next()). */
let pendingBundleId: string | undefined;
/** Must run early on every start: otherwise the updater rolls back to the previous bundle. */
export async function markAppReady(): Promise<void> {
if (!isNative) return;
try {
await updater.notifyAppReady();
} catch (e) {
console.warn('notifyAppReady failed', e);
}
}
/** True once, right after this app reloaded itself to apply an update. */
export function consumeUpdateReload(): boolean {
try {
const at = Number(localStorage.getItem(RELOAD_MARK) ?? 0);
localStorage.removeItem(RELOAD_MARK);
return at > 0 && Date.now() - at < 60_000;
} catch {
return false;
}
}
/**
* Checks the server for a newer bundle and downloads it. Applies it immediately when
* `canApplyNow()` says so, otherwise queues it for the next backgrounding.
*/
export async function checkForUpdate(canApplyNow: () => boolean): Promise<void> {
if (!isNative || BUILD_VERSION === 'dev' || checking) return;
if (Date.now() - lastCheck < MIN_CHECK_INTERVAL_MS) return;
checking = true;
lastCheck = Date.now();
try {
const res = await fetch(`${UPDATE_URL}/version.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
if (!res.ok) return;
const published = (await res.json()) as PublishedVersion;
if (!published?.version || !published.url || compareVersions(published.version, BUILD_VERSION) <= 0) return;
const nativeBuild = Number((await App.getInfo()).build) || 0;
if (published.minNativeBuild && nativeBuild < published.minNativeBuild) {
console.info(`Update ${published.version} needs a newer app (build ${published.minNativeBuild}, installed ${nativeBuild})`);
return;
}
const { bundles } = await updater.list();
let bundle = bundles.find((b) => b.version === published.version && b.status !== 'error' && b.status !== 'downloading');
bundle ??= await updater.download({
url: new URL(published.url, `${UPDATE_URL}/`).href,
version: published.version,
checksum: published.checksum,
});
if (canApplyNow()) await applyNow(bundle.id);
else {
await updater.next({ id: bundle.id });
pendingBundleId = bundle.id;
}
} catch (e) {
// Offline, server down, bad bundle: keep running the current version.
console.warn('Update check failed', e);
} finally {
checking = false;
}
}
// ---- APK self-update (native part changed: plugins, manifest, Java) ----
export interface PublishedApk {
versionCode: number;
versionName: string;
/** APK file, relative to apk.json or absolute. */
url: string;
sha256: string;
size: number;
}
export type ApkInstallEvent =
| { kind: 'progress'; downloaded: number; total: number }
| { kind: 'status'; status: 'installing' | 'confirm' | 'failed'; message?: string };
interface ApkUpdaterPlugin {
canInstall(): Promise<{ allowed: boolean }>;
openInstallSettings(): Promise<void>;
downloadAndInstall(options: { url: string; sha256: string }): Promise<void>;
addListener(event: 'progress', cb: (e: { downloaded: number; total: number }) => void): Promise<PluginListenerHandle>;
addListener(event: 'status', cb: (e: { status: 'installing' | 'confirm' | 'failed'; message?: string }) => void): Promise<PluginListenerHandle>;
}
/** Native plugin in android/app (ApkUpdaterPlugin.java). Used directly, never through a Promise. */
const ApkUpdater = registerPlugin<ApkUpdaterPlugin>('ApkUpdater');
/** A newer APK than the installed one, if the server publishes it. */
export async function checkForApkUpdate(): Promise<PublishedApk | undefined> {
if (!isNative || BUILD_VERSION === 'dev') return undefined;
try {
const res = await fetch(`${UPDATE_URL}/apk.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
if (!res.ok) return undefined;
const apk = (await res.json()) as PublishedApk;
const installed = Number((await App.getInfo()).build) || 0;
return apk?.versionCode > installed && apk.url && apk.sha256 ? apk : undefined;
} catch {
return undefined;
}
}
export const apkInstaller = {
canInstall: async () => (await ApkUpdater.canInstall()).allowed,
openInstallSettings: () => ApkUpdater.openInstallSettings(),
/** Downloads and hands the APK to Android; `onEvent` reports progress and the installer's status. */
async install(apk: PublishedApk, onEvent: (e: ApkInstallEvent) => void): Promise<void> {
const handles: PluginListenerHandle[] = [];
const cleanup = () => void Promise.all(handles.splice(0).map((h) => h.remove()));
handles.push(
await ApkUpdater.addListener('progress', (e) => onEvent({ kind: 'progress', ...e })),
await ApkUpdater.addListener('status', (e) => {
onEvent({ kind: 'status', ...e });
if (e.status === 'failed') cleanup();
}),
);
try {
await ApkUpdater.downloadAndInstall({ url: new URL(apk.url, `${UPDATE_URL}/`).href, sha256: apk.sha256 });
} catch (e) {
cleanup();
throw e;
}
// Listeners stay until the installer reports "failed"; a successful install restarts the app.
},
};
/** Applies a queued update now (call when the app reaches a safe screen). */
export async function applyPendingUpdate(): Promise<void> {
if (pendingBundleId) await applyNow(pendingBundleId);
}
async function applyNow(id: string): Promise<void> {
try {
localStorage.setItem(RELOAD_MARK, String(Date.now()));
} catch {
// the PIN will simply be asked again
}
// Reloads the WebView from the new bundle; nothing after this runs.
await updater.set({ id });
}