Chestny Znak scanner: bundle offline ZXing decoder, ЧЗ badge, session auto-push

- Native BarcodeDetector needs Google Play Services (fails "Barcode detection
  service unavailable" on de-Googled ROMs). Replace it with a bundled, fully
  offline ZXing decoder (frontend/lib/zxing.js, @zxing/library UMD): scanner uses
  BrowserDatamatrixCodeReader (TRY_HARDER) for live camera and decodeFromImageUrl
  for the photo fallback. No Google, codes never leave the device.
- Source badge "ЧЗ" (src-chzn) for products added via the scanner.
- tools/chzn_export.sh: adb push the exported session to the device Downloads so
  BalDav's file picker can import it directly.
This commit is contained in:
4aiman 2026-08-27 04:31:58 +03:00
commit 3ca2bc00c0
4 changed files with 46 additions and 33 deletions

View file

@ -1649,6 +1649,7 @@ select {
.src-kdv { background: rgba(255, 167, 38, 0.25); color: #ffb74d; }
.src-ion { background: rgba(158, 158, 158, 0.2); color: #9e9e9e; }
.src-manual { background: rgba(255, 235, 59, 0.2); color: #fff176; }
.src-chzn { background: rgba(66, 165, 245, 0.22); color: #64b5f6; }
.food-nutrients {
font-size: 11px;

View file

@ -18,6 +18,7 @@
<script src="lib/chart.min.js"></script>
<script src="lib/chartjs-plugin-zoom.min.js"></script>
<script src="lib/jszip.min.js"></script>
<script src="lib/zxing.js"></script>
<script src="js/db.js"></script>
<script src="js/bmr.js"></script>
<script src="js/tilecache.js"></script>

View file

@ -1844,15 +1844,16 @@ function showAddProductModal(onSaved, prefill) {
});
}
// Честный знак scanner: camera (BarcodeDetector) with photo/manual fallbacks.
// On a decoded DataMatrix -> ChZ.resolve -> prefill the new-product modal.
// Честный знак scanner: camera + photo/manual, decoded by bundled ZXing
// (frontend/lib/zxing.js) — fully offline, no Google Play Services (the native
// BarcodeDetector needs GMS and is absent on de-Googled ROMs). On a decoded
// DataMatrix -> ChZ.resolve -> prefill the new-product modal.
function showChznScanModal(onSaved) {
if (!ChZ.hasSession()) {
appAlert("Сессия Честного знака не импортирована.\nНастройки → Честный знак → Импорт сессии (файл chzn_session.json из tools/chzn_export.sh).");
return;
}
let hasBD = ("BarcodeDetector" in window);
let detector = hasBD ? new BarcodeDetector({ formats: ["data_matrix", "qr_code"] }) : null;
let hasZX = (typeof ZXing !== "undefined" && ZXing.BrowserDatamatrixCodeReader);
let ov = document.createElement("div");
ov.className = "modal-overlay";
@ -1874,16 +1875,28 @@ function showChznScanModal(onSaved) {
</div>`;
document.body.appendChild(ov);
let stream = null, scanning = false, busy = false;
let controls = null, busy = false;
let base = winStack.length;
let statusEl = ov.querySelector("#cz-status");
let video = ov.querySelector("#cz-video");
function cleanup() {
scanning = false;
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
let reader = null;
if (hasZX) {
let hints = new Map();
hints.set(ZXing.DecodeHintType.TRY_HARDER, true); // better on curved/low-contrast codes
reader = new ZXing.BrowserDatamatrixCodeReader(hints, { delayBetweenScanAttempts: 120 });
}
winPush("chzn-scan", () => { cleanup(); ov.remove(); });
function stopCamera() {
try { if (controls) controls.stop(); } catch {}
controls = null;
try {
let s = video && video.srcObject;
if (s && s.getTracks) s.getTracks().forEach(t => t.stop());
if (video) video.srcObject = null;
} catch {}
}
winPush("chzn-scan", () => { stopCamera(); ov.remove(); });
function close() {
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
@ -1893,38 +1906,31 @@ function showChznScanModal(onSaved) {
async function onDecoded(rawVal) {
if (busy) return;
busy = true; scanning = false;
busy = true;
try { if (controls) controls.stop(); } catch {}
statusEl.textContent = "Код распознан, запрашиваю КБЖУ...";
try {
let prefill = await ChZ.resolve(rawVal);
cleanup();
stopCamera();
close();
showAddProductModal(onSaved, prefill);
} catch (e) {
busy = false;
statusEl.textContent = (e.notFound ? "Код не найден в Честном знаке. " : "Ошибка: " + e.message + ". ") + "Наведите ещё раз.";
if (detector && stream) { scanning = true; requestAnimationFrame(loop); }
if (reader) startCamera(); // resume continuous scan
}
}
async function loop() {
if (busy || !scanning) return;
try {
let codes = await detector.detect(video);
if (codes && codes.length) { scanning = false; onDecoded(codes[0].rawValue); return; }
} catch (e) { /* transient decode error, keep scanning */ }
requestAnimationFrame(loop);
}
async function startCamera() {
if (!detector) { statusEl.textContent = "Сканер недоступен в этом WebView. Выберите фото кода или введите вручную."; return; }
if (!reader) { statusEl.textContent = "Декодер ZXing не загрузился — пересобери приложение."; return; }
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } } });
video.srcObject = stream;
await video.play();
scanning = true;
statusEl.textContent = "Наведите на код Честного знака...";
loop();
controls = await reader.decodeFromConstraints(
{ video: { facingMode: { ideal: "environment" } } },
video,
(result) => { if (result && !busy) onDecoded(result.getText()); }
// per-frame NotFoundException is normal — ignored
);
} catch (e) {
statusEl.textContent = "Камера недоступна (" + (e.name || e.message) + "). Выберите фото или введите код.";
}
@ -1932,13 +1938,16 @@ function showChznScanModal(onSaved) {
ov.querySelector("#cz-photo").onclick = () => {
pickFile("image/*", async (buf) => {
if (!detector) { statusEl.textContent = "Сканер недоступен — введите код вручную."; return; }
if (!reader) { statusEl.textContent = "Декодер ZXing не загрузился."; return; }
let url = URL.createObjectURL(new Blob([buf]));
try {
let bmp = await createImageBitmap(new Blob([buf]));
let codes = await detector.detect(bmp);
if (codes && codes.length) onDecoded(codes[0].rawValue);
else statusEl.textContent = "Код на фото не распознан. Попробуйте более резкое фото.";
} catch (e) { statusEl.textContent = "Ошибка распознавания: " + e.message; }
let result = await reader.decodeFromImageUrl(url);
onDecoded(result.getText());
} catch (e) {
statusEl.textContent = (e && e.name === "NotFoundException")
? "Код на фото не распознан. Попробуйте более резкое фото."
: "Ошибка распознавания: " + (e.message || e);
} finally { URL.revokeObjectURL(url); }
}, true);
};
ov.querySelector("#cz-manual").onclick = async () => {
@ -7434,6 +7443,7 @@ function sourceBadge(source) {
"5ka": ["5ка", "src-5ka"],
kdv: ["KDV", "src-kdv"],
ion: ["ИОН", "src-ion"],
chzn: ["ЧЗ", "src-chzn"],
manual: ["Свой", "src-manual"],
recipe_import: ["Импорт", "src-manual"],
};

1
frontend/lib/zxing.js Normal file

File diff suppressed because one or more lines are too long