From b6fb2fe40f7916c810b21328a812db352cdc7cdd Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Tue, 29 Sep 2026 19:36:08 +0300 Subject: [PATCH] capgo and adk autoupdates. card tags --- .gitignore | 2 + README.md | 38 ++++ development specification-v2.md | 17 +- mobile-client/android/app/build.gradle | 37 +++- .../android/app/capacitor.build.gradle | 1 + .../release/baselineProfiles/0/app-release.dm | Bin 0 -> 3161 bytes .../release/baselineProfiles/1/app-release.dm | Bin 0 -> 3102 bytes .../android/app/release/output-metadata.json | 37 ++++ .../android/app/src/main/AndroidManifest.xml | 8 + .../com/aggtaa/cardy/ApkInstallReceiver.java | 15 ++ .../com/aggtaa/cardy/ApkUpdaterPlugin.java | 186 ++++++++++++++++++ .../java/com/aggtaa/cardy/MainActivity.java | 1 + .../android/capacitor.settings.gradle | 3 + .../android/keystore.properties.example | 6 + mobile-client/capacitor.config.ts | 24 +++ mobile-client/package.json | 5 + mobile-client/scripts/android-dev.mjs | 39 ++++ mobile-client/scripts/publish-android-apk.mjs | 92 +++++++++ .../scripts/publish-android-update.mjs | 64 ++++++ mobile-client/src/ApkUpdateBanner.tsx | 84 ++++++++ mobile-client/src/App.tsx | 50 ++++- mobile-client/src/PinPad.tsx | 3 +- mobile-client/src/buildVersion.ts | 20 ++ mobile-client/src/env.d.ts | 4 + mobile-client/src/logic.test.ts | 17 ++ mobile-client/src/screens/CardScreen.tsx | 3 +- mobile-client/src/screens/CardsScreen.tsx | 24 ++- mobile-client/src/screens/EditScreen.tsx | 24 ++- mobile-client/src/screens/LoginScreen.tsx | 3 +- mobile-client/src/styles.css | 72 +++++++ mobile-client/src/ui.tsx | 17 ++ mobile-client/src/updater.ts | 183 +++++++++++++++++ mobile-client/vite.config.ts | 90 +++++++-- pnpm-lock.yaml | 20 ++ server-be/src/cards.ts | 2 + server-be/test/clientApi.test.ts | 27 +++ server-fe/src/pages/CardEditor.tsx | 34 +++- server-fe/src/pages/Cards.tsx | 26 ++- server-fe/src/styles.css | 12 ++ shared/src/cardDisplay.ts | 7 + shared/src/models.ts | 2 + shared/src/schemas.ts | 33 ++++ shared/src/tags.test.ts | 51 +++++ 43 files changed, 1329 insertions(+), 54 deletions(-) create mode 100644 mobile-client/android/app/release/baselineProfiles/0/app-release.dm create mode 100644 mobile-client/android/app/release/baselineProfiles/1/app-release.dm create mode 100644 mobile-client/android/app/release/output-metadata.json create mode 100644 mobile-client/android/app/src/main/java/com/aggtaa/cardy/ApkInstallReceiver.java create mode 100644 mobile-client/android/app/src/main/java/com/aggtaa/cardy/ApkUpdaterPlugin.java create mode 100644 mobile-client/android/keystore.properties.example create mode 100644 mobile-client/scripts/android-dev.mjs create mode 100644 mobile-client/scripts/publish-android-apk.mjs create mode 100644 mobile-client/scripts/publish-android-update.mjs create mode 100644 mobile-client/src/ApkUpdateBanner.tsx create mode 100644 mobile-client/src/buildVersion.ts create mode 100644 mobile-client/src/env.d.ts create mode 100644 mobile-client/src/updater.ts create mode 100644 shared/src/tags.test.ts diff --git a/.gitignore b/.gitignore index 3d55b09..66c3691 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ dist-capacitor/ +mobile-client/updates/ +mobile-client/android/keystore.properties data/ .env *.log diff --git a/README.md b/README.md index ae19545..3011b5c 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,44 @@ pnpm --filter @cardy/mobile-client build:android # web build for Capacitor + c Then open `mobile-client/android` in Android Studio (or `gradlew assembleDebug` there) to build the APK. The app registers `cardy://login?...` links (key QR codes) and asks for camera access when scanning. +### Live updates (no APK reinstall) + +The APK contains the web build and works fully offline. Newer web builds are delivered over the air +(`@capgo/capacitor-updater` in manual mode, no Capgo cloud, telemetry disabled): + +```bash +pnpm --filter @cardy/mobile-client publish:android # build + zip into mobile-client/updates/android +``` + +The mobile-client server on :10003 serves it at `https://dev.aggtaa.com/m/android/version.json`. +On launch and on every return to the foreground (when online) the app checks it, downloads a newer +bundle in the background and reloads into it on the card list; on a card or in the editor it waits +(`next()`: applied on backgrounding). A bundle that fails to start is rolled back automatically. +Versions come from the build time (`2026.929.151230` = 2026-09-29 15:12:30 UTC). + +### APK self-update (native part changed) + +Changes to the native part (plugins, `AndroidManifest.xml`, Java code) need a new APK: + +```bash +pnpm --filter @cardy/mobile-client publish:android-apk # signed release APK into mobile-client/updates/android +``` + +It needs `mobile-client/android/keystore.properties` (copy `keystore.properties.example`, not committed) +with the release key. `versionCode` is the build time (`yyDDDHHmm`, UTC), `versionName` the web version. +Installed apps show "A new version of the app is available" on the card list; Install downloads the +APK and hands it to Android's installer (first time: allow "Install unknown apps" for Cardy, then +confirm the system dialog). Web updates published after an APK release require that APK or newer. + +All builds (also Android Studio debug builds) are signed with the release key, so they can replace +each other. An app signed with another key (e.g. the old debug key) must be uninstalled once. + +### Live reload during development + +`pnpm --filter @cardy/mobile-client android:dev` (with `pnpm --filter @cardy/mobile-client dev` running +and a device connected via adb) installs an app that loads the dev server through `adb reverse`. +Run `build:android` again before building an APK for real use. + `server-be` listens on `127.0.0.1` by default because `/admin/*` trusts `Remote-Email`. If the proxy runs on another machine, set `HOST` and `ADMIN_PROXY_SECRET`, and make the proxy add `X-Cardy-Proxy-Secret` on the `/api/admin` route. diff --git a/development specification-v2.md b/development specification-v2.md index a54c441..68673f0 100644 --- a/development specification-v2.md +++ b/development specification-v2.md @@ -1,6 +1,6 @@ # Cardy — техническое задание, версия 2 -Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональный комментарий (многострочный текст) + опциональное лого + опциональные картинки (фото/скриншоты). +Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональный комментарий (многострочный текст) + опциональные теги + опциональное лого + опциональные картинки (фото/скриншоты). Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались). Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят. @@ -102,6 +102,7 @@ cards: code: "4600000000000" codeType: EAN13 comment: "Card of Anna\nScan twice" # многострочный, до 2000 символов, '' если пусто + tags: [продукты, семья] # [] если нет logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null images: # порядок = порядок добавления - { id: 5f6a…, file: 5f6a….webp, mime: image/webp } @@ -181,8 +182,8 @@ cards: |---|---| | `GET /api/auth` | проверка ключа; возвращает email и группу | | `GET /api/cards` | все неудалённые карты группы, без пагинации | -| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment` и файл `logo`); возвращает карту с ID | -| `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий; опционально файл `logo`, опционально `removeLogo=true`) | +| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment`, `tags` (строка через запятую) и файл `logo`); возвращает карту с ID | +| `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий, пустые `tags` — теги; опционально файл `logo`, опционально `removeLogo=true`) | | `DELETE /api/cards/:id` | пометить карту удалённой | | `GET /api/cards/:id/logo` | лого | | `GET /api/cards/:id/images/:imageId` | картинка | @@ -191,6 +192,8 @@ cards: Все изменяющие запросы возвращают карту целиком (включая новый `imagesVersion`). При конкурентных правках побеждает последняя запись. +**Теги карты.** Вводятся строкой через запятую; у каждого тега обрезаются ведущие и концевые пробелы, пустые теги отбрасываются, точные повторы тоже (`один,,, два` → `[один, два]`). Не больше 30 тегов, каждый до 50 символов. API принимает строку или массив строк (к каждому элементу применяется то же правило). + ## 4. Клиентское мобильное приложение React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 (`vite preview` собранной версии; `vite` — для разработки) с базовым путём `/m` (`https://dev.aggtaa.com/m`); прокси срезает `/m`, сервер восстанавливает его сам. Сборка для Capacitor (`pnpm build:android`) — с относительным базовым путём, в `dist-capacitor`. Service worker кэширует приложение целиком (включая `bwip-js` и wasm-модуль сканера) для работы офлайн. @@ -200,11 +203,13 @@ React + Vite + TS; PWA и Android-приложение через Capacitor. PWA - **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически. - **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает. - **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне. -- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, фильтр по подстроке названия (от 1 символа), кнопки «обновить» и «добавить». +- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить». - **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы. -- **Экран карты**: название, код (отрисовка `bwip-js`), комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением). -- **Экран добавления и редактирования**: название, комментарий (многострочное поле), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. +- **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением). +- **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. - После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся. +- **Обновления Android-приложения без переустановки APK** (`@capgo/capacitor-updater` в ручном режиме, без облака Capgo, телеметрия отключена). APK содержит веб-сборку и полностью работает офлайн. `pnpm publish:android` собирает новую версию (номер — время сборки в UTC, `ГГГГ.МДД.Ччммсс`), кладёт zip и `version.json` (версия, SHA-256, минимальный `versionCode` APK) в `mobile-client/updates/android`; сервер `mobile-client` раздаёт их по `/m/android/`. При запуске и каждом возврате в приложение (не чаще раза в минуту, только при наличии связи) приложение проверяет `version.json` и скачивает более новую совместимую версию в фоне. На экране списка карт (разблокировано, без идущей загрузки картинок) она применяется сразу — перезагрузкой без повторного PIN; на экране карты, в редакторе, на экране входа или PIN — откладывается (`next()`: применяется при уходе в фон или при возврате к списку). Версия, не подтвердившая запуск за 10 секунд, откатывается к предыдущей. Установка нового APK сбрасывает скачанные версии. +- **Обновление самого APK** (при изменениях нативной части: плагины, манифест, Java). `pnpm publish:android-apk` собирает релизный APK, подписанный ключом из `android/keystore.properties` (не в git; тем же ключом подписываются и отладочные сборки), с `versionCode` из времени сборки (`ггДДДЧЧмм`, UTC) и `versionName` = версия веб-сборки, и кладёт его вместе с `apk.json` (versionCode, versionName, SHA-256, размер) в `mobile-client/updates/android`. При запуске и возврате в приложение (не чаще раза в минуту) приложение сверяет `apk.json` со своим `versionCode` и при наличии более нового APK показывает на экране списка карт баннер «A new version of the app is available» с кнопкой Install: APK скачивается с прогрессом, проверяется SHA-256 и передаётся системному установщику Android (`PackageInstaller`). В первый раз нужно разрешить Cardy «Установку неизвестных приложений» (приложение открывает этот экран настроек), затем подтвердить системный диалог; на Android 12+ последующие обновления могут устанавливаться без подтверждения, если это разрешит система. Веб-обновления, опубликованные после выпуска APK, требуют этот APK или новее (`minNativeBuild`), старые установки получают предложение обновить APK. - Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет). ## 5. Тесты diff --git a/mobile-client/android/app/build.gradle b/mobile-client/android/app/build.gradle index 10d5895..7b61bf3 100644 --- a/mobile-client/android/app/build.gradle +++ b/mobile-client/android/app/build.gradle @@ -1,5 +1,20 @@ apply plugin: 'com.android.application' +// ---- Cardy: versioning and signing ---- + +// versionCode from the build time (UTC): yyDDDHHmm (year, day of year, time), e.g. 262721430. +// Grows with every build and stays below Android's limit (2100000000) until 2099. +// Override with -PcardyVersionCode=... (the APK publishing script passes the value it records). +def cardyVersionCode = project.findProperty('cardyVersionCode') ?: new Date().format('yyDDDHHmm', TimeZone.getTimeZone('UTC')) +def cardyVersionName = project.findProperty('cardyVersionName') ?: '1.0' + +// Release key: android/keystore.properties (not in git) with storeFile, storePassword, keyAlias, keyPassword. +// Debug builds use the same key so that Android Studio builds and published APKs can replace each other. +def keystoreProps = new Properties() +def keystoreFile = rootProject.file('keystore.properties') +if (keystoreFile.exists()) keystoreFile.withInputStream { keystoreProps.load(it) } +def hasReleaseKey = ['storeFile', 'storePassword', 'keyAlias', 'keyPassword'].every { keystoreProps.getProperty(it)?.trim() } + android { namespace = "com.aggtaa.cardy" compileSdk = rootProject.ext.compileSdkVersion @@ -7,8 +22,8 @@ android { applicationId "com.aggtaa.cardy" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode cardyVersionCode as Integer + versionName cardyVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -16,14 +31,32 @@ android { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + signingConfigs { + if (hasReleaseKey) { + cardy { + storeFile file(keystoreProps.getProperty('storeFile').trim()) + storePassword keystoreProps.getProperty('storePassword') + keyAlias keystoreProps.getProperty('keyAlias').trim() + keyPassword keystoreProps.getProperty('keyPassword') + } + } + } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (hasReleaseKey) signingConfig signingConfigs.cardy + } + debug { + if (hasReleaseKey) signingConfig signingConfigs.cardy } } } +if (!hasReleaseKey) { + logger.warn('Cardy: android/keystore.properties is missing or incomplete: debug builds use the debug key, release builds are unsigned.') +} + repositories { flatDir{ dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' diff --git a/mobile-client/android/app/capacitor.build.gradle b/mobile-client/android/app/capacitor.build.gradle index bf02646..0e1c917 100644 --- a/mobile-client/android/app/capacitor.build.gradle +++ b/mobile-client/android/app/capacitor.build.gradle @@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') implementation project(':capacitor-preferences') + implementation project(':capgo-capacitor-updater') } diff --git a/mobile-client/android/app/release/baselineProfiles/0/app-release.dm b/mobile-client/android/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000000000000000000000000000000000000..2dfb4f5b6a8003cb2c354b7edfe1b6e6d16e208e GIT binary patch literal 3161 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 + + + + + + diff --git a/mobile-client/android/app/src/main/java/com/aggtaa/cardy/ApkInstallReceiver.java b/mobile-client/android/app/src/main/java/com/aggtaa/cardy/ApkInstallReceiver.java new file mode 100644 index 0000000..9b26b4c --- /dev/null +++ b/mobile-client/android/app/src/main/java/com/aggtaa/cardy/ApkInstallReceiver.java @@ -0,0 +1,15 @@ +package com.aggtaa.cardy; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** Receives PackageInstaller results for self-updates (see ApkUpdaterPlugin). */ +public class ApkInstallReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (ApkUpdaterPlugin.ACTION_INSTALL_STATUS.equals(intent.getAction())) { + ApkUpdaterPlugin.onInstallStatus(intent); + } + } +} diff --git a/mobile-client/android/app/src/main/java/com/aggtaa/cardy/ApkUpdaterPlugin.java b/mobile-client/android/app/src/main/java/com/aggtaa/cardy/ApkUpdaterPlugin.java new file mode 100644 index 0000000..4ebaa22 --- /dev/null +++ b/mobile-client/android/app/src/main/java/com/aggtaa/cardy/ApkUpdaterPlugin.java @@ -0,0 +1,186 @@ +package com.aggtaa.cardy; + +import android.app.PendingIntent; +import android.content.Intent; +import android.content.pm.PackageInstaller; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.MessageDigest; + +/** + * Self-update of the whole app (outside of Play Store): downloads an APK, checks its SHA-256 and hands + * it to the system installer. Android asks the user to confirm unless it allows an update without user + * action (Android 12+, when this app installed itself last time). + * + * Events: "progress" { downloaded, total }, "status" { status: "installing" | "confirm" | "failed", message }. + * A successful install replaces and restarts the app, so there is no "done" event. + */ +@CapacitorPlugin(name = "ApkUpdater") +public class ApkUpdaterPlugin extends Plugin { + + static final String ACTION_INSTALL_STATUS = "com.aggtaa.cardy.APK_INSTALL_STATUS"; + private static ApkUpdaterPlugin instance; + private volatile boolean busy = false; + + @Override + public void load() { + instance = this; + } + + /** Whether the user allowed this app to install apps ("Install unknown apps"). */ + @PluginMethod + public void canInstall(PluginCall call) { + JSObject result = new JSObject(); + result.put("allowed", Build.VERSION.SDK_INT < 26 || getContext().getPackageManager().canRequestPackageInstalls()); + call.resolve(result); + } + + /** Opens the system screen where the user allows installing apps from Cardy. */ + @PluginMethod + public void openInstallSettings(PluginCall call) { + if (Build.VERSION.SDK_INT >= 26) { + Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getContext().getPackageName())); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getContext().startActivity(intent); + } + call.resolve(); + } + + /** Downloads { url } , verifies { sha256 } and starts the installation. Resolves once installation has started. */ + @PluginMethod + public void downloadAndInstall(PluginCall call) { + String url = call.getString("url"); + String sha256 = call.getString("sha256"); + if (url == null || sha256 == null) { + call.reject("url and sha256 are required"); + return; + } + if (busy) { + call.reject("An update is already in progress"); + return; + } + busy = true; + new Thread(() -> { + File apk = new File(getContext().getCacheDir(), "cardy-update.apk"); + try { + download(url, apk); + String actual = sha256Hex(apk); + if (!actual.equalsIgnoreCase(sha256)) throw new IOException("Checksum mismatch"); + install(apk); + call.resolve(); + } catch (Exception e) { + busy = false; + apk.delete(); + call.reject(e.getMessage() != null ? e.getMessage() : e.toString()); + } + }).start(); + } + + private void download(String url, File target) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(20000); + conn.setReadTimeout(30000); + try { + if (conn.getResponseCode() != 200) throw new IOException("Download failed: HTTP " + conn.getResponseCode()); + long total = conn.getContentLengthLong(); + try (InputStream in = conn.getInputStream(); OutputStream out = new FileOutputStream(target)) { + byte[] buffer = new byte[64 * 1024]; + long done = 0; + long lastReport = 0; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + done += n; + if (done - lastReport >= 256 * 1024 || done == total) { + lastReport = done; + JSObject progress = new JSObject(); + progress.put("downloaded", done); + progress.put("total", total); + notifyListeners("progress", progress); + } + } + } + } finally { + conn.disconnect(); + } + } + + private static String sha256Hex(File file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream in = new FileInputStream(file)) { + byte[] buffer = new byte[64 * 1024]; + int n; + while ((n = in.read(buffer)) != -1) digest.update(buffer, 0, n); + } + StringBuilder hex = new StringBuilder(); + for (byte b : digest.digest()) hex.append(String.format("%02x", b)); + return hex.toString(); + } + + private void install(File apk) throws IOException { + PackageInstaller installer = getContext().getPackageManager().getPackageInstaller(); + PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL); + params.setAppPackageName(getContext().getPackageName()); + if (Build.VERSION.SDK_INT >= 31) { + // Granted only when Android allows it (this app is the installer of record); otherwise it asks. + params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED); + } + int sessionId = installer.createSession(params); + try (PackageInstaller.Session session = installer.openSession(sessionId)) { + try (InputStream in = new FileInputStream(apk); OutputStream out = session.openWrite("cardy.apk", 0, apk.length())) { + byte[] buffer = new byte[64 * 1024]; + int n; + while ((n = in.read(buffer)) != -1) out.write(buffer, 0, n); + session.fsync(out); + } + Intent intent = new Intent(getContext(), ApkInstallReceiver.class).setAction(ACTION_INSTALL_STATUS); + int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0); + PendingIntent pending = PendingIntent.getBroadcast(getContext(), sessionId, intent, flags); + session.commit(pending.getIntentSender()); + } + JSObject status = new JSObject(); + status.put("status", "installing"); + notifyListeners("status", status); + } + + /** Called by ApkInstallReceiver with the installer's result. */ + static void onInstallStatus(Intent intent) { + ApkUpdaterPlugin plugin = instance; + int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE); + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + // Android wants the user to confirm: show its dialog. + Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT); + if (confirm != null && plugin != null) { + confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + plugin.getContext().startActivity(confirm); + JSObject result = new JSObject(); + result.put("status", "confirm"); + plugin.notifyListeners("status", result); + } + return; + } + if (plugin == null) return; + plugin.busy = false; + if (status != PackageInstaller.STATUS_SUCCESS) { + JSObject result = new JSObject(); + result.put("status", "failed"); + String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + result.put("message", message != null ? message : "Installation failed (" + status + ")"); + plugin.notifyListeners("status", result); + } + } +} diff --git a/mobile-client/android/app/src/main/java/com/aggtaa/cardy/MainActivity.java b/mobile-client/android/app/src/main/java/com/aggtaa/cardy/MainActivity.java index 1c20188..157fb0f 100644 --- a/mobile-client/android/app/src/main/java/com/aggtaa/cardy/MainActivity.java +++ b/mobile-client/android/app/src/main/java/com/aggtaa/cardy/MainActivity.java @@ -7,6 +7,7 @@ public class MainActivity extends BridgeActivity { @Override public void onCreate(Bundle savedInstanceState) { registerPlugin(ScreenBrightnessPlugin.class); + registerPlugin(ApkUpdaterPlugin.class); super.onCreate(savedInstanceState); } } diff --git a/mobile-client/android/capacitor.settings.gradle b/mobile-client/android/capacitor.settings.gradle index 3ed849b..07507e5 100644 --- a/mobile-client/android/capacitor.settings.gradle +++ b/mobile-client/android/capacitor.settings.gradle @@ -7,3 +7,6 @@ project(':capacitor-app').projectDir = new File('../../node_modules/.pnpm/@capac include ':capacitor-preferences' project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/preferences/android') + +include ':capgo-capacitor-updater' +project(':capgo-capacitor-updater').projectDir = new File('../../node_modules/.pnpm/@capgo+capacitor-updater@8.51.25_@capacitor+core@8.5.2/node_modules/@capgo/capacitor-updater/android') diff --git a/mobile-client/android/keystore.properties.example b/mobile-client/android/keystore.properties.example new file mode 100644 index 0000000..4404520 --- /dev/null +++ b/mobile-client/android/keystore.properties.example @@ -0,0 +1,6 @@ +# Copy to keystore.properties (same folder, not committed) and fill in. +# Use forward slashes in the path. +storeFile=D:/Documents/Security/android_keychain/cardy.jks +storePassword= +keyAlias= +keyPassword= diff --git a/mobile-client/capacitor.config.ts b/mobile-client/capacitor.config.ts index 3c5d7a7..84c2de4 100644 --- a/mobile-client/capacitor.config.ts +++ b/mobile-client/capacitor.config.ts @@ -1,13 +1,37 @@ import type { CapacitorConfig } from '@capacitor/cli'; +// Live reload (scripts/android-dev.mjs): the app loads the Vite dev server instead of the bundled build. +// Only set while syncing for development; a normal `build:android` bundles the web build. +const devUrl = process.env.CAP_DEV_URL; + const config: CapacitorConfig = { appId: 'com.aggtaa.cardy', appName: 'Cardy', webDir: 'dist-capacitor', backgroundColor: '#0f1115', + // No Capacitor logging even in debug builds: it writes plugin results (e.g. the stored access key + // read from Preferences) and the JS console to logcat. Debug JS via chrome://inspect instead. + loggingBehavior: 'none', android: { backgroundColor: '#0f1115', }, + plugins: { + // Live updates in manual mode (src/updater.ts): the app decides when to check, download and apply. + CapacitorUpdater: { + autoUpdate: 'off', + // Never talk to Capgo's cloud: no stats/telemetry, no update or channel requests. + statsUrl: '', + updateUrl: '', + channelUrl: '', + // A new bundle must call notifyAppReady() within this time, otherwise it is rolled back. + appReadyTimeout: 10000, + // A newly installed APK starts from its own built-in bundle. + resetWhenUpdate: true, + autoDeleteFailed: true, + autoDeletePrevious: true, + }, + }, + ...(devUrl ? { server: { url: devUrl, cleartext: true } } : {}), }; export default config; diff --git a/mobile-client/package.json b/mobile-client/package.json index a504083..ff59b6b 100644 --- a/mobile-client/package.json +++ b/mobile-client/package.json @@ -9,6 +9,9 @@ "preview": "vite preview", "start": "vite build && vite preview", "build:android": "vite build --mode capacitor && cap sync android", + "android:dev": "node scripts/android-dev.mjs", + "publish:android": "node scripts/publish-android-update.mjs", + "publish:android-apk": "node scripts/publish-android-apk.mjs", "typecheck": "tsc -p tsconfig.json", "test": "vitest run" }, @@ -17,6 +20,7 @@ "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.5.2", "@capacitor/preferences": "^8.0.1", + "@capgo/capacitor-updater": "^8.51.25", "@cardy/shared": "workspace:*", "barcode-detector": "^3.2.2", "bwip-js": "^4.11.4", @@ -30,6 +34,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", + "fflate": "^0.8.3", "typescript": "^7.0.2", "vite": "^8.3.1", "vite-plugin-pwa": "^1.3.0", diff --git a/mobile-client/scripts/android-dev.mjs b/mobile-client/scripts/android-dev.mjs new file mode 100644 index 0000000..85c8cd9 --- /dev/null +++ b/mobile-client/scripts/android-dev.mjs @@ -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 ). +// +// 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); diff --git a/mobile-client/scripts/publish-android-apk.mjs b/mobile-client/scripts/publish-android-apk.mjs new file mode 100644 index 0000000..bd1e993 --- /dev/null +++ b/mobile-client/scripts/publish-android-apk.mjs @@ -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-.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.'); diff --git a/mobile-client/scripts/publish-android-update.mjs b/mobile-client/scripts/publish-android-update.mjs new file mode 100644 index 0000000..03a5b4c --- /dev/null +++ b/mobile-client/scripts/publish-android-update.mjs @@ -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-.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.'); diff --git a/mobile-client/src/ApkUpdateBanner.tsx b/mobile-client/src/ApkUpdateBanner.tsx new file mode 100644 index 0000000..22640da --- /dev/null +++ b/mobile-client/src/ApkUpdateBanner.tsx @@ -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({ 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 ( + <> +
+ {text} + {action && ( + + )} +
+ {state.step === 'permission' && ( + setState({ step: 'idle' })} + onConfirm={() => { + setState({ step: 'idle' }); + void apkInstaller.openInstallSettings(); + }} + /> + )} + + ); +} diff --git a/mobile-client/src/App.tsx b/mobile-client/src/App.tsx index 8f4aaab..d113d33 100644 --- a/mobile-client/src/App.tsx +++ b/mobile-client/src/App.tsx @@ -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
; - return
; + return
; } -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(); + 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 })} diff --git a/mobile-client/src/PinPad.tsx b/mobile-client/src/PinPad.tsx index 446eea6..302d00b 100644 --- a/mobile-client/src/PinPad.tsx +++ b/mobile-client/src/PinPad.tsx @@ -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({
{footer}
+ ); } diff --git a/mobile-client/src/buildVersion.ts b/mobile-client/src/buildVersion.ts new file mode 100644 index 0000000..a6456f0 --- /dev/null +++ b/mobile-client/src/buildVersion.ts @@ -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; +} diff --git a/mobile-client/src/env.d.ts b/mobile-client/src/env.d.ts new file mode 100644 index 0000000..d115ef5 --- /dev/null +++ b/mobile-client/src/env.d.ts @@ -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; diff --git a/mobile-client/src/logic.test.ts b/mobile-client/src/logic.test.ts index b87b3f5..952d0bb 100644 --- a/mobile-client/src/logic.test.ts +++ b/mobile-client/src/logic.test.ts @@ -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 => ({ 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'); diff --git a/mobile-client/src/screens/CardScreen.tsx b/mobile-client/src/screens/CardScreen.tsx index 0aff968..447ff46 100644 --- a/mobile-client/src/screens/CardScreen.tsx +++ b/mobile-client/src/screens/CardScreen.tsx @@ -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({ {CODE_TYPE_LABELS[card.codeType]} + {card.tags?.length > 0 && } {card.comment &&

{card.comment}

} {card.images.length > 0 && ( diff --git a/mobile-client/src/screens/CardsScreen.tsx b/mobile-client/src/screens/CardsScreen.tsx index 0b12d25..12e47bd 100644 --- a/mobile-client/src/screens/CardsScreen.tsx +++ b/mobile-client/src/screens/CardsScreen.tsx @@ -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(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 ( setConfirmKey(true)} />} right={} @@ -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({ )} + {apkUpdate && } + {sync.busy && sync.progress && sync.progress.total > 0 && ( Downloading images {sync.progress.done}/{sync.progress.total} @@ -108,6 +112,8 @@ export function CardsScreen({ )} + + diff --git a/mobile-client/src/screens/EditScreen.tsx b/mobile-client/src/screens/EditScreen.tsx index e540378..ed7c5b4 100644 --- a/mobile-client/src/screens/EditScreen.tsx +++ b/mobile-client/src/screens/EditScreen.tsx @@ -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(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({ /> + +
Photos
diff --git a/mobile-client/src/screens/LoginScreen.tsx b/mobile-client/src/screens/LoginScreen.tsx index 6d25394..8ffc642 100644 --- a/mobile-client/src/screens/LoginScreen.tsx +++ b/mobile-client/src/screens/LoginScreen.tsx @@ -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'} + ); } diff --git a/mobile-client/src/styles.css b/mobile-client/src/styles.css index 543e52d..29f8d1d 100644 --- a/mobile-client/src/styles.css +++ b/mobile-client/src/styles.css @@ -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 { diff --git a/mobile-client/src/ui.tsx b/mobile-client/src/ui.tsx index dd42d34..17acb84 100644 --- a/mobile-client/src/ui.tsx +++ b/mobile-client/src/ui.tsx @@ -41,6 +41,23 @@ export function IconButton({ icon, label, onClick, disabled, spinning }: { icon: ); } +export function TagList({ tags }: { tags: readonly string[] }) { + return ( +
    + {tags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ ); +} + +/** Version of the running web build (built-in or live-updated bundle). */ +export function VersionLabel() { + return
Version {__BUILD_VERSION__}
; +} + // ---- layout ---- export function Screen({ title, left, right, children, className }: { title?: ReactNode; left?: ReactNode; right?: ReactNode; children: ReactNode; className?: string }) { diff --git a/mobile-client/src/updater.ts b/mobile-client/src/updater.ts new file mode 100644 index 0000000..ef2db8d --- /dev/null +++ b/mobile-client/src/updater.ts @@ -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 { + 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 { + 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; + downloadAndInstall(options: { url: string; sha256: string }): Promise; + addListener(event: 'progress', cb: (e: { downloaded: number; total: number }) => void): Promise; + addListener(event: 'status', cb: (e: { status: 'installing' | 'confirm' | 'failed'; message?: string }) => void): Promise; +} + +/** Native plugin in android/app (ApkUpdaterPlugin.java). Used directly, never through a Promise. */ +const ApkUpdater = registerPlugin('ApkUpdater'); + +/** A newer APK than the installed one, if the server publishes it. */ +export async function checkForApkUpdate(): Promise { + 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 { + 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 { + if (pendingBundleId) await applyNow(pendingBundleId); +} + +async function applyNow(id: string): Promise { + 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 }); +} diff --git a/mobile-client/vite.config.ts b/mobile-client/vite.config.ts index 99a8421..aadb165 100644 --- a/mobile-client/vite.config.ts +++ b/mobile-client/vite.config.ts @@ -1,39 +1,94 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import path from 'node:path'; import react from '@vitejs/plugin-react'; import { defineConfig, loadEnv, type Plugin, type ProxyOptions } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; +import { buildVersionFromDate } from './src/buildVersion.js'; // Web build: public at https://dev.aggtaa.com/m/, the reverse proxy strips "/m" (X-Forwarded-Prefix). // Capacitor build (--mode capacitor): served from the app bundle root. // // Env (mobile-client/.env or process env): -// PORT dev/preview server port (default 10003) -// ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com) -// BACKEND_URL local dev only: server-be address (default http://127.0.0.1:10002) +// PORT dev/preview server port (default 10003) +// ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com) +// BACKEND_URL local dev only: server-be address (default http://127.0.0.1:10002) +// CARDY_UPDATE_URL where the Android app looks for live updates (default https://dev.aggtaa.com/m/android) +// CARDY_BUILD_VERSION override the build version (default: from the build time) const WEB_BASE = '/m/'; +/** Published Android updates (scripts/publish-android-update.mjs, publish-android-apk.mjs), served at /m/android/. */ +const UPDATES_DIR = path.resolve('updates/android'); +const UPDATES_PATH = `${WEB_BASE}android/`; + +type Next = () => void; /** The proxy strips /m; put it back so Vite's base-aware dev and preview servers find the files. */ -function restoreStrippedBase(): Plugin { - const rewrite = (req: { url?: string }, _res: unknown, next: () => void) => { - const url = req.url ?? '/'; - if (!url.startsWith(WEB_BASE) && !url.startsWith('/api/') && url !== '/api') req.url = WEB_BASE.slice(0, -1) + url; - next(); +function restoreStrippedBase(req: IncomingMessage, _res: ServerResponse, next: Next) { + const url = req.url ?? '/'; + if (!url.startsWith(WEB_BASE) && !url.startsWith('/api/') && url !== '/api') req.url = WEB_BASE.slice(0, -1) + url; + next(); +} + +/** version.json and bundle zips for the Android app; CORS is open because the app runs on https://localhost. */ +function serveAndroidUpdates(req: IncomingMessage, res: ServerResponse, next: Next) { + const url = (req.url ?? '').split('?')[0] ?? ''; + if (!url.startsWith(UPDATES_PATH) || (req.method !== 'GET' && req.method !== 'HEAD')) return next(); + const name = decodeURIComponent(url.slice(UPDATES_PATH.length)); + const file = path.join(UPDATES_DIR, name); + res.setHeader('Access-Control-Allow-Origin', '*'); + if (!/^[A-Za-z0-9._-]+$/.test(name) || !existsSync(file)) { + res.statusCode = 404; + res.end(); + return; + } + const isJson = name.endsWith('.json'); + res.setHeader( + 'Content-Type', + isJson ? 'application/json' : name.endsWith('.apk') ? 'application/vnd.android.package-archive' : 'application/zip', + ); + if (!isJson) res.setHeader('Content-Length', String(statSync(file).size)); + // Bundle files are named by version and never change. + res.setHeader('Cache-Control', isJson ? 'no-cache' : 'public, max-age=31536000, immutable'); + if (req.method === 'HEAD') { + res.end(); + return; + } + createReadStream(file).pipe(res); +} + +function cardyServer(): Plugin { + const install = (use: (handler: (req: IncomingMessage, res: ServerResponse, next: Next) => void) => void) => { + use(restoreStrippedBase); + use(serveAndroidUpdates); }; return { - name: 'cardy-restore-stripped-base', - configureServer: (server) => void server.middlewares.use(rewrite), - configurePreviewServer: (server) => void server.middlewares.use(rewrite), + name: 'cardy-server', + configureServer: (server) => install((h) => server.middlewares.use(h)), + configurePreviewServer: (server) => install((h) => server.middlewares.use(h)), }; } -export default defineConfig(({ mode }) => { +/** Writes cardy-version.json next to the build: read by the update publishing script. */ +function emitVersionFile(version: string): Plugin { + return { + name: 'cardy-version-file', + apply: 'build', + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'cardy-version.json', source: JSON.stringify({ version }) }); + }, + }; +} + +export default defineConfig(({ mode, command }) => { const env = loadEnv(mode, process.cwd(), ''); const capacitor = mode === 'capacitor'; + const buildVersion = process.env.CARDY_BUILD_VERSION || (command === 'build' ? buildVersionFromDate(new Date()) : 'dev'); // Local development without the reverse proxy: /api/* -> server-be with /api stripped. const proxy: Record = { '/api': { target: env.BACKEND_URL || 'http://127.0.0.1:10002', - rewrite: (path) => path.replace(/^\/api/, '') || '/', + rewrite: (p) => p.replace(/^\/api/, '') || '/', headers: { 'X-Forwarded-Prefix': '/api' }, }, }; @@ -47,9 +102,14 @@ export default defineConfig(({ mode }) => { return { base: capacitor ? './' : WEB_BASE, + define: { + __BUILD_VERSION__: JSON.stringify(buildVersion), + __UPDATE_URL__: JSON.stringify((env.CARDY_UPDATE_URL || 'https://dev.aggtaa.com/m/android').replace(/\/+$/, '')), + }, plugins: [ react(), - restoreStrippedBase(), + cardyServer(), + ...(capacitor ? [emitVersionFile(buildVersion)] : []), VitePWA({ disable: capacitor, registerType: 'autoUpdate', @@ -75,7 +135,7 @@ export default defineConfig(({ mode }) => { // bwip-js and the zxing wasm module are large but needed offline. maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, navigateFallback: 'index.html', - navigateFallbackDenylist: [/^\/api(\/|$)/], + navigateFallbackDenylist: [/^\/api(\/|$)/, /\/android\//], }, }), ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ac86a..01de42b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@capacitor/preferences': specifier: ^8.0.1 version: 8.0.1(@capacitor/core@8.5.2) + '@capgo/capacitor-updater': + specifier: ^8.51.25 + version: 8.51.25(@capacitor/core@8.5.2) '@cardy/shared': specifier: workspace:* version: link:../shared @@ -56,6 +59,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.1.1 version: 6.1.1(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + fflate: + specifier: ^0.8.3 + version: 0.8.3 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -691,6 +697,11 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' + '@capgo/capacitor-updater@8.51.25': + resolution: {integrity: sha512-lznyAv9Ktl1CD6/OKZY+lQZl9ke+dbadLEB2XQKP7VbQO8/5D8yOHS+pysQgVnNkiZYEC3v1AxgY4a5Z+DnxXw==} + peerDependencies: + '@capacitor/core': ^8.0.0 + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -1818,6 +1829,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -3731,6 +3745,10 @@ snapshots: dependencies: '@capacitor/core': 8.5.2 + '@capgo/capacitor-updater@8.51.25(@capacitor/core@8.5.2)': + dependencies: + '@capacitor/core': 8.5.2 + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -4702,6 +4720,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + filelist@1.0.6: dependencies: minimatch: 5.1.9 diff --git a/server-be/src/cards.ts b/server-be/src/cards.ts index 67dbede..d0f20b1 100644 --- a/server-be/src/cards.ts +++ b/server-be/src/cards.ts @@ -18,6 +18,7 @@ const cardSchema = z.object({ code: z.coerce.string(), codeType: z.enum(CODE_TYPES), comment: z.string().default(''), + tags: z.array(z.string()).default([]), logo: imageRefSchema.nullable().default(null), images: z.array(imageRefSchema).default([]), imagesVersion: z.number().int().default(0), @@ -112,6 +113,7 @@ export class CardsService { id, ...fields, comment: fields.comment ?? '', + tags: fields.tags ?? [], logo: logoRef, images: [], imagesVersion: logoRef ? 1 : 0, diff --git a/server-be/test/clientApi.test.ts b/server-be/test/clientApi.test.ts index 1197ccb..91700de 100644 --- a/server-be/test/clientApi.test.ts +++ b/server-be/test/clientApi.test.ts @@ -152,6 +152,33 @@ describe('client API', () => { expect((patched.json() as Card).comment).toBe('From admin\nsecond line'); }); + it('parses comma-separated tags and keeps them on partial updates', async () => { + const { app, key } = await setup(); + const api = client(app, key); + + const created = (await api('POST', '/cards', [...cardParts(), { name: 'tags', value: 'один,,, два' }])).json() as Card; + expect(created.tags).toEqual(['один', 'два']); + + const renamed = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Renamed' }])).json() as Card; + expect(renamed.tags).toEqual(['один', 'два']); + + const retagged = (await api('PUT', `/cards/${created.id}`, [{ name: 'tags', value: ' food , ,pharmacy ' }])).json() as Card; + expect(retagged.tags).toEqual(['food', 'pharmacy']); + + const cleared = (await api('PUT', `/cards/${created.id}`, [{ name: 'tags', value: '' }])).json() as Card; + expect(cleared.tags).toEqual([]); + + expect((await api('POST', '/cards', cardParts())).json()).toMatchObject({ tags: [] }); + + const patched = await app.inject({ + method: 'PATCH', + url: `/admin/cards/${created.id}`, + headers: adminHeaders, + payload: { tags: [' a ', '', 'b, c'] }, + }); + expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']); + }); + it('returns JSON 404 for unknown API routes', async () => { const { app, key } = await setup(); const res = await client(app, key)('GET', '/nope'); diff --git a/server-fe/src/pages/CardEditor.tsx b/server-fe/src/pages/CardEditor.tsx index 2141aac..ba88846 100644 --- a/server-fe/src/pages/CardEditor.tsx +++ b/server-fe/src/pages/CardEditor.tsx @@ -1,5 +1,14 @@ import { useEffect, useState } from 'react'; -import { CODE_TYPES, CODE_TYPE_LABELS, cardFieldsSchema, randomCardColor, type Card, type CardFields } from '@cardy/shared'; +import { + CODE_TYPES, + CODE_TYPE_LABELS, + cardFieldsSchema, + formatTags, + parseTags, + randomCardColor, + type Card, + type CardFields, +} from '@cardy/shared'; import { api } from '../api.js'; import { CardBadge, CodePreview, ErrorText, FilePicker, formatDate, useAction, useLoad } from '../components.js'; import { linkClick, navigate } from '../router.js'; @@ -30,10 +39,12 @@ function Editor({ initial }: { initial?: Card }) { ? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType, comment: initial.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(initial?.tags)); const action = useAction(); const [saved, setSaved] = useState(false); - useEffect(() => setSaved(false), [fields]); + useEffect(() => setSaved(false), [fields, tagsText]); const dirty = !card || @@ -41,12 +52,13 @@ function Editor({ initial }: { initial?: Card }) { card.color !== fields.color || card.code !== fields.code || card.codeType !== fields.codeType || - (card.comment ?? '') !== (fields.comment ?? ''); + (card.comment ?? '') !== (fields.comment ?? '') || + (card.tags ?? []).join('\n') !== parseTags(tagsText).join('\n'); const set = (key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value })); const save = async (e: React.FormEvent) => { e.preventDefault(); - const parsed = cardFieldsSchema.safeParse(fields); + const parsed = cardFieldsSchema.safeParse({ ...fields, tags: tagsText }); if (!parsed.success) { action.setError(parsed.error.issues.map((i) => i.message).join('; ')); return; @@ -54,6 +66,7 @@ function Editor({ initial }: { initial?: Card }) { const result = await action.run(() => (card ? api.updateCard(card.id, parsed.data) : api.createCard(parsed.data))); if (!result) return; setCard(result); + setTagsText(formatTags(result.tags)); setSaved(true); if (!card) navigate(`/cards/${result.id}`, { replace: true }); }; @@ -133,6 +146,19 @@ function Editor({ initial }: { initial?: Card }) { onChange={(e) => set('comment', e.target.value)} />
+
+ + setTagsText(e.target.value)} placeholder="Comma-separated, e.g. food, pharmacy" /> + {parseTags(tagsText).length > 0 && ( +
+ {parseTags(tagsText).map((t) => ( + + {t} + + ))} +
+ )} +
- setFilter(e.target.value)} /> + setFilter(e.target.value)} + />
+ {c.tags?.length > 0 &&
{c.tags.join(' · ')}
}
{c.deleted && deleted} diff --git a/server-fe/src/styles.css b/server-fe/src/styles.css index 3d7f06c..f9f4a63 100644 --- a/server-fe/src/styles.css +++ b/server-fe/src/styles.css @@ -420,6 +420,18 @@ section > .row { opacity: 0.55; } +.pill.tag-pill { + margin-left: 0; + color: var(--text); +} + +.card-tags { + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .card-name { font-weight: 600; overflow: hidden; diff --git a/shared/src/cardDisplay.ts b/shared/src/cardDisplay.ts index 6324fa2..f28d074 100644 --- a/shared/src/cardDisplay.ts +++ b/shared/src/cardDisplay.ts @@ -1,3 +1,10 @@ +/** Case-insensitive substring search over the card's name, tags and comment; an empty query matches all. */ +export function cardMatchesQuery(card: { name: string; tags?: readonly string[]; comment?: string }, query: string): boolean { + const q = query.trim().toLocaleLowerCase(); + if (!q) return true; + return [card.name, ...(card.tags ?? []), card.comment ?? ''].some((text) => text.toLocaleLowerCase().includes(q)); +} + /** Colors that keep white text readable. */ export const CARD_PALETTE = [ '#C62828', '#AD1457', '#6A1B9A', '#4527A0', '#283593', '#1565C0', '#01579B', '#00695C', diff --git a/shared/src/models.ts b/shared/src/models.ts index 7aaeaba..0fc945d 100644 --- a/shared/src/models.ts +++ b/shared/src/models.ts @@ -22,6 +22,8 @@ export interface Card { codeType: CodeType; /** Free text, may be multiline; '' when empty. */ comment: string; + /** Trimmed, non-empty, unique; [] when none. */ + tags: string[]; logo: ImageRef | null; images: ImageRef[]; imagesVersion: number; diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 4483e07..83d20d6 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -4,6 +4,37 @@ import { ROLES } from './models.js'; export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB'); +export const MAX_TAGS = 30; +export const MAX_TAG_LENGTH = 50; + +/** + * Tags from a comma-separated string (or a list of such strings): split by commas, trim each tag, + * drop empty ones and exact duplicates. "один,,, два" -> ["один", "два"]. + */ +export function parseTags(input: string | readonly string[]): string[] { + const parts = (typeof input === 'string' ? [input] : input).flatMap((s) => s.split(',')); + const tags: string[] = []; + for (const part of parts) { + const tag = part.trim(); + if (tag && !tags.includes(tag)) tags.push(tag); + } + return tags; +} + +/** Tags written as the user types them: "a, b, c". */ +export function formatTags(tags: readonly string[] | undefined): string { + return (tags ?? []).join(', '); +} + +export const tagsSchema = z + .union([z.string(), z.array(z.string())]) + .transform(parseTags) + .pipe( + z + .array(z.string().max(MAX_TAG_LENGTH, `A tag is too long (max ${MAX_TAG_LENGTH} characters)`)) + .max(MAX_TAGS, `Too many tags (max ${MAX_TAGS})`), + ); + export const cardFieldsSchema = z.object({ name: z.string().trim().min(1, 'Name is required').max(200), color: colorSchema, @@ -16,6 +47,8 @@ export const cardFieldsSchema = z.object({ .transform((s) => s.replace(/\r\n?/g, '\n').trim()) .pipe(z.string().max(2000, 'Comment is too long (max 2000 characters)')) .optional(), + // Comma-separated string or a list; no default: partial updates must not clear them. + tags: tagsSchema.optional(), }); export type CardFields = z.infer; diff --git a/shared/src/tags.test.ts b/shared/src/tags.test.ts new file mode 100644 index 0000000..fab36ad --- /dev/null +++ b/shared/src/tags.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { cardMatchesQuery } from './cardDisplay.js'; +import { cardFieldsSchema, formatTags, parseTags, tagsSchema } from './schemas.js'; + +describe('tags', () => { + it('splits by commas, trims and drops empty tags', () => { + expect(parseTags('один,,, два')).toEqual(['один', 'два']); + expect(parseTags(' food , pharmacy, ')).toEqual(['food', 'pharmacy']); + expect(parseTags('')).toEqual([]); + expect(parseTags(' , ,, ')).toEqual([]); + }); + + it('keeps inner spaces and case, drops exact duplicates', () => { + expect(parseTags('Home Goods, home goods, Home Goods')).toEqual(['Home Goods', 'home goods']); + }); + + it('accepts lists of comma-separated strings', () => { + expect(parseTags(['a, b', ' c ', ''])).toEqual(['a', 'b', 'c']); + }); + + it('validates limits and formats back', () => { + expect(tagsSchema.safeParse('x'.repeat(51)).success).toBe(false); + expect(tagsSchema.safeParse(Array.from({ length: 31 }, (_, i) => `t${i}`).join(',')).success).toBe(false); + expect(formatTags(['a', 'b c'])).toBe('a, b c'); + }); + + it('is optional in card fields (partial updates keep tags)', () => { + const base = { name: 'Shop', color: '#123456', code: '1', codeType: 'QR' as const }; + expect(cardFieldsSchema.parse(base).tags).toBeUndefined(); + expect(cardFieldsSchema.parse({ ...base, tags: 'один,,, два' }).tags).toEqual(['один', 'два']); + expect(cardFieldsSchema.partial().parse({}).tags).toBeUndefined(); + }); +}); + +describe('cardMatchesQuery', () => { + const card = { name: 'Пятёрочка', tags: ['продукты', 'Food'], comment: 'Карта Анны\nсканировать дважды' }; + + it('searches name, tags and comment, case-insensitively', () => { + expect(cardMatchesQuery(card, 'пятёр')).toBe(true); + expect(cardMatchesQuery(card, 'ПРОДУКТ')).toBe(true); + expect(cardMatchesQuery(card, 'food')).toBe(true); + expect(cardMatchesQuery(card, 'анны')).toBe(true); + expect(cardMatchesQuery(card, 'аптека')).toBe(false); + expect(cardMatchesQuery(card, ' ')).toBe(true); + }); + + it('handles cards stored before tags and comments existed', () => { + expect(cardMatchesQuery({ name: 'Old' }, 'old')).toBe(true); + expect(cardMatchesQuery({ name: 'Old' }, 'x')).toBe(false); + }); +});