capgo and adk autoupdates. card tags
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-capacitor/
|
||||
mobile-client/updates/
|
||||
mobile-client/android/keystore.properties
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка 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. Тесты
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.aggtaa.cardy",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 24
|
||||
}
|
||||
@@ -32,6 +32,11 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<!-- Results of self-updates via PackageInstaller (ApkUpdaterPlugin). -->
|
||||
<receiver
|
||||
android:name=".ApkInstallReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -49,4 +54,7 @@
|
||||
<!-- Scanning key QR codes and card barcodes (getUserMedia in the WebView). -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<!-- Self-update: install newer APKs of this app downloaded from the Cardy server. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
</manifest>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(ScreenBrightnessPlugin.class);
|
||||
registerPlugin(ApkUpdaterPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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=
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.');
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { apkInstaller, type PublishedApk } from './updater.js';
|
||||
import { ConfirmDialog } from './ui.js';
|
||||
|
||||
type State =
|
||||
| { step: 'idle' }
|
||||
| { step: 'permission' }
|
||||
| { step: 'downloading'; downloaded: number; total: number }
|
||||
| { step: 'installing' }
|
||||
| { step: 'confirm' }
|
||||
| { step: 'error'; message: string };
|
||||
|
||||
const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
|
||||
|
||||
/** "App update available" on the card list: downloads the APK and hands it to Android's installer. */
|
||||
export function ApkUpdateBanner({ apk }: { apk: PublishedApk }) {
|
||||
const [state, setState] = useState<State>({ step: 'idle' });
|
||||
|
||||
const install = async () => {
|
||||
try {
|
||||
if (!(await apkInstaller.canInstall())) {
|
||||
setState({ step: 'permission' });
|
||||
return;
|
||||
}
|
||||
setState({ step: 'downloading', downloaded: 0, total: apk.size });
|
||||
await apkInstaller.install(apk, (e) => {
|
||||
if (e.kind === 'progress') setState({ step: 'downloading', downloaded: e.downloaded, total: e.total > 0 ? e.total : apk.size });
|
||||
else if (e.status === 'installing') setState({ step: 'installing' });
|
||||
else if (e.status === 'confirm') setState({ step: 'confirm' });
|
||||
else setState({ step: 'error', message: e.message ?? 'Installation failed' });
|
||||
});
|
||||
} catch (e) {
|
||||
setState({ step: 'error', message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
let text: string;
|
||||
let action: string | undefined = 'Install';
|
||||
switch (state.step) {
|
||||
case 'downloading':
|
||||
text = `Downloading the update… ${mb(state.downloaded)} / ${mb(state.total)} MB`;
|
||||
action = undefined;
|
||||
break;
|
||||
case 'installing':
|
||||
text = 'Installing… the app will restart.';
|
||||
action = undefined;
|
||||
break;
|
||||
case 'confirm':
|
||||
text = 'Confirm the installation in the Android dialog.';
|
||||
action = 'Retry';
|
||||
break;
|
||||
case 'error':
|
||||
text = `Update failed: ${state.message}`;
|
||||
action = 'Retry';
|
||||
break;
|
||||
default:
|
||||
text = `A new version of the app is available (${apk.versionName}, ${mb(apk.size)} MB).`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="banner info update-banner" role="status">
|
||||
<span>{text}</span>
|
||||
{action && (
|
||||
<button className="text-button strong" onClick={install}>
|
||||
{action}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{state.step === 'permission' && (
|
||||
<ConfirmDialog
|
||||
title="Allow app updates"
|
||||
message="Android needs your permission for Cardy to install its own updates. Turn on “Allow from this source” on the next screen, come back and tap Install again."
|
||||
confirmLabel="Open settings"
|
||||
onCancel={() => setState({ step: 'idle' })}
|
||||
onConfirm={() => {
|
||||
setState({ step: 'idle' });
|
||||
void apkInstaller.openInstallSettings();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,14 @@ import { useNavigation, type LoginPrefill } from './navigation.js';
|
||||
import { onBackButton, onDeepLink, onResumeAfter } from './platform.js';
|
||||
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
|
||||
import { refreshCardList, syncImages } from './sync.js';
|
||||
import {
|
||||
applyPendingUpdate,
|
||||
checkForApkUpdate,
|
||||
checkForUpdate,
|
||||
consumeUpdateReload,
|
||||
markAppReady,
|
||||
type PublishedApk,
|
||||
} from './updater.js';
|
||||
import { CardScreen } from './screens/CardScreen.js';
|
||||
import { CardsScreen, type SyncState } from './screens/CardsScreen.js';
|
||||
import { EditScreen } from './screens/EditScreen.js';
|
||||
@@ -29,13 +37,17 @@ export function App() {
|
||||
);
|
||||
}, []);
|
||||
if (!boot) return <div className="splash" />;
|
||||
return <Main boot={boot} />;
|
||||
return <Main boot={boot} justUpdated={JUST_UPDATED} />;
|
||||
}
|
||||
|
||||
function Main({ boot }: { boot: Boot }) {
|
||||
/** Read once per page load (render functions may run twice). */
|
||||
const JUST_UPDATED = consumeUpdateReload();
|
||||
|
||||
function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
const [session, setSession] = useState(boot.session);
|
||||
const [pin, setPin] = useState(boot.pin);
|
||||
const [locked, setLocked] = useState(!!boot.pin);
|
||||
// Not locked after a reload that applied an update: the user was unlocked a moment ago.
|
||||
const [locked, setLocked] = useState(() => !!boot.pin && !justUpdated);
|
||||
const [cards, setCards] = useState(boot.cards);
|
||||
const [usage, setUsage] = useState(boot.usage);
|
||||
const [imageEpoch, setImageEpoch] = useState(0);
|
||||
@@ -184,6 +196,37 @@ function Main({ boot }: { boot: Boot }) {
|
||||
|
||||
useEffect(() => onResumeAfter(RELOCK_AFTER_MS, () => pin && setLocked(true)), [pin]);
|
||||
|
||||
// ---- live updates (Android app only) ----
|
||||
|
||||
// The app rendered: confirm this bundle works (a bundle that never gets here is rolled back).
|
||||
useEffect(() => void markAppReady(), []);
|
||||
|
||||
// Reloading is safe only on the unlocked card list with no download running: never while a card is
|
||||
// shown at the till, while editing, or on the lock screen (the reload skips the PIN).
|
||||
const safeToReload = !locked && !lockedLogin && route?.name === 'cards' && !sync.busy;
|
||||
const safeRef = useRef(safeToReload);
|
||||
safeRef.current = safeToReload;
|
||||
|
||||
// A newer APK (native part changed) is offered on the card list; installing it needs the user's OK.
|
||||
const [apkUpdate, setApkUpdate] = useState<PublishedApk>();
|
||||
const lastApkCheck = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
void checkForUpdate(() => safeRef.current);
|
||||
if (Date.now() - lastApkCheck.current >= 60_000) {
|
||||
lastApkCheck.current = Date.now();
|
||||
void checkForApkUpdate().then(setApkUpdate);
|
||||
}
|
||||
};
|
||||
check();
|
||||
return onResumeAfter(0, check);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (safeToReload) void applyPendingUpdate();
|
||||
}, [safeToReload]);
|
||||
|
||||
// Android back button = history back; on the lock screen and at the root it exits the app.
|
||||
const lockedRef = useRef({ locked, lockedLogin });
|
||||
lockedRef.current = { locked, lockedLogin };
|
||||
@@ -261,6 +304,7 @@ function Main({ boot }: { boot: Boot }) {
|
||||
imageEpoch={imageEpoch}
|
||||
sync={sync}
|
||||
session={session}
|
||||
apkUpdate={apkUpdate}
|
||||
onDismissSync={() => setSync({ busy: false })}
|
||||
onRefresh={refresh}
|
||||
onOpen={(id) => push({ name: 'card', id })}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { PIN_LENGTH } from './pin.js';
|
||||
import { Icon } from './ui.js';
|
||||
import { Icon, VersionLabel } from './ui.js';
|
||||
|
||||
/**
|
||||
* Four-dot PIN entry with an on-screen keypad (a hardware keyboard works too).
|
||||
@@ -81,6 +81,7 @@ export function PinPad({
|
||||
</button>
|
||||
</div>
|
||||
<div className="pin-footer">{footer}</div>
|
||||
<VersionLabel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+4
@@ -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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Card } from '@cardy/shared';
|
||||
import { normalizeServerUrl } from './api.js';
|
||||
import { buildVersionFromDate, compareVersions } from './buildVersion.js';
|
||||
import { detectedFormatToCodeType } from './codeFormats.js';
|
||||
import { checkPin, createPinRecord } from './pin.js';
|
||||
import { cardsNeedingImages, runPool, sortCards } from './sync.js';
|
||||
@@ -12,6 +13,7 @@ const card = (id: string, name: string, extra: Partial<Card> = {}): Card => ({
|
||||
code: '1',
|
||||
codeType: 'QR',
|
||||
comment: '',
|
||||
tags: [],
|
||||
logo: null,
|
||||
images: [],
|
||||
imagesVersion: 0,
|
||||
@@ -78,6 +80,21 @@ describe('PIN', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('build versions', () => {
|
||||
it('derive a semver-compatible version from the build time (UTC)', () => {
|
||||
expect(buildVersionFromDate(new Date('2026-09-29T15:12:30Z'))).toBe('2026.929.151230');
|
||||
expect(buildVersionFromDate(new Date('2026-01-05T00:00:07Z'))).toBe('2026.105.7');
|
||||
});
|
||||
|
||||
it('orders versions like build times', () => {
|
||||
const times = ['2026-01-05T00:00:07Z', '2026-01-05T09:59:59Z', '2026-01-05T10:00:00Z', '2026-09-29T15:12:30Z', '2026-10-01T00:00:00Z', '2027-01-01T00:00:00Z'];
|
||||
const versions = times.map((t) => buildVersionFromDate(new Date(t)));
|
||||
for (let i = 1; i < versions.length; i++) expect(compareVersions(versions[i]!, versions[i - 1]!)).toBe(1);
|
||||
expect(compareVersions('2026.929.151230', '2026.929.151230')).toBe(0);
|
||||
expect(compareVersions('2026.929.1', 'dev')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('normalizes the server address', () => {
|
||||
expect(normalizeServerUrl(' dev.aggtaa.com/api/ ')).toBe('https://dev.aggtaa.com/api');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CODE_TYPE_LABELS, textColorOn, type Card } from '@cardy/shared';
|
||||
import { setMaxBrightness } from '../platform.js';
|
||||
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, useStoredImage } from '../ui.js';
|
||||
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, TagList, useStoredImage } from '../ui.js';
|
||||
|
||||
export function CardScreen({
|
||||
card,
|
||||
@@ -64,6 +64,7 @@ export function CardScreen({
|
||||
<span className="muted">{CODE_TYPE_LABELS[card.codeType]}</span>
|
||||
</div>
|
||||
|
||||
{card.tags?.length > 0 && <TagList tags={card.tags} />}
|
||||
{card.comment && <p className="card-comment">{card.comment}</p>}
|
||||
|
||||
{card.images.length > 0 && (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { textColorOn, type Card } from '@cardy/shared';
|
||||
import { cardMatchesQuery, textColorOn, type Card } from '@cardy/shared';
|
||||
import { ApkUpdateBanner } from '../ApkUpdateBanner.js';
|
||||
import type { Session, UsageMap } from '../storage.js';
|
||||
import type { PublishedApk } from '../updater.js';
|
||||
import { sortCards } from '../sync.js';
|
||||
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen } from '../ui.js';
|
||||
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
|
||||
|
||||
export interface SyncState {
|
||||
busy: boolean;
|
||||
@@ -17,6 +19,7 @@ export function CardsScreen({
|
||||
imageEpoch,
|
||||
sync,
|
||||
session,
|
||||
apkUpdate,
|
||||
onDismissSync,
|
||||
onRefresh,
|
||||
onOpen,
|
||||
@@ -28,6 +31,7 @@ export function CardsScreen({
|
||||
imageEpoch: number;
|
||||
sync: SyncState;
|
||||
session: Session;
|
||||
apkUpdate?: PublishedApk;
|
||||
onDismissSync: () => void;
|
||||
onRefresh: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
@@ -38,14 +42,12 @@ export function CardsScreen({
|
||||
const [confirmKey, setConfirmKey] = useState(false);
|
||||
const filterInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
const sorted = sortCards(cards, usage);
|
||||
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted;
|
||||
}, [cards, usage, filter]);
|
||||
// Search by name, tags and comment.
|
||||
const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]);
|
||||
|
||||
return (
|
||||
<Screen
|
||||
className="cards-screen"
|
||||
title="Cards"
|
||||
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
|
||||
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
|
||||
@@ -59,8 +61,8 @@ export function CardsScreen({
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="Filter by name"
|
||||
aria-label="Filter by name"
|
||||
placeholder="Search by name, tag or comment"
|
||||
aria-label="Search by name, tag or comment"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
@@ -78,6 +80,8 @@ export function CardsScreen({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apkUpdate && <ApkUpdateBanner apk={apkUpdate} />}
|
||||
|
||||
{sync.busy && sync.progress && sync.progress.total > 0 && (
|
||||
<Banner kind="info">
|
||||
Downloading images {sync.progress.done}/{sync.progress.total}
|
||||
@@ -108,6 +112,8 @@ export function CardsScreen({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VersionLabel />
|
||||
|
||||
<button className="fab" onClick={onAdd} aria-label="Add card">
|
||||
<Icon name="plus" size={28} />
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
CODE_TYPES,
|
||||
CODE_TYPE_LABELS,
|
||||
cardFieldsSchema,
|
||||
formatTags,
|
||||
parseTags,
|
||||
randomCardColor,
|
||||
type Card,
|
||||
type CardFields,
|
||||
@@ -14,7 +16,7 @@ import { ApiError, type Api } from '../api.js';
|
||||
import { compressImage, fileNameFor } from '../imageTools.js';
|
||||
import { ScannerView, scanImageFile } from '../ScannerView.js';
|
||||
import { images } from '../storage.js';
|
||||
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, useBlobUrl, useStoredImage } from '../ui.js';
|
||||
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, TagList, useBlobUrl, useStoredImage } from '../ui.js';
|
||||
|
||||
type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob };
|
||||
interface NewImage {
|
||||
@@ -45,6 +47,8 @@ export function EditScreen({
|
||||
? { name: card.name, color: card.color, code: card.code, codeType: card.codeType, comment: card.comment ?? '' }
|
||||
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
|
||||
);
|
||||
// Edited as typed ("a, b"); parsed into the tag list on save.
|
||||
const [tagsText, setTagsText] = useState(formatTags(card?.tags));
|
||||
// Set once the card exists on the server (also after a partially failed first save).
|
||||
const [savedId, setSavedId] = useState(card?.id);
|
||||
const [hadLogo, setHadLogo] = useState<ImageRef | null>(card?.logo ?? null);
|
||||
@@ -93,7 +97,7 @@ export function EditScreen({
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const parsed = cardFieldsSchema.safeParse(fields);
|
||||
const parsed = cardFieldsSchema.safeParse({ ...fields, tags: tagsText });
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues.map((i) => i.message).join('. '));
|
||||
return;
|
||||
@@ -108,7 +112,9 @@ export function EditScreen({
|
||||
let addedDone = false;
|
||||
try {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(parsed.data)) form.append(k, v);
|
||||
for (const [k, v] of Object.entries(parsed.data)) {
|
||||
if (v !== undefined) form.append(k, Array.isArray(v) ? v.join(',') : v);
|
||||
}
|
||||
if (logo.kind === 'new') form.append('logo', logo.blob, fileNameFor(logo.blob, 'logo'));
|
||||
if (logo.kind === 'remove' && hadLogo) form.append('removeLogo', 'true');
|
||||
current = savedId ? await api.updateCard(savedId, form) : await api.createCard(form);
|
||||
@@ -272,6 +278,18 @@ export function EditScreen({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>Tags</span>
|
||||
<input
|
||||
value={tagsText}
|
||||
onChange={(e) => setTagsText(e.target.value)}
|
||||
placeholder="Comma-separated, e.g. food, pharmacy"
|
||||
autoCapitalize="off"
|
||||
aria-label="Tags"
|
||||
/>
|
||||
{parseTags(tagsText).length > 0 && <TagList tags={parseTags(tagsText)} />}
|
||||
</label>
|
||||
|
||||
<div className="field">
|
||||
<span>Photos</span>
|
||||
<div className="photo-grid">
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { LoginPrefill } from '../navigation.js';
|
||||
import { defaultServerUrl } from '../platform.js';
|
||||
import { ScannerView } from '../ScannerView.js';
|
||||
import type { Session } from '../storage.js';
|
||||
import { Banner, Icon, IconButton, Screen } from '../ui.js';
|
||||
import { Banner, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
|
||||
|
||||
export type { LoginPrefill };
|
||||
|
||||
@@ -136,6 +136,7 @@ export function LoginScreen({
|
||||
{busy ? 'Checking…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<VersionLabel />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,6 +41,23 @@ export function IconButton({ icon, label, onClick, disabled, spinning }: { icon:
|
||||
);
|
||||
}
|
||||
|
||||
export function TagList({ tags }: { tags: readonly string[] }) {
|
||||
return (
|
||||
<ul className="tags" aria-label="Tags">
|
||||
{tags.map((tag) => (
|
||||
<li key={tag} className="tag">
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Version of the running web build (built-in or live-updated bundle). */
|
||||
export function VersionLabel() {
|
||||
return <div className="version-label">Version {__BUILD_VERSION__}</div>;
|
||||
}
|
||||
|
||||
// ---- layout ----
|
||||
|
||||
export function Screen({ title, left, right, children, className }: { title?: ReactNode; left?: ReactNode; right?: ReactNode; children: ReactNode; className?: string }) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { App } from '@capacitor/app';
|
||||
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||
import { CapacitorUpdater } from '@capgo/capacitor-updater';
|
||||
import { compareVersions } from './buildVersion.js';
|
||||
import { isNative } from './platform.js';
|
||||
|
||||
/**
|
||||
* Live updates of the Android app's web part (Capgo updater in manual mode, own server):
|
||||
* the app always runs a local bundle (built-in or downloaded), so everything keeps working offline.
|
||||
* When dev.aggtaa.com is reachable and publishes a newer version, the bundle is downloaded in the
|
||||
* background and applied right away on a safe screen, otherwise via next() (on backgrounding).
|
||||
*/
|
||||
|
||||
export const BUILD_VERSION = __BUILD_VERSION__;
|
||||
const UPDATE_URL = __UPDATE_URL__;
|
||||
const CHECK_TIMEOUT_MS = 10_000;
|
||||
/** Do not check more often than this (launch + every return to the foreground). */
|
||||
const MIN_CHECK_INTERVAL_MS = 60_000;
|
||||
/** Set right before an immediate reload so the fresh bundle does not ask for the PIN again. */
|
||||
const RELOAD_MARK = 'cardy:update-reload';
|
||||
|
||||
interface PublishedVersion {
|
||||
version: string;
|
||||
/** Bundle zip, relative to version.json or absolute. */
|
||||
url: string;
|
||||
/** SHA-256 of the zip, hex. */
|
||||
checksum: string;
|
||||
/** Lowest native build (android versionCode) the bundle works with. */
|
||||
minNativeBuild?: number;
|
||||
}
|
||||
|
||||
// Imported statically on purpose: a Capacitor plugin object is a Proxy that answers any property,
|
||||
// including `then`, so it must never be the resolved value of a Promise (the Promise would call
|
||||
// CapacitorUpdater.then() -> "not implemented on android").
|
||||
const updater = CapacitorUpdater;
|
||||
|
||||
let checking = false;
|
||||
let lastCheck = 0;
|
||||
/** Downloaded bundle waiting for a safe moment (also queued with next()). */
|
||||
let pendingBundleId: string | undefined;
|
||||
|
||||
/** Must run early on every start: otherwise the updater rolls back to the previous bundle. */
|
||||
export async function markAppReady(): Promise<void> {
|
||||
if (!isNative) return;
|
||||
try {
|
||||
await updater.notifyAppReady();
|
||||
} catch (e) {
|
||||
console.warn('notifyAppReady failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** True once, right after this app reloaded itself to apply an update. */
|
||||
export function consumeUpdateReload(): boolean {
|
||||
try {
|
||||
const at = Number(localStorage.getItem(RELOAD_MARK) ?? 0);
|
||||
localStorage.removeItem(RELOAD_MARK);
|
||||
return at > 0 && Date.now() - at < 60_000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the server for a newer bundle and downloads it. Applies it immediately when
|
||||
* `canApplyNow()` says so, otherwise queues it for the next backgrounding.
|
||||
*/
|
||||
export async function checkForUpdate(canApplyNow: () => boolean): Promise<void> {
|
||||
if (!isNative || BUILD_VERSION === 'dev' || checking) return;
|
||||
if (Date.now() - lastCheck < MIN_CHECK_INTERVAL_MS) return;
|
||||
checking = true;
|
||||
lastCheck = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${UPDATE_URL}/version.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
|
||||
if (!res.ok) return;
|
||||
const published = (await res.json()) as PublishedVersion;
|
||||
if (!published?.version || !published.url || compareVersions(published.version, BUILD_VERSION) <= 0) return;
|
||||
|
||||
const nativeBuild = Number((await App.getInfo()).build) || 0;
|
||||
if (published.minNativeBuild && nativeBuild < published.minNativeBuild) {
|
||||
console.info(`Update ${published.version} needs a newer app (build ${published.minNativeBuild}, installed ${nativeBuild})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { bundles } = await updater.list();
|
||||
let bundle = bundles.find((b) => b.version === published.version && b.status !== 'error' && b.status !== 'downloading');
|
||||
bundle ??= await updater.download({
|
||||
url: new URL(published.url, `${UPDATE_URL}/`).href,
|
||||
version: published.version,
|
||||
checksum: published.checksum,
|
||||
});
|
||||
|
||||
if (canApplyNow()) await applyNow(bundle.id);
|
||||
else {
|
||||
await updater.next({ id: bundle.id });
|
||||
pendingBundleId = bundle.id;
|
||||
}
|
||||
} catch (e) {
|
||||
// Offline, server down, bad bundle: keep running the current version.
|
||||
console.warn('Update check failed', e);
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- APK self-update (native part changed: plugins, manifest, Java) ----
|
||||
|
||||
export interface PublishedApk {
|
||||
versionCode: number;
|
||||
versionName: string;
|
||||
/** APK file, relative to apk.json or absolute. */
|
||||
url: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type ApkInstallEvent =
|
||||
| { kind: 'progress'; downloaded: number; total: number }
|
||||
| { kind: 'status'; status: 'installing' | 'confirm' | 'failed'; message?: string };
|
||||
|
||||
interface ApkUpdaterPlugin {
|
||||
canInstall(): Promise<{ allowed: boolean }>;
|
||||
openInstallSettings(): Promise<void>;
|
||||
downloadAndInstall(options: { url: string; sha256: string }): Promise<void>;
|
||||
addListener(event: 'progress', cb: (e: { downloaded: number; total: number }) => void): Promise<PluginListenerHandle>;
|
||||
addListener(event: 'status', cb: (e: { status: 'installing' | 'confirm' | 'failed'; message?: string }) => void): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
/** Native plugin in android/app (ApkUpdaterPlugin.java). Used directly, never through a Promise. */
|
||||
const ApkUpdater = registerPlugin<ApkUpdaterPlugin>('ApkUpdater');
|
||||
|
||||
/** A newer APK than the installed one, if the server publishes it. */
|
||||
export async function checkForApkUpdate(): Promise<PublishedApk | undefined> {
|
||||
if (!isNative || BUILD_VERSION === 'dev') return undefined;
|
||||
try {
|
||||
const res = await fetch(`${UPDATE_URL}/apk.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
|
||||
if (!res.ok) return undefined;
|
||||
const apk = (await res.json()) as PublishedApk;
|
||||
const installed = Number((await App.getInfo()).build) || 0;
|
||||
return apk?.versionCode > installed && apk.url && apk.sha256 ? apk : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const apkInstaller = {
|
||||
canInstall: async () => (await ApkUpdater.canInstall()).allowed,
|
||||
openInstallSettings: () => ApkUpdater.openInstallSettings(),
|
||||
|
||||
/** Downloads and hands the APK to Android; `onEvent` reports progress and the installer's status. */
|
||||
async install(apk: PublishedApk, onEvent: (e: ApkInstallEvent) => void): Promise<void> {
|
||||
const handles: PluginListenerHandle[] = [];
|
||||
const cleanup = () => void Promise.all(handles.splice(0).map((h) => h.remove()));
|
||||
handles.push(
|
||||
await ApkUpdater.addListener('progress', (e) => onEvent({ kind: 'progress', ...e })),
|
||||
await ApkUpdater.addListener('status', (e) => {
|
||||
onEvent({ kind: 'status', ...e });
|
||||
if (e.status === 'failed') cleanup();
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await ApkUpdater.downloadAndInstall({ url: new URL(apk.url, `${UPDATE_URL}/`).href, sha256: apk.sha256 });
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw e;
|
||||
}
|
||||
// Listeners stay until the installer reports "failed"; a successful install restarts the app.
|
||||
},
|
||||
};
|
||||
|
||||
/** Applies a queued update now (call when the app reaches a safe screen). */
|
||||
export async function applyPendingUpdate(): Promise<void> {
|
||||
if (pendingBundleId) await applyNow(pendingBundleId);
|
||||
}
|
||||
|
||||
async function applyNow(id: string): Promise<void> {
|
||||
try {
|
||||
localStorage.setItem(RELOAD_MARK, String(Date.now()));
|
||||
} catch {
|
||||
// the PIN will simply be asked again
|
||||
}
|
||||
// Reloads the WebView from the new bundle; nothing after this runs.
|
||||
await updater.set({ id });
|
||||
}
|
||||
@@ -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<string, ProxyOptions> = {
|
||||
'/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\//],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Generated
+20
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = <K extends keyof CardFields>(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)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="tags">Tags</label>
|
||||
<input id="tags" value={tagsText} onChange={(e) => setTagsText(e.target.value)} placeholder="Comma-separated, e.g. food, pharmacy" />
|
||||
{parseTags(tagsText).length > 0 && (
|
||||
<div className="row">
|
||||
{parseTags(tagsText).map((t) => (
|
||||
<span key={t} className="pill tag-pill">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<button type="submit" disabled={action.busy || (!dirty && !!card)}>
|
||||
{card ? 'Save' : 'Create card'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CODE_TYPE_LABELS } from '@cardy/shared';
|
||||
import { CODE_TYPE_LABELS, cardMatchesQuery } from '@cardy/shared';
|
||||
import { api } from '../api.js';
|
||||
import { CardBadge, ErrorText, useLoad } from '../components.js';
|
||||
import { linkClick } from '../router.js';
|
||||
@@ -9,13 +9,14 @@ export function CardsPage() {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showDeleted, setShowDeleted] = useState(false);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
return (cards.data ?? [])
|
||||
.filter((c) => showDeleted || !c.deleted)
|
||||
.filter((c) => !q || c.name.toLowerCase().includes(q))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [cards.data, filter, showDeleted]);
|
||||
// Search by name, tags and comment.
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
(cards.data ?? [])
|
||||
.filter((c) => (showDeleted || !c.deleted) && cardMatchesQuery(c, filter))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[cards.data, filter, showDeleted],
|
||||
);
|
||||
const deletedCount = cards.data?.filter((c) => c.deleted).length ?? 0;
|
||||
|
||||
return (
|
||||
@@ -27,7 +28,13 @@ export function CardsPage() {
|
||||
</a>
|
||||
</div>
|
||||
<div className="row">
|
||||
<input className="grow" type="search" placeholder="Filter by name" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
<input
|
||||
className="grow"
|
||||
type="search"
|
||||
placeholder="Search by name, tag or comment"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={showDeleted} onChange={(e) => setShowDeleted(e.target.checked)} />
|
||||
Show deleted ({deletedCount})
|
||||
@@ -45,6 +52,7 @@ export function CardsPage() {
|
||||
{CODE_TYPE_LABELS[c.codeType]}
|
||||
{c.images.length > 0 && ` · ${c.images.length} image${c.images.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
{c.tags?.length > 0 && <div className="card-tags small">{c.tags.join(' · ')}</div>}
|
||||
</div>
|
||||
{c.deleted && <span className="pill danger">deleted</span>}
|
||||
</a>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof cardFieldsSchema>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user