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
+39
View File
@@ -0,0 +1,39 @@
// Live reload on an Android device/emulator connected via adb.
//
// 1. `pnpm dev` in mobile-client must be running (Vite on PORT, default 10003).
// 2. adb reverse makes http://localhost:PORT on the phone reach the dev server on this computer.
// localhost counts as a secure context, so the camera and crypto.subtle (PIN) keep working.
// 3. `cap run android` syncs a config pointing the WebView at the dev server, builds and installs the app.
// Extra arguments are passed to `cap run` (e.g. --target <deviceId>).
//
// Afterwards run `pnpm build:android` before building an APK to share: it bundles the web build again.
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
const port = process.env.PORT || '10003';
const devUrl = `http://localhost:${port}/m/`;
function findAdb() {
const exe = process.platform === 'win32' ? 'adb.exe' : 'adb';
const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || (process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk'));
const fromSdk = sdk && path.join(sdk, 'platform-tools', exe);
return fromSdk && existsSync(fromSdk) ? fromSdk : exe;
}
const adb = findAdb();
const reverse = spawnSync(adb, ['reverse', `tcp:${port}`, `tcp:${port}`], { stdio: 'inherit' });
if (reverse.status !== 0) {
console.error(`\n"${adb} reverse" failed: connect a device with USB debugging (or start an emulator) and make sure adb is installed.`);
process.exit(1);
}
console.log(`adb reverse tcp:${port} -> this computer. The app will load ${devUrl}\n`);
const run = spawnSync('pnpm', ['exec', 'cap', 'run', 'android', ...process.argv.slice(2)], {
stdio: 'inherit',
shell: true,
env: { ...process.env, CAP_DEV_URL: devUrl },
});
console.log('\nReminder: the installed app now loads the dev server. Run `pnpm build:android` before building an APK to share.');
process.exit(run.status ?? 1);
@@ -0,0 +1,92 @@
// Publishes a new APK for in-app self-update (use it whenever the native part changes: plugins,
// AndroidManifest, Java code; web-only changes go through publish-android-update.mjs):
// 1. builds the Capacitor web bundle and syncs it into the Android project,
// 2. builds a release APK signed with the key from android/keystore.properties,
// versionCode = build time (yyDDDHHmm, UTC), versionName = web build version,
// 3. copies it to updates/android/cardy-<versionCode>.apk and writes updates/android/apk.json.
// Web updates published afterwards require this APK (see minNativeBuild in publish-android-update.mjs).
//
// Needs a JDK (JAVA_HOME, or Android Studio's bundled one) and the Android SDK (android/local.properties).
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const root = path.resolve(import.meta.dirname, '..');
const androidDir = path.join(root, 'android');
const outDir = path.join(root, 'updates', 'android');
const KEEP_APKS = 2;
function fail(message) {
console.error(`\n${message}`);
process.exit(1);
}
// ---- preconditions ----
const keystoreProps = path.join(androidDir, 'keystore.properties');
if (!existsSync(keystoreProps)) fail(`Missing ${keystoreProps}: copy keystore.properties.example and fill it in.`);
const props = Object.fromEntries(
readFileSync(keystoreProps, 'utf8')
.split(/\r?\n/)
.filter((l) => l.includes('=') && !l.trimStart().startsWith('#'))
.map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]),
);
for (const key of ['storeFile', 'storePassword', 'keyAlias', 'keyPassword']) {
if (!props[key]) fail(`${keystoreProps}: ${key} is empty.`);
}
if (!existsSync(props.storeFile)) fail(`Keystore not found: ${props.storeFile}`);
const env = { ...process.env };
if (!env.JAVA_HOME) {
const studioJbr = 'C:/Program Files/Android/Android Studio/jbr';
if (existsSync(studioJbr)) env.JAVA_HOME = studioJbr;
else fail('Set JAVA_HOME to a JDK (e.g. Android Studio\'s "jbr" folder).');
}
// ---- version ----
const now = new Date();
const dayOfYear = Math.floor((Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - Date.UTC(now.getUTCFullYear(), 0, 0)) / 86_400_000);
const pad = (n, w) => String(n).padStart(w, '0');
const versionCode = Number(
`${pad(now.getUTCFullYear() % 100, 2)}${pad(dayOfYear, 3)}${pad(now.getUTCHours(), 2)}${pad(now.getUTCMinutes(), 2)}`,
);
// ---- build ----
execSync('pnpm exec vite build --mode capacitor', { cwd: root, stdio: 'inherit' });
execSync('pnpm exec cap sync android', { cwd: root, stdio: 'inherit' });
const { version: versionName } = JSON.parse(readFileSync(path.join(root, 'dist-capacitor', 'cardy-version.json'), 'utf8'));
const gradlew = process.platform === 'win32' ? 'gradlew.bat' : './gradlew';
execSync(`${gradlew} assembleRelease -PcardyVersionCode=${versionCode} -PcardyVersionName=${versionName}`, {
cwd: androidDir,
stdio: 'inherit',
env,
});
const built = path.join(androidDir, 'app', 'build', 'outputs', 'apk', 'release', 'app-release.apk');
if (!existsSync(built)) fail(`Signed APK not found at ${built} (is the signing configured?).`);
// ---- publish ----
mkdirSync(outDir, { recursive: true });
const apkName = `cardy-${versionCode}.apk`;
copyFileSync(built, path.join(outDir, apkName));
const data = readFileSync(path.join(outDir, apkName));
const sha256 = createHash('sha256').update(data).digest('hex');
// apk.json last: clients never see an APK that is not there yet.
writeFileSync(
path.join(outDir, 'apk.json'),
JSON.stringify({ versionCode, versionName, url: apkName, sha256, size: data.length, publishedAt: now.toISOString() }, null, 2),
);
const apks = readdirSync(outDir)
.filter((f) => /^cardy-\d+\.apk$/.test(f))
.sort((a, b) => statSync(path.join(outDir, b)).mtimeMs - statSync(path.join(outDir, a)).mtimeMs);
for (const old of apks.slice(KEEP_APKS)) unlinkSync(path.join(outDir, old));
console.log(`\nPublished APK ${versionName} (versionCode ${versionCode}, ${(data.length / 1024 / 1024).toFixed(1)} MB)`);
console.log(` ${path.join(outDir, apkName)}`);
console.log('Served at /m/android/apk.json; web updates published from now on require this APK.');
@@ -0,0 +1,64 @@
// Publishes a live update of the Android app's web part:
// 1. builds the Capacitor web bundle (its version comes from the build time),
// 2. zips it into updates/android/bundle-<version>.zip,
// 3. writes updates/android/version.json (version, zip, SHA-256, minimum native build).
// The mobile-client dev/preview server serves updates/android at /m/android/, i.e.
// https://dev.aggtaa.com/m/android/version.json. Installed apps pick the update up on the next check.
//
// minNativeBuild is the versionCode of the latest published APK (updates/android/apk.json, see
// publish-android-apk.mjs): after an APK release, web updates go only to that APK or newer, older
// installations are offered the APK instead. Publish an APK whenever the native part changes
// (plugins, AndroidManifest, Java code).
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { zipSync } from 'fflate';
const root = path.resolve(import.meta.dirname, '..');
const distDir = path.join(root, 'dist-capacitor');
const outDir = path.join(root, 'updates', 'android');
const KEEP_BUNDLES = 3;
execSync('pnpm exec vite build --mode capacitor', { cwd: root, stdio: 'inherit' });
const { version } = JSON.parse(readFileSync(path.join(distDir, 'cardy-version.json'), 'utf8'));
function collect(dir, prefix = '') {
const files = {};
for (const name of readdirSync(dir)) {
const full = path.join(dir, name);
const rel = prefix ? `${prefix}/${name}` : name;
if (statSync(full).isDirectory()) Object.assign(files, collect(full, rel));
else files[rel] = readFileSync(full);
}
return files;
}
const zip = Buffer.from(zipSync(collect(distDir), { level: 6 }));
const checksum = createHash('sha256').update(zip).digest('hex');
let minNativeBuild = 1;
try {
minNativeBuild = Number(JSON.parse(readFileSync(path.join(outDir, 'apk.json'), 'utf8')).versionCode) || 1;
} catch {
// no APK published yet: any installed APK can take the bundle
}
mkdirSync(outDir, { recursive: true });
const zipName = `bundle-${version}.zip`;
writeFileSync(path.join(outDir, zipName), zip);
// version.json last: clients never see a version whose zip is not there yet.
writeFileSync(
path.join(outDir, 'version.json'),
JSON.stringify({ version, url: zipName, checksum, minNativeBuild, publishedAt: new Date().toISOString() }, null, 2),
);
// Keep a few recent bundles (clients may be mid-download of the previous one).
const bundles = readdirSync(outDir)
.filter((f) => /^bundle-.+\.zip$/.test(f))
.sort((a, b) => statSync(path.join(outDir, b)).mtimeMs - statSync(path.join(outDir, a)).mtimeMs);
for (const old of bundles.slice(KEEP_BUNDLES)) unlinkSync(path.join(outDir, old));
console.log(`\nPublished Android update ${version} (${(zip.length / 1024).toFixed(0)} KiB, native build >= ${minNativeBuild})`);
console.log(` ${path.join(outDir, zipName)}`);
console.log('Served at /m/android/version.json by the mobile-client dev/preview server.');