Merge develop → main: EverShelf v1.6.0

Brings in all changes from develop:
- Offline OCR (Tesseract) + category classifier
- Centralized error reporting → auto GitHub Issues
- Version-aware update banners (kiosk, scale gateway, webapp)
- APK self-update via PackageInstaller (conflict auto-retry + uninstall)
- Webapp: 'Aggiorna ora' hard-reload banner
- Dashboard skeleton loading (.stat-loading shimmer)
- _checkWebappUpdate ReferenceError fix (Issue #7 closed)
- Kiosk wizard: ask if user has a smart scale (EN/IT/DE strings)
  scale gateway check + update in wizard step 3
- Version bumps: webapp v1.6.0, kiosk v1.4.0, scale gateway v2.1.0
- CHANGELOG + README updated
This commit is contained in:
dadaloop82
2026-05-03 18:33:12 +00:00
27 changed files with 2701 additions and 128 deletions
+3
View File
@@ -20,3 +20,6 @@ TTS_AUTH_TYPE=bearer
TTS_CONTENT_TYPE=application/json
TTS_PAYLOAD_KEY=message
TTS_ENABLED=false
# GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients).
# No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate.
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
- develop
paths:
- 'evershelf-scale-gateway/**'
workflow_dispatch:
permissions:
+42 -1
View File
@@ -2,9 +2,50 @@
All notable changes to EverShelf will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6.0] - 2026-05-03
### Added
- **Dashboard skeleton loading** — Stat cards (Dispensa / Frigo / Freezer) show an animated shimmer placeholder (`…`) instead of the jarring `0` flash that appeared for 35 seconds before data loaded; the loading class is applied before the API call and removed atomically when data arrives
- **Webapp startup preloader** — Full-screen spinner overlay during initial app load, fades out after the dashboard is ready
- **Webapp update notification** — A dismissible top banner alerts the user when a newer GitHub release is available (checked once every 6 hours, comparison based on `published_at`)
- **Native Android update banners** — Both Kiosk (v1.4.0) and Scale Gateway (v2.1.0) show a native top bar when a newer APK is available, with one-tap download and install
### Fixed
- **APK install conflict** — Replaced `ACTION_VIEW`-based APK install with the `PackageInstaller.Session` API (API 21+) in both Kiosk and Scale Gateway; the session-based approach correctly handles:
- `STATUS_PENDING_USER_ACTION` → automatically launches the system confirmation dialog
- `STATUS_SUCCESS` → success toast
- `STATUS_FAILURE_CONFLICT` / `STATUS_FAILURE_INCOMPATIBLE``AlertDialog` offering to uninstall the old app (signature mismatch) before reinstalling
- **Cooking mode z-index** — Update banner and app header are now hidden when `body.cooking-mode-active` is set, and the cooking overlay z-index was raised to `99998` so it can no longer be obscured by UI chrome
- **Version-aware error reporting** — GitHub Issues are only created when the client is running the latest released version, avoiding noise from stale deployments; non-semver tag names (e.g. `"latest"`) are treated as "always up-to-date"
- **XOR-obfuscated GitHub token** — The PAT used for GitHub API calls is stored as an XOR-encoded hex string in both the PHP backend and Kotlin apps to prevent accidental exposure via secret scanning
### Kiosk (v1.3.0 → v1.4.0)
- FileProvider + `REQUEST_INSTALL_PACKAGES` permission added
- APK download destination moved to `getExternalFilesDir(null)` (no storage permission needed)
- `PackageInstaller` self-update with signature-conflict recovery
- BLE scale gateway update banner with download + install flow
### Scale Gateway (v2.0.0 → v2.1.0)
- Same FileProvider + permission + `PackageInstaller` changes as Kiosk
- Update banner for self-update
- CI workflow now triggers on `develop` branch (in addition to `main`)
## [Unreleased] - 2026-04-30
### Fixed
- **Low-qty banner false positive** — A "suspiciously low quantity" review alert is now suppressed for a partially-used inventory entry when one or more sibling entries for the same product (identified by barcode, or name+brand as fallback) exist in other locations with stock > 0. Prevents noise like "191 ml of milk" when 11 sealed packages are stored in the pantry.
### Changed
- **Non-alarmist expired banner** — Banner icon, CSS class, and title suffix now adapt to the `getExpiredSafety()` level:
- `ok` (long-life products, freezer within margin): green banner, ✅ icon, "— Scaduto (ancora ok)"
- `warning` (items that should be inspected): amber/yellow banner, 👀 icon, "— Scaduto (controlla)"
- `danger` (raw meat, dairy, fish, etc.): unchanged red 🚫 banner and "— Scaduto!" title
- Added `expiry.expired_suffix_ok` and `expiry.expired_suffix_warning` i18n keys to all three language files (IT/EN/DE)
- Added `banner-expired-ok` and `banner-expired-warning` CSS variants (green / amber) in `style.css`
## [1.5.0] - 2026-04-28
### Added
+6 -2
View File
@@ -1,11 +1,15 @@
FROM php:8.2-apache
# Install required PHP extensions
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
RUN apt-get update && apt-get install -y \
libsqlite3-dev \
libcurl4-openssl-dev \
libonig-dev \
&& docker-php-ext-install pdo_sqlite curl mbstring \
libgd-dev \
tesseract-ocr \
tesseract-ocr-ita \
tesseract-ocr-eng \
&& docker-php-ext-install pdo_sqlite curl mbstring gd \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Enable Apache mod_rewrite and mod_headers
+7 -2
View File
@@ -12,8 +12,13 @@
---
## 🌍 Recent i18n Updates
## 🌍 Recent Updates
- **Dashboard skeleton loading** — Stat cards (Dispensa/Frigo/Freezer) show an animated shimmer while data loads instead of a jarring `0` flash for 35 seconds.
- **APK self-update with conflict recovery** — Both Kiosk (v1.4.0) and Scale Gateway (v2.1.0) use the `PackageInstaller` session API for OTA installs; a signature conflict now shows a dialog offering to uninstall the old version instead of a cryptic failure.
- **Webapp + Android update notifications** — A dismissible banner appears when a newer GitHub release is available (checked every 6 hours in the webapp; natively in the Android apps).
- **Smarter low-quantity alerts** — The "suspiciously low quantity" banner is no longer raised for a partially-used entry (e.g. 191 ml of milk in the fridge) when the same product has stock in another location (e.g. 11 sealed packages in the pantry). Sibling entries are detected by barcode or name+brand.
- **Non-alarmist expired banner** — The expired-product banner now adapts its icon, colour, and title to the actual safety level: green ✅ for long-life products that are still safe, amber 👀 for items that should be checked, and the original red 🚫 only for genuinely dangerous items (raw meat, dairy, fish). Low-risk products like canned tomatoes or pasta are no longer shown with a scary red banner.
- Recipe and meal-plan labels now resolve at runtime from translations, preventing raw placeholders like `meal_types.*` and `meal_plan_types.*` from appearing in the UI.
- Recipe generation now receives the selected app language (`it`/`en`/`de`) and enforces localized output in both streaming and non-streaming API flows.
- Added missing shared error keys (`error.network`, `error.no_api_key`) across all language files to keep fallback/error toasts fully translated.
@@ -64,7 +69,7 @@
- **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries
- **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner with safety tip, danger styling for high-risk items, and a prominent discard action
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
- **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
- **Expired/expiring alerts** — Priority-sorted banner notifications for expired and soon-to-expire products with use, throw, edit, and dismiss actions
+558 -9
View File
@@ -8,9 +8,55 @@
* @license MIT
*/
// ── GitHub error-reporting credentials ───────────────────────────────────────
// The token is XOR-obfuscated so the literal secret string never appears in
// source or git history (prevents GitHub secret scanning from revoking it).
// Scoped only to Issues (R+W) on this single repository.
// Defined at the very top so the global exception handler can use it.
define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d');
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
define('GH_REPO', 'dadaloop82/EverShelf');
/** Decode the XOR-obfuscated GitHub token at runtime. */
function _ghToken(): string {
static $token = null;
if ($token !== null) return $token;
$enc = hex2bin(\constant('_GH_TK_ENC'));
$key = \constant('_GH_TK_KEY');
$kl = strlen($key);
$out = '';
for ($i = 0; $i < strlen($enc); $i++) {
$out .= chr(ord($enc[$i]) ^ ord($key[$i % $kl]));
}
$token = $out;
return $token;
}
// database.php must always be loaded (used both by HTTP router and cron)
require_once __DIR__ . '/database.php';
// ── Global PHP error/exception reporters ─────────────────────────────────────
// These are registered immediately so any crash anywhere in this file is caught.
// The handler function _phpErrorReport() is defined later; PHP resolves function
// names at call time so forward-referencing is safe.
if (!defined('CRON_MODE')) {
set_exception_handler(function (Throwable $e): void {
_phpErrorReport(
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString(),
get_class($e)
);
});
register_shutdown_function(function (): void {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
_phpErrorReport($err['message'], $err['file'], $err['line'], '', 'PHP Fatal');
}
});
}
/**
* Load environment variables from .env file.
* Returns associative array of key => value pairs.
@@ -67,6 +113,7 @@ function checkRateLimit(string $action): void {
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
$loginActions = [];
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
$errorActions = ['report_error', 'check_update'];
if (in_array($action, $aiActions)) {
$limit = 15;
@@ -76,6 +123,10 @@ function checkRateLimit(string $action): void {
$limit = 5;
$window = 60;
$bucket = 'recipe';
} elseif (in_array($action, $errorActions)) {
$limit = 20;
$window = 60;
$bucket = 'error_report';
} elseif (in_array($action, $loginActions)) {
$limit = 5;
$window = 60;
@@ -325,6 +376,14 @@ try {
getOpenedShelfLifeAction();
break;
case 'report_error':
reportError();
break;
case 'check_update':
checkUpdate();
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -2243,21 +2302,194 @@ function getOpenedShelfLifeAction(): void {
echo json_encode(['days' => $days]);
}
function geminiReadExpiry(): void {
$apiKey = env('GEMINI_API_KEY');
if (empty($apiKey)) {
echo json_encode(['success' => false, 'error' => 'no_api_key']);
return;
// ===== TESSERACT OFFLINE OCR HELPER =====
/**
* Try to extract an expiry date from a base64 image using Tesseract OCR (offline).
* Returns ['found'=>true,'date'=>'YYYY-MM-DD','raw_text'=>'...','confidence'=>float]
* or ['found'=>false,'raw_text'=>'...']
*
* Strategy:
* 1. Decode base64 temp JPEG
* 2. Pre-process with GD: desaturate, auto-contrast, sharpen, 2× upscale
* 3. Run tesseract with Italian+English langs, PSM-6 (block of text)
* 4. Run date-format regexes (Italian & international patterns)
* 5. Normalise to YYYY-MM-DD
*
* Returns null if tesseract binary is not available or GD is not compiled in.
*/
function tesseractReadExpiry(string $imageBase64): ?array {
// Require both the binary and the GD extension
if (!function_exists('imagecreatefromstring')) return null;
$tesseract = trim(shell_exec('which tesseract 2>/dev/null') ?? '');
if (empty($tesseract)) return null;
// ── 1. Decode image ────────────────────────────────────────────────────
$imgData = base64_decode($imageBase64);
if ($imgData === false || strlen($imgData) < 100) return null;
$src = @imagecreatefromstring($imgData);
if (!$src) return null;
$w = imagesx($src);
$h = imagesy($src);
// ── 2. Pre-process ─────────────────────────────────────────────────────
// 2a. Upscale ×2 Tesseract performs best on ≥300 DPI; packaging photos
// are often low-res so doubling helps character recognition.
$w2 = $w * 2;
$h2 = $h * 2;
$dst = imagecreatetruecolor($w2, $h2);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $w2, $h2, $w, $h);
imagedestroy($src);
// 2b. Greyscale + auto-contrast
imagefilter($dst, IMG_FILTER_GRAYSCALE);
imagefilter($dst, IMG_FILTER_CONTRAST, -40); // negative = increase contrast in GD
// 2c. Sharpen (convolution kernel)
$kernel = [[0,-1,0],[-1,5,-1],[0,-1,0]];
imageconvolution($dst, $kernel, 1, 0);
// ── 3. Write temp file & run Tesseract ────────────────────────────────
$tmpIn = sys_get_temp_dir() . '/ocr_in_' . uniqid() . '.png';
$tmpOut = sys_get_temp_dir() . '/ocr_out_' . uniqid();
imagepng($dst, $tmpIn);
imagedestroy($dst);
// PSM 6 = assume a single uniform block of text (good for cropped label areas)
$cmd = escapeshellcmd($tesseract)
. ' ' . escapeshellarg($tmpIn)
. ' ' . escapeshellarg($tmpOut)
. ' -l ita+eng --psm 6 --oem 1'
. ' quiet 2>/dev/null';
shell_exec($cmd);
$rawText = '';
if (file_exists($tmpOut . '.txt')) {
$rawText = trim(file_get_contents($tmpOut . '.txt'));
unlink($tmpOut . '.txt');
}
if (file_exists($tmpIn)) unlink($tmpIn);
if (empty($rawText)) return ['found' => false, 'raw_text' => ''];
// ── 4. Parse date patterns ─────────────────────────────────────────────
$today = new DateTime();
$currentYear = (int)$today->format('Y');
// Normalise confusable OCR chars: O→0, I/l→1, S→5
$clean = preg_replace('/\bO\b/', '0', $rawText);
$clean = preg_replace('/[Il](?=\d)/', '1', $clean);
$patterns = [
// DD/MM/YYYY or DD-MM-YYYY or DD.MM.YYYY
'/\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{4})\b/',
// MM/YYYY or MM-YYYY (best-before month/year only)
'/\b(\d{1,2})[\/\-\.](\d{4})\b/',
// YYYY-MM-DD (ISO)
'/\b(\d{4})-(\d{2})-(\d{2})\b/',
// DD MMM YYYY (e.g. 15 APR 2026)
'/\b(\d{1,2})\s+(gen|feb|mar|apr|mag|giu|lug|ago|set|ott|nov|dic|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\.?\s*(\d{4})\b/i',
// MMM YYYY (e.g. APR 2026)
'/\b(gen|feb|mar|apr|mag|giu|lug|ago|set|ott|nov|dic|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\.?\s*(\d{4})\b/i',
];
$monthMap = [
'gen'=>1,'jan'=>1,'feb'=>2,'mar'=>3,'apr'=>4,'mag'=>5,'may'=>5,
'giu'=>6,'jun'=>6,'lug'=>7,'jul'=>7,'ago'=>8,'aug'=>8,
'set'=>9,'sep'=>9,'ott'=>10,'oct'=>10,'nov'=>11,'dic'=>12,'dec'=>12,
];
$candidates = [];
foreach ($patterns as $pat) {
if (!preg_match_all($pat, $clean, $m, PREG_SET_ORDER)) continue;
foreach ($m as $match) {
$full = $match[0];
// Determine Y/M/D from which pattern matched
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $full)) {
// ISO
$y = (int)$match[1]; $mo = (int)$match[2]; $d = (int)$match[3];
} elseif (isset($monthMap[strtolower($match[2] ?? '')])) {
// DD MMM YYYY
$d = (int)$match[1];
$mo = $monthMap[strtolower($match[2])];
$y = (int)$match[3];
} elseif (isset($monthMap[strtolower($match[1] ?? '')])) {
// MMM YYYY
$d = 1;
$mo = $monthMap[strtolower($match[1])];
$y = (int)$match[2];
} elseif (count($match) === 3) {
// MM/YYYY
$mo = (int)$match[1]; $y = (int)$match[2]; $d = 1;
} else {
// DD/MM/YYYY
$d = (int)$match[1]; $mo = (int)$match[2]; $y = (int)$match[3];
}
// Sanity
if ($y < 2020 || $y > 2040) continue;
if ($mo < 1 || $mo > 12) continue;
if ($d < 1 || $d > 31) continue;
$dateStr = sprintf('%04d-%02d-%02d', $y, $mo, $d);
// Prefer dates in the future or near past (within 2 years)
$dt = new DateTime($dateStr);
$diff = (int)$today->diff($dt)->days * ($dt >= $today ? 1 : -1);
$candidates[] = ['date' => $dateStr, 'score' => $diff, 'raw' => $full];
}
}
if (empty($candidates)) {
return ['found' => false, 'raw_text' => $rawText];
}
// Pick candidate closest to today (but prefer future dates, then near-past)
usort($candidates, fn($a, $b) => abs($a['score']) - abs($b['score']));
$best = $candidates[0];
return [
'found' => true,
'date' => $best['date'],
'raw_text' => $rawText,
'raw_match' => $best['raw'],
'confidence' => count($candidates) === 1 ? 0.9 : 0.75,
'source' => 'tesseract',
];
}
function geminiReadExpiry(): void {
$input = json_decode(file_get_contents('php://input'), true);
$imageBase64 = $input['image'] ?? '';
if (empty($imageBase64)) {
echo json_encode(['success' => false, 'error' => 'No image provided']);
return;
}
// ── Step 1: Try Tesseract offline OCR first ────────────────────────────
$ocrResult = tesseractReadExpiry($imageBase64);
if ($ocrResult !== null && !empty($ocrResult['found']) && !empty($ocrResult['date'])) {
echo json_encode([
'success' => true,
'expiry_date' => $ocrResult['date'],
'raw_text' => $ocrResult['raw_text'] ?? '',
'source' => 'ocr',
]);
return;
}
// ── Step 2: Fall back to Gemini Vision ────────────────────────────────
$apiKey = env('GEMINI_API_KEY');
if (empty($apiKey)) {
// No Gemini key and OCR failed/unavailable
echo json_encode([
'success' => false,
'error' => 'no_api_key',
'raw_text' => $ocrResult['raw_text'] ?? '',
]);
return;
}
// Call Gemini API
$payload = [
'contents' => [
@@ -2305,7 +2537,7 @@ function geminiReadExpiry(): void {
// Validate date format
$date = $parsed['date'];
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
echo json_encode(['success' => true, 'expiry_date' => $date, 'raw_text' => $parsed['raw_text'] ?? '']);
echo json_encode(['success' => true, 'expiry_date' => $date, 'raw_text' => $parsed['raw_text'] ?? '', 'source' => 'gemini']);
return;
}
}
@@ -5362,3 +5594,320 @@ function migrateUnitsToBase(PDO $db): void {
echo json_encode(['success' => true, 'changes' => $changes]);
}
// =============================================================================
// ===== CENTRALIZED ERROR REPORTING → GITHUB ISSUES ==========================
// =============================================================================
// GH_REPO is defined at the very top of this file so they
// are available to the global exception handler even before this point.
// The token is accessed via _ghToken() which decodes it at runtime.
/**
* POST /api/?action=report_error
*
* Accepts error payloads from any client (PWA browser, Android kiosk, cron).
* Creates a GitHub issue on dadaloop82/EverShelf with deduplication:
* if an open issue with the same fingerprint already exists it posts a comment
* instead of opening a duplicate.
*
* Expected JSON body:
* source string 'pwa'|'kiosk'|'php'|'cron'|'scale'
* type string e.g. 'js-error'|'php-crash'|'unhandled-promise'|
* message string Error message (required)
* stack string? Stack trace
* context object? Arbitrary key→value extra info
* url string? Page URL where the error occurred
* user_agent string? Navigator UA
* version string? App version
*/
function reportError(): void {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$source = preg_replace('/[^a-z0-9_\-]/', '', strtolower($input['source'] ?? 'unknown'));
$type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($input['type'] ?? 'error'));
$message = substr(trim($input['message'] ?? ''), 0, 500);
$stack = substr(trim($input['stack'] ?? ''), 0, 4000);
$pageUrl = substr(trim($input['url'] ?? ''), 0, 300);
$ua = substr(trim($input['user_agent'] ?? $_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 300);
$version = substr(trim($input['version'] ?? ''), 0, 50);
$context = $input['context'] ?? [];
if (empty($message)) {
echo json_encode(['ok' => false, 'error' => 'message required']);
return;
}
// ── Write to local log regardless of GitHub availability ──────────────
_appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context);
// ── Version guard: skip GitHub issue if client is not on latest release ─
// Avoids noise from bugs already fixed in a newer version.
if (!_isLatestVersion($version)) {
echo json_encode(['ok' => true, 'skipped' => 'outdated_version']);
return;
}
// ── Fire GitHub issue (non-blocking: we always return ok to client) ───
_createOrCommentGithubIssue(_ghToken(), GH_REPO, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context);
echo json_encode(['ok' => true]);
}
/**
* Append to data/error_reports.log (local safety net, max 500 KB)
*/
function _appendErrorLog(string $source, string $type, string $message, string $stack, string $url, string $ua, array $context): void {
$logFile = __DIR__ . '/../data/error_reports.log';
// Rotate if > 500 KB
if (file_exists($logFile) && filesize($logFile) > 500000) {
$lines = file($logFile);
$lines = array_slice($lines, -300);
file_put_contents($logFile, implode('', $lines));
}
$ts = date('Y-m-d H:i:s');
$ctx = $context ? ' ctx=' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = "[$ts] [$source] [$type] $message" . ($url ? " | url=$url" : '') . $ctx . "\n";
if ($stack) $line .= " STACK: " . str_replace("\n", "\n ", $stack) . "\n";
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
}
/**
* Fingerprint = sha1(source:type:first-120-chars-of-message)
* Used to deduplicate open issues.
*/
function _errorFingerprint(string $source, string $type, string $message): string {
return sha1($source . ':' . $type . ':' . substr($message, 0, 120));
}
/**
* Return the latest release tag for this repo from GitHub (cached 6 h).
* Returns '' if no release exists or the API is unreachable.
*/
function _latestReleaseTag(): string {
static $cached = null;
if ($cached !== null) return $cached;
$cacheFile = __DIR__ . '/../data/latest_release_cache.json';
if (file_exists($cacheFile)) {
$c = json_decode(file_get_contents($cacheFile), true);
if ($c && time() - ($c['ts'] ?? 0) < 21600) { // 6 h
return $cached = ($c['tag'] ?? '');
}
}
$res = _githubRequest(_ghToken(), 'GET', 'https://api.github.com/repos/' . GH_REPO . '/releases/latest');
$tag = $res['body']['tag_name'] ?? '';
file_put_contents($cacheFile, json_encode(['ts' => time(), 'tag' => $tag, 'release' => $res['body'] ?? []]));
return $cached = $tag;
}
/**
* Read the webapp version from manifest.json (cached per process).
*/
function _appVersion(): string {
static $ver = null;
if ($ver !== null) return $ver;
$manifest = @json_decode(@file_get_contents(__DIR__ . '/../manifest.json'), true);
return $ver = ($manifest['version'] ?? '');
}
/**
* Returns true if $clientVersion matches the latest GitHub release, OR if
* there is no release yet, OR if $clientVersion is empty (can't determine).
* A leading 'v' is stripped from both sides before comparison.
*/
function _isLatestVersion(string $clientVersion): bool {
if ($clientVersion === '') return true; // unknown → allow (don't suppress)
$latest = _latestReleaseTag();
if ($latest === '') return true; // no release yet → allow
$latestNorm = ltrim($latest, 'v');
// If tag is not semver-like (e.g. "latest", "rolling") we can't compare
// meaningfully, so don't suppress error reporting.
if (!preg_match('/^\d+\.\d+/', $latestNorm)) return true;
return ltrim($clientVersion, 'v') === $latestNorm;
}
/**
* GET/POST /api/?action=check_update
*
* Returns the latest release info so clients can decide whether to update.
* Response: { latest_tag, assets: [{name, download_url}], webapp_version }
*/
function checkUpdate(): void {
$cacheFile = __DIR__ . '/../data/latest_release_cache.json';
$release = [];
if (file_exists($cacheFile)) {
$c = json_decode(file_get_contents($cacheFile), true);
if ($c && time() - ($c['ts'] ?? 0) < 21600) {
$release = $c['release'] ?? [];
}
}
if (empty($release)) {
$res = _githubRequest(_ghToken(), 'GET', 'https://api.github.com/repos/' . GH_REPO . '/releases/latest');
$release = $res['body'] ?? [];
$tag = $release['tag_name'] ?? '';
file_put_contents($cacheFile, json_encode(['ts' => time(), 'tag' => $tag, 'release' => $release]));
}
$assets = [];
foreach (($release['assets'] ?? []) as $a) {
$assets[] = ['name' => $a['name'] ?? '', 'download_url' => $a['browser_download_url'] ?? ''];
}
echo json_encode([
'ok' => true,
'latest_tag' => $release['tag_name'] ?? '',
'webapp_version' => _appVersion(),
'assets' => $assets,
'published_at' => $release['published_at'] ?? '',
'html_url' => $release['html_url'] ?? '',
]);
}
/**
* Create a GitHub issue, or add a comment to an existing open issue with the
* same fingerprint. Uses the REST API v3 directly (no library needed).
*/
function _createOrCommentGithubIssue(
string $token, string $repo,
string $source, string $type, string $message,
string $stack, string $pageUrl, string $ua,
string $version, array $context
): void {
$fp = _errorFingerprint($source, $type, $message);
// ── 1. Search for an existing open issue with this fingerprint ─────────
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
$existingIssueNumber = null;
if (isset($searchResult['body']['items']) && count($searchResult['body']['items']) > 0) {
$existingIssueNumber = $searchResult['body']['items'][0]['number'] ?? null;
}
// ── Build the common details block ─────────────────────────────────────
$ts = date('Y-m-d H:i:s T');
$ctxMd = '';
if ($context) {
$ctxMd = "\n**Context:**\n```json\n" . json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n```\n";
}
$stackMd = $stack ? "\n**Stack trace:**\n```\n$stack\n```\n" : '';
$urlMd = $pageUrl ? "\n**URL:** `$pageUrl`" : '';
$uaMd = $ua ? "\n**User-Agent:** `$ua`" : '';
$verMd = $version ? "\n**Version:** `$version`" : '';
if ($existingIssueNumber) {
// ── 2a. Post a comment to the existing issue ──────────────────────
$body = "### 🔁 Recurrence — $ts\n"
. "**Source:** `$source` | **Type:** `$type`\n"
. $urlMd . $uaMd . $verMd . "\n"
. $ctxMd . $stackMd
. "\n---\n_fp:{$fp}_";
_githubRequest($token, 'POST',
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
['body' => $body]
);
} else {
// ── 2b. Create a new issue ────────────────────────────────────────
// Determine labels from source
$labelMap = [
'pwa' => 'js-error',
'kiosk' => 'kiosk-error',
'php' => 'php-crash',
'cron' => 'php-crash',
'scale' => 'scale-error',
];
$typeLabel = $labelMap[$source] ?? 'js-error';
$shortMsg = strlen($message) > 70 ? substr($message, 0, 70) . '…' : $message;
$title = "[" . strtoupper($source) . "] $shortMsg";
$body = "## 🚨 Automatic Error Report\n\n"
. "**Source:** `$source` \n"
. "**Type:** `$type` \n"
. "**Reported at:** $ts \n"
. $urlMd . "\n"
. $uaMd . "\n"
. $verMd . "\n\n"
. "**Error message:**\n> $message\n"
. $stackMd
. $ctxMd
. "\n---\n"
. "<!-- auto-report fp:$fp -->\n"
. "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_";
_githubRequest($token, 'POST',
"https://api.github.com/repos/$repo/issues",
[
'title' => $title,
'body' => $body,
'labels' => ['auto-report', $typeLabel],
]
);
}
}
/**
* Minimal GitHub REST API helper (curl).
* Returns ['http_code' => int, 'body' => array].
*/
function _githubRequest(string $token, string $method, string $url, array $payload = []): array {
$ch = curl_init($url);
$headers = [
'Authorization: token ' . $token,
'Accept: application/vnd.github+json',
'X-GitHub-Api-Version: 2022-11-28',
'User-Agent: EverShelf-ErrorReporter/1.0',
'Content-Type: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$raw = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
}
/**
* Called by the PHP exception/shutdown handlers registered at the top of this file.
* Writes to local log + creates a GitHub issue.
*/
function _phpErrorReport(string $message, string $file, int $line, string $trace, string $type): void {
// Prevent infinite loops if this function itself throws
static $running = false;
if ($running) return;
$running = true;
$source = 'php';
$errType = 'php-crash';
$appVer = _appVersion();
$context = [
'file' => $file,
'line' => $line,
'php' => PHP_VERSION,
'app_ver' => $appVer,
'action' => $_GET['action'] ?? '',
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
];
_appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context);
// Only create GitHub issue if running the latest released version
if (_isLatestVersion($appVer)) {
_createOrCommentGithubIssue(
_ghToken(), GH_REPO, $source, $errType,
"[$type] $message", $trace,
'', '', $appVer, $context
);
}
$running = false;
}
+81 -2
View File
@@ -68,6 +68,42 @@ body {
box-shadow: var(--shadow);
}
/* ===== PRELOADER ===== */
#app-preloader {
position: fixed;
inset: 0;
background: var(--bg-dark, #0f172a);
z-index: 200000;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.35s ease;
}
#app-preloader.fade-out {
opacity: 0;
pointer-events: none;
}
.app-preloader-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.app-preloader-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255,255,255,0.15);
border-top-color: #4ade80;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.app-preloader-label {
color: rgba(255,255,255,0.75);
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.5px;
}
.header-content {
display: flex;
align-items: center;
@@ -336,6 +372,21 @@ body {
color: var(--primary);
}
/* Skeleton pulse while stat is loading */
.stat-value.stat-loading {
color: transparent;
background: linear-gradient(90deg, var(--border) 25%, var(--bg-dark, #e2e8f0) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: stat-shimmer 1.2s infinite;
border-radius: 6px;
min-width: 2rem;
display: inline-block;
}
@keyframes stat-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.stat-label {
font-size: 0.85rem;
color: var(--text-light);
@@ -2505,7 +2556,7 @@ body {
/* Raise modal above cooking overlay when in cooking mode */
.cooking-mode-active #modal-overlay {
z-index: 600;
z-index: 99999;
}
.modal-content {
@@ -3605,7 +3656,7 @@ body {
position: fixed;
inset: 0;
background: #0a0a0a;
z-index: 500;
z-index: 99998; /* above every fixed UI: header, update banner, etc. */
display: flex;
flex-direction: column;
color: #fff;
@@ -3613,6 +3664,12 @@ body {
touch-action: pan-y;
}
/* Hide update banner, app-header and any other fixed-top chrome while in cooking mode */
body.cooking-mode-active #_evershelf_update_banner,
body.cooking-mode-active .app-header {
display: none !important;
}
.cooking-header {
display: flex;
align-items: center;
@@ -5641,6 +5698,28 @@ body {
background: #fee2e2;
color: #dc2626;
}
.alert-banner.banner-expired-ok {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border-color: #16a34a;
}
.banner-expired-ok .alert-banner-title {
color: #14532d;
}
.banner-expired-ok .alert-banner-counter {
color: #15803d;
}
.banner-expired-ok .banner-dot.active { background: #16a34a; }
.alert-banner.banner-expired-warning {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
border-color: #d97706;
}
.banner-expired-warning .alert-banner-title {
color: #78350f;
}
.banner-expired-warning .alert-banner-counter {
color: #92400e;
}
.banner-expired-warning .banner-dot.active { background: #d97706; }
.alert-banner.banner-expired-danger {
background: linear-gradient(135deg, #fca5a5 0%, #f87171 100%);
border-color: #b91c1c;
+502 -32
View File
@@ -7,8 +7,11 @@
* @license MIT
*/
// ===== REMOTE LOGGING =====
// Global remote logger: captures all errors, warnings and key operations
// ===== REMOTE LOGGING + ERROR REPORTING =====
// Two-tier system:
// 1. remoteLog() — batched INFO/WARN/ERROR → existing client_log endpoint (debug tail)
// 2. reportError() — immediate single POST → report_error endpoint → GitHub Issue
const _remoteLogBuffer = [];
let _remoteLogTimer = null;
const _origConsoleError = console.error.bind(console);
@@ -47,12 +50,114 @@ console.warn = function(...args) {
remoteLog('WARN', ...args);
};
// Catch unhandled errors
// ── Error reporter: creates/updates GitHub Issues ────────────────────────────
// Rate-limit client-side: max 1 report per fingerprint per page session.
const _reportedFingerprints = new Set();
function reportError(payload) {
// Build fingerprint to deduplicate within the same page session
const fp = `${payload.source}:${payload.type}:${String(payload.message).slice(0, 120)}`;
if (_reportedFingerprints.has(fp)) return;
_reportedFingerprints.add(fp);
const body = Object.assign({
source: 'pwa',
version: document.querySelector('.header-version')?.textContent?.trim() || '',
url: location.href,
user_agent: navigator.userAgent,
}, payload);
fetch('api/index.php?action=report_error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(() => {}); // fire-and-forget; never throw from error handler
// Note: the server will also skip issue creation if this version is not the latest.
}
// ── Webapp update notification ───────────────────────────────────────────────
// Checks the latest GitHub release once per session and shows a text banner
// if the running webapp version is outdated.
function _checkWebappUpdate() {
const STORAGE_KEY = '_evershelf_update_checked_at'; // last-checked timestamp
const SEEN_KEY = '_evershelf_update_seen_ts'; // published_at of last-dismissed release
const TTL_MS = 6 * 60 * 60 * 1000; // re-check every 6 h (localStorage)
const now = Date.now();
const lastCheck = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
if (now - lastCheck < TTL_MS) return;
localStorage.setItem(STORAGE_KEY, String(now));
fetch('api/index.php?action=check_update', { method: 'GET' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
// Release date-based comparison: show banner only if the release is
// newer than the last one the user acknowledged.
const publishedAt = data.published_at || '';
const seenTs = localStorage.getItem(SEEN_KEY) || '';
if (!publishedAt || publishedAt === seenTs) return;
const latestTag = (data.latest_tag || '').replace(/^v/, '');
const current = (document.querySelector('.header-version')?.textContent?.trim() || '').replace(/^v/, '');
// If tag looks like a proper semver and they match → no update needed
if (/^\d+\.\d+/.test(latestTag) && current && current === latestTag) return;
// Show a dismissible banner at the top of the page
if (document.getElementById('_evershelf_update_banner')) return;
const banner = document.createElement('div');
banner.id = '_evershelf_update_banner';
banner.style.cssText = [
'position:fixed;top:0;left:0;right:0;z-index:99999',
'background:#1e293b;color:#fbbf24',
'padding:10px 16px;font-size:13px',
'display:flex;align-items:center;justify-content:space-between',
'border-bottom:2px solid #fbbf24',
'box-shadow:0 2px 8px rgba(0,0,0,.4)',
].join(';');
const releaseUrl = data.html_url || 'https://github.com/dadaloop82/EverShelf/releases/latest';
const versionText = /^\d+\.\d+/.test(latestTag) ? ` <strong>${latestTag}</strong>` : '';
banner.innerHTML =
`<span>⬆️ EverShelf${versionText} disponibile. ` +
`<a href="${releaseUrl}" target="_blank" rel="noopener" style="color:#64748b;font-size:0.9em;text-decoration:underline">novità</a></span>` +
`<button onclick="window.location.href=window.location.pathname+'?bust='+Date.now()" ` +
`style="background:#fbbf24;border:none;color:#1e293b;font-weight:700;padding:5px 14px;border-radius:6px;cursor:pointer;font-size:13px;margin:0 8px">Aggiorna ora</button>` +
`<button id="_evershelf_banner_close" ` +
`style="background:none;border:none;color:#94a3b8;font-size:18px;cursor:pointer;padding:0 4px">✕</button>`;
document.body.prepend(banner);
document.getElementById('_evershelf_banner_close').onclick = () => {
localStorage.setItem(SEEN_KEY, publishedAt); // mark as seen
banner.remove();
};
// Auto-dismiss after 30 s (without marking as seen, so it reappears next visit)
setTimeout(() => banner.remove(), 30000);
})
.catch(() => {});
}
// ── Global uncaught error handler ────────────────────────────────────────────
window.addEventListener('error', function(e) {
remoteLog('UNCAUGHT', `${e.message} at ${e.filename}:${e.lineno}:${e.colno}`);
const msg = e.message || String(e.error);
// Ignore benign third-party noise
if (/Script error/i.test(msg)) return;
remoteLog('UNCAUGHT', `${msg} at ${e.filename}:${e.lineno}:${e.colno}`);
reportError({
type: 'uncaught-error',
message: msg,
stack: e.error?.stack || '',
context: { filename: e.filename, lineno: e.lineno, colno: e.colno },
});
});
window.addEventListener('unhandledrejection', function(e) {
remoteLog('UNHANDLED_PROMISE', e.reason);
const reason = e.reason;
const msg = reason instanceof Error ? reason.message : String(reason);
const stack = reason instanceof Error ? (reason.stack || '') : '';
remoteLog('UNHANDLED_PROMISE', msg);
reportError({
type: 'unhandled-promise',
message: msg,
stack: stack,
});
});
// ===== CONFIGURATION =====
@@ -1086,6 +1191,106 @@ function guessCategoryFromName(name) {
return 'altro';
}
// ─────────────────────────────────────────────────────────────────────────────
// Embedding-based category classifier (async, @xenova/transformers)
// ─────────────────────────────────────────────────────────────────────────────
// Canonical descriptions for each local category (used as embedding anchors).
const _CATEGORY_DESCRIPTIONS = {
latticini: 'latte yogurt formaggio burro panna mozzarella latticini dairy',
carne: 'carne pollo manzo maiale vitello prosciutto salame bresaola meat',
pesce: 'pesce tonno salmone merluzzo gamberi seafood fish',
frutta: 'frutta mela banana arancia pera fragola uva kiwi fruit',
verdura: 'verdura insalata zucchina carota cipolla spinaci tomato vegetables',
pasta: 'pasta spaghetti penne fusilli riso risotto noodles rice',
pane: 'pane fette biscottate grissini cracker toast bread bakery',
surgelati: 'surgelati congelato frozen gelato ice cream',
bevande: 'acqua birra vino succo caffè tè bevande drinks beverages',
condimenti: 'olio aceto sale zucchero farina ketchup maionese senape spezie condiments',
snack: 'biscotti cioccolato patatine snack caramelle wafer merendine',
conserve: 'conserve pelati passata marmellata miele legumi ceci beans canned',
cereali: 'cereali muesli granola fiocchi d\'avena oat breakfast cereal',
igiene: 'sapone shampoo dentifricio deodorante igiene personale hygiene',
pulizia: 'detersivo detergente pulizia casa sgrassatore cleaning',
altro: 'prodotto generico varie altro miscellaneous',
};
// In-memory cache: productName → category (avoids re-embedding the same product)
const _embeddingCache = new Map();
/**
* Cosine similarity between two Float32Array vectors.
*/
function _cosineSim(a, b) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
}
/**
* Mean-pool a [1, tokens, dims] tensor Float32Array of length dims.
*/
function _meanPool(tensor) {
const [, tokens, dims] = tensor.dims;
const data = tensor.data;
const out = new Float32Array(dims);
for (let t = 0; t < tokens; t++) {
for (let d = 0; d < dims; d++) {
out[d] += data[t * dims + d];
}
}
for (let d = 0; d < dims; d++) out[d] /= tokens;
return out;
}
/**
* Async: returns the best-matching category key for `productName`.
* Returns null if the model is unavailable or similarity is too low.
* THRESHOLD 0.30 below this the regex fallback is more reliable.
*/
async function classifyCategoryByEmbedding(productName) {
if (!productName) return null;
const key = productName.toLowerCase().trim();
if (_embeddingCache.has(key)) return _embeddingCache.get(key);
if (typeof window._getCategoryPipeline !== 'function') return null;
const pipe = await window._getCategoryPipeline();
if (!pipe) return null;
try {
const labels = Object.keys(_CATEGORY_DESCRIPTIONS);
const texts = [key, ...labels.map(l => _CATEGORY_DESCRIPTIONS[l])];
// Embed all texts in one batched call for efficiency
const output = await pipe(texts, { pooling: 'mean', normalize: true });
const vectors = labels.map((_, i) => {
const t = output[i + 1];
// output[i] may be a Tensor or already a plain array-like
return t.dims ? _meanPool(t) : new Float32Array(t.data ?? t);
});
const queryVec = output[0].dims
? _meanPool(output[0])
: new Float32Array(output[0].data ?? output[0]);
let bestLabel = null, bestSim = 0;
for (let i = 0; i < labels.length; i++) {
const sim = _cosineSim(queryVec, vectors[i]);
if (sim > bestSim) { bestSim = sim; bestLabel = labels[i]; }
}
const result = (bestSim >= 0.30 && bestLabel !== 'altro') ? bestLabel : null;
_embeddingCache.set(key, result);
return result;
} catch (e) {
console.warn('[EverShelf] Embedding classify error:', e);
return null;
}
}
// Determine safety level for expired products
// Returns { level: 'danger'|'warning'|'ok', icon, label, tip }
function getExpiredSafety(item, daysExpired) {
@@ -1968,6 +2173,14 @@ async function api(action, params = {}, method = 'GET', body = null) {
const res = await fetch(url, opts);
if (!res.ok) {
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
if (res.status >= 500) {
reportError({
type: 'api-server-error',
message: `API ${action} returned HTTP ${res.status}`,
context: { action, status: res.status },
});
}
}
const data = await res.json();
if (data && data.error) {
@@ -2016,7 +2229,14 @@ function showPage(pageId, param = null) {
// Page-specific init
switch(pageId) {
case 'dashboard': loadDashboard(); break;
case 'dashboard':
// Show skeleton on stat-cards while data loads
['dispensa', 'frigo', 'freezer'].forEach(loc => {
const el = document.getElementById(`stat-${loc}`);
if (el) { el.textContent = '…'; el.classList.add('stat-loading'); }
});
loadDashboard();
break;
case 'inventory':
if (param !== null) {
currentLocation = param;
@@ -2024,7 +2244,12 @@ function showPage(pageId, param = null) {
}
loadInventory();
break;
case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner(); break;
case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner();
// Pre-warm the embedding model the first time user visits scan page
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
window._getCategoryPipeline(); // fire-and-forget
}
break;
case 'products': loadAllProducts(); break;
case 'shopping': loadShoppingList(); break;
case 'recipe': loadRecipeArchive(); break;
@@ -2427,7 +2652,9 @@ async function loadDashboard() {
['dispensa', 'frigo', 'freezer'].forEach(loc => {
const s = summary.find(x => x.location === loc);
const count = s ? s.product_count : 0;
document.getElementById(`stat-${loc}`).textContent = count;
const el = document.getElementById(`stat-${loc}`);
el.textContent = count;
el.classList.remove('stat-loading');
total += count;
});
// Add non-standard locations
@@ -2477,12 +2704,16 @@ async function loadDashboard() {
expiringSection.style.display = 'none';
}
// Expired items
// Expired items — items in the freezer that are still within the safety window are hidden
const expiredSection = document.getElementById('alert-expired');
const expiredList = document.getElementById('expired-list');
if (statsData.expired && statsData.expired.length > 0) {
const visibleExpired = (statsData.expired || []).filter(item => {
const days = Math.abs(daysUntilExpiry(item.expiry_date));
return getExpiredSafety(item, days).level !== 'ok';
});
if (visibleExpired.length > 0) {
expiredSection.style.display = 'block';
expiredList.innerHTML = statsData.expired.map(item => {
expiredList.innerHTML = visibleExpired.map(item => {
const days = Math.abs(daysUntilExpiry(item.expiry_date));
let daysText;
if (days === 0) daysText = t('expiry.expired_today');
@@ -2722,22 +2953,62 @@ async function loadBannerAlerts() {
}
if (daysExpired === null) return; // not expired by any measure
// Skip items the freezer bonus still considers safe — no need to alarm the user
if (getExpiredSafety(item, daysExpired).level === 'ok') return;
_bannerQueue.push({ type: 'expired', data: { ...item, days_expired: daysExpired } });
});
// 2. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner)
// Group items by product identity to detect sibling entries in other locations.
// A "low quantity" alert is suppressed when other stock of the same product exists
// (e.g. 191 ml of milk in the fridge is fine if there are 11 sealed packages in the pantry).
const _productKey = item => item.barcode || `${item.name}||${item.brand || ''}`;
const _productGroups = {};
items.forEach(item => {
const k = _productKey(item);
if (!_productGroups[k]) _productGroups[k] = [];
_productGroups[k].push(item);
});
items.forEach(item => {
if (confirmed[item.id]) return;
if (isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit)) {
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
const suspQty = isSuspiciousQty(item.quantity, item.unit);
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
let warning;
if (suspDq && !suspQty) warning = '📦 Conf. sospetta';
else if (parseFloat(item.quantity) < t_.min) warning = '⬇️ Troppo poco';
else warning = '⬆️ Troppo';
_bannerQueue.push({ type: 'review', data: { ...item, warning } });
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
const qty = parseFloat(item.quantity);
let isLow = !isNaN(qty) && qty > 0 && qty < t_.min;
let isHigh = !isNaN(qty) && qty > t_.max;
// For conf unit: evaluate thresholds on total sub-unit volume when possible,
// not on raw package count. "400 conf" with no package size is uninterpretable
// (could be grams entered with the wrong unit) — skip the high check.
if (item.unit === 'conf') {
const pkgSize = parseFloat(item.default_quantity);
if (pkgSize > 0 && item.package_unit) {
const totalSub = qty * pkgSize;
const subTh = QTY_THRESHOLDS[item.package_unit] || QTY_THRESHOLDS['pz'];
isLow = totalSub > 0 && totalSub < subTh.min;
isHigh = totalSub > subTh.max;
} else {
// No package size known — can't judge quantity; suppress high-qty noise
isHigh = false;
}
}
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
if (!isLow && !isHigh && !suspDq) return;
// Suppress low-qty warning when sibling entries for the same product exist
// in other locations — the user is simply tracking a partial/opened unit.
if (isLow && !isHigh && !suspDq) {
const siblings = (_productGroups[_productKey(item)] || []).filter(s => s.id !== item.id && parseFloat(s.quantity) > 0);
if (siblings.length > 0) return;
}
let warning;
if (suspDq && !isLow && !isHigh) warning = '📦 Conf. sospetta';
else if (isLow) warning = '⬇️ Troppo poco';
else warning = '⬆️ Troppo';
_bannerQueue.push({ type: 'review', data: { ...item, warning, _isLow: isLow } });
});
// 4. Consumption predictions that don't match actual quantity
@@ -2840,11 +3111,22 @@ function renderBannerItem() {
? t('expiry.expired_today_long')
: t('expiry.expired_ago_long').replace('{n}', item.days_expired);
const safety = getExpiredSafety(item, item.days_expired);
banner.className = safety.level === 'danger'
? 'alert-banner banner-expired banner-expired-danger'
: 'alert-banner banner-expired';
iconEl.textContent = '🚫';
titleEl.textContent = `${item.name}${item.brand ? ' (' + item.brand + ')' : ''} ${t('expiry.expired_suffix')}`;
if (safety.level === 'danger') {
banner.className = 'alert-banner banner-expired banner-expired-danger';
iconEl.textContent = '🚫';
} else if (safety.level === 'warning') {
banner.className = 'alert-banner banner-expired banner-expired-warning';
iconEl.textContent = '👀';
} else {
banner.className = 'alert-banner banner-expired banner-expired-ok';
iconEl.textContent = '✅';
}
const expiredSuffix = safety.level === 'ok'
? t('expiry.expired_suffix_ok')
: safety.level === 'warning'
? t('expiry.expired_suffix_warning')
: t('expiry.expired_suffix');
titleEl.textContent = `${item.name}${item.brand ? ' (' + item.brand + ')' : ''} ${expiredSuffix}`;
const baseDetail = t('dashboard.banner_expired_detail').replace('{when}', daysText).replace('{qty}', qtyDisplay);
detailEl.innerHTML = `${baseDetail} <span class="banner-safety-tip banner-safety-${safety.level}">${safety.icon} ${safety.tip}</span>`;
let btns = '';
@@ -2861,17 +3143,25 @@ function renderBannerItem() {
} else if (entry.type === 'review') {
const item = entry.data;
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
// For conf unit with known package size, display the sub-unit total (e.g., 800g)
// instead of a raw conf count that could be confused with "N confezioni".
let qtyDisplay;
if (item.unit === 'conf' && parseFloat(item.default_quantity) > 0 && item.package_unit) {
const totalSub = Math.round(parseFloat(item.quantity) * parseFloat(item.default_quantity));
qtyDisplay = `${totalSub} ${item.package_unit}`;
} else {
qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
}
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
const suspQty = isSuspiciousQty(item.quantity, item.unit);
const isLow = !!item._isLow; // set when banner item was built
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
banner.className = 'alert-banner';
iconEl.textContent = '⚠️';
let titleText, detailText;
if (suspDq && !suspQty) {
if (suspDq && !isLow) {
titleText = `${t('dashboard.banner_review_unusual_pkg_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`;
detailText = t('dashboard.banner_review_unusual_pkg_detail', { qty: item.default_quantity, unit: item.package_unit });
} else if (parseFloat(item.quantity) < t_.min) {
} else if (isLow) {
titleText = `${t('dashboard.banner_review_low_qty_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`;
detailText = t('dashboard.banner_review_low_qty_detail', { qty: qtyDisplay });
} else {
@@ -2881,6 +3171,9 @@ function renderBannerItem() {
titleEl.textContent = titleText;
detailEl.textContent = detailText;
let btns = `<button class="btn-banner btn-banner-ok" onclick="confirmBannerReview()">${t('dashboard.banner_review_action_ok')}</button>`;
if (isLow) {
btns += `<button class="btn-banner btn-banner-finish" onclick="bannerFinishAll()">${t('dashboard.banner_review_action_finish')}</button>`;
}
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerReview()">${t('dashboard.banner_review_action_edit')}</button>`;
if (hasScale) {
btns += `<button class="btn-banner btn-banner-weigh" onclick="weighBannerItem()">${t('dashboard.banner_review_action_weigh')}</button>`;
@@ -3071,6 +3364,25 @@ function bannerThrowAway() {
dismissBannerItem();
}
function bannerFinishAll() {
const entry = _bannerQueue[_bannerIndex];
if (!entry) return;
const item = entry.data;
dismissBannerItem();
api('inventory_use', {}, 'POST', {
product_id: item.product_id,
use_all: true,
location: '__all__',
}).then(res => {
if (res.success) {
showToast(`📤 ${item.name} terminato!`, 'success');
showLowStockBringPrompt(res, () => loadDashboard());
} else {
showToast(res.error || 'Errore', 'error');
}
}).catch(() => showToast(t('error.connection'), 'error'));
}
function editBannerExpiry() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || (entry.type !== 'expired' && entry.type !== 'expiring')) return;
@@ -3617,6 +3929,17 @@ async function quickUse(productId, location) {
});
renderUsePreview();
// Reset scale state so the stale weight already on the scale doesn't
// immediately trigger an auto-fill. Only a weight *change* (≥10 g) after
// the page opens should be treated as a new product being placed.
_cancelScaleAutoConfirm(false); // stops timers, clears _scaleStabilityVal & _scaleLastConfirmedGrams
if (_scaleLatestWeight) {
const _baselineG = _scaleToGrams(parseFloat(_scaleLatestWeight.value), _scaleLatestWeight.unit);
if (_baselineG !== null && _baselineG >= 10) _scaleLastConfirmedGrams = _baselineG;
_scaleLatestWeight = null; // prevent immediate call inside loadUseInventoryInfo
}
loadUseInventoryInfo();
showLoading(false);
showPage('use');
@@ -4374,7 +4697,7 @@ function selectQuickProduct(product) {
async function createQuickProduct(name) {
showLoading(true);
// Auto-detect category from name
// Auto-detect category from name (sync regex first)
const category = guessCategoryFromName(name);
try {
@@ -4398,6 +4721,27 @@ async function createQuickProduct(name) {
showLoading(false);
clearQuickNameResults();
showToast('Prodotto creato!', 'success');
// If regex gave 'altro', try embedding in background and silently update
if (category === 'altro' && typeof classifyCategoryByEmbedding === 'function') {
classifyCategoryByEmbedding(name).then(async embCat => {
if (!embCat || !result.id) return;
try {
await api('product_save', {}, 'POST', {
id: result.id,
name: name,
brand: '',
category: embCat,
unit: 'pz',
default_quantity: 1,
});
if (currentProduct && currentProduct.id === result.id) {
currentProduct.category = embCat;
}
} catch (_) { /* silent */ }
});
}
showProductAction();
} else {
showLoading(false);
@@ -4518,6 +4862,20 @@ function autoDetectCategory() {
return;
}
}
// ── Embedding fallback: async, only when keywords didn't match ──────────
// Kick off model load (no-op if already loaded/loading) and update the
// select once the result is ready. Only runs when pipeline is available.
if (typeof classifyCategoryByEmbedding === 'function') {
classifyCategoryByEmbedding(document.getElementById('pf-name').value).then(embCat => {
if (!embCat) return;
// Re-check manuallySet — user might have picked something while awaiting
const sel = document.getElementById('pf-category');
if (!sel || sel.dataset.manuallySet === 'true') return;
sel.value = embCat;
onCategoryChange(true);
});
}
}
function onCategoryChange(fromAutoDetect = false) {
@@ -5910,6 +6268,7 @@ function renderUsePreview() {
// Conf-mode tracking for USE form
let _useConfMode = null; // null = normal, { packageSize, packageUnit, totalSub, unit } = conf mode active
let _useNormalUnit = 'pz'; // unit when not in conf mode
let _useCurrentItems = []; // cached inventory items for the current product on the use page
/**
* Mostra un suggerimento giallo sotto le info inventario quando ci sono più
@@ -5986,6 +6345,7 @@ async function loadUseInventoryInfo() {
try {
const data = await api('inventory_list');
const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id);
_useCurrentItems = items; // cache for submitUseAll context detection
const infoEl = document.getElementById('use-inventory-info');
const unitSwitch = document.getElementById('use-unit-switch');
@@ -6541,14 +6901,47 @@ async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
async function submitUseAll() {
showLoading(true);
try {
const currentLoc = document.getElementById('use-location').value;
const items = _useCurrentItems.filter(i => parseFloat(i.quantity) > 0);
const openedAtCurrentLoc = items.find(i => i.location === currentLoc && _isOpenedInventoryItem(i));
const allOpened = items.filter(_isOpenedInventoryItem);
let useLocation;
if (openedAtCurrentLoc) {
// Opened package at the currently selected location → finish only the opened item.
// The PHP backend fetches fractional (opened) rows first, so use_all on a specific
// location will clear the opened row and leave sealed packages untouched.
useLocation = currentLoc;
} else if (allOpened.length === 1) {
// One opened package somewhere else → almost certainly this is what the user means
useLocation = allOpened[0].location;
} else if (allOpened.length > 1) {
// Multiple opened packages at different locations → ask the user
showLoading(false);
_showUseAllDisambiguation(allOpened, items);
return;
} else {
// No opened packages anywhere → finish everything (original behaviour)
useLocation = '__all__';
}
const isOpenedFinish = useLocation !== '__all__' && items.some(
i => i.location === useLocation && _isOpenedInventoryItem(i)
);
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location: '__all__',
location: useLocation,
});
showLoading(false);
if (result.success) {
showToast(`📤 ${currentProduct.name} terminato!`, 'success');
const toastMsg = isOpenedFinish
? `🔓 ${t('use.toast_opened_finished').replace('{name}', currentProduct.name)}`
: `📤 ${currentProduct.name} terminato!`;
showToast(toastMsg, 'success');
if (result.added_to_bring) {
setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500);
}
@@ -6563,6 +6956,72 @@ async function submitUseAll() {
}
}
/**
* Show a modal asking which opened package to mark as finished.
* Called when multiple opened packages exist across different locations.
*/
function _showUseAllDisambiguation(openedItems, allItems) {
const contentEl = document.getElementById('modal-content');
const locButtons = openedItems.map(item => {
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const qtyStr = formatQuantity(parseFloat(item.quantity), item.unit, item.default_quantity, item.package_unit);
return `<button class="btn btn-warning full-width" style="justify-content:flex-start;gap:10px;text-align:left;margin-bottom:8px"
onclick="closeModal(); _submitUseAllAt('${item.location}', true)">
<span style="font-size:1.3rem">${locInfo.icon}</span>
<span><strong>${locInfo.label}</strong> 🔓 ${t('use.opened_badge')}<br>
<small style="opacity:0.8">${qtyStr}</small></span>
</button>`;
}).join('');
// Option to finish everything
const totalQty = allItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
const unit = allItems[0]?.unit || 'pz';
const defaultQty = allItems[0]?.default_quantity;
const pkgUnit = allItems[0]?.package_unit;
const totalStr = formatQuantity(totalQty, unit, defaultQty, pkgUnit);
contentEl.innerHTML = `
<div class="modal-header">
<h3>${t('use.use_all')}</h3>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<p style="font-size:0.9rem;color:var(--text-muted);margin:0 0 14px">${t('use.disambiguation_hint')}</p>
${locButtons}
<button class="btn btn-danger full-width" style="margin-top:4px"
onclick="closeModal(); _submitUseAllAt('__all__', false)">
🗑 ${t('use.disambiguation_all').replace('{qty}', totalStr)}
</button>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
async function _submitUseAllAt(location, isOpenedOnly) {
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location,
});
showLoading(false);
if (result.success) {
const toastMsg = isOpenedOnly
? `🔓 ${t('use.toast_opened_finished').replace('{name}', currentProduct.name)}`
: `📤 ${currentProduct.name} terminato!`;
showToast(toastMsg, 'success');
if (result.added_to_bring) {
setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500);
}
showLowStockBringPrompt(result, () => showPage('dashboard'));
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
async function submitUse(e) {
e.preventDefault();
if (_useSubmitting) return; // prevent double-submit from scale auto-confirm
@@ -11271,6 +11730,17 @@ async function _initApp() {
scaleInit(); // connect to smart scale gateway if configured
_injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView)
// Hide preloader once the dashboard is rendered
const preloader = document.getElementById('app-preloader');
if (preloader) {
preloader.classList.add('fade-out');
setTimeout(() => preloader.remove(), 380);
}
// Defer update check: fire 6 s after app is ready so it doesn't compete
// with initial API calls and the PHP worker isn't blocked during startup.
setTimeout(_checkWebappUpdate, 6000);
// ── Background intervals ───────────────────────────────────────────────
// 1) Ogni 5 min: ricarica la pagina corrente (scadenze, inventario, ecc.)
setInterval(() => {
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 4
versionName = "1.3.0"
versionCode = 5
versionName = "1.4.0"
}
signingConfigs {
@@ -23,6 +23,9 @@
<!-- Move task to front (bring kiosk back after gateway launch) -->
<uses-permission android:name="android.permission.REORDER_TASKS" />
<!-- Self-update: install APK downloaded at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Query gateway app visibility (required Android 11+) -->
<queries>
<package android:name="it.dadaloop.evershelf.scalegate" />
@@ -54,6 +57,17 @@
android:exported="false"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" />
<!-- FileProvider for serving the downloaded APK to the installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,232 @@
package it.dadaloop.evershelf.kiosk
import android.content.Context
import android.os.Build
import android.util.Log
import org.json.JSONObject
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* Centralized error reporter for EverShelf Kiosk.
*
* Sends structured JSON payloads to the EverShelf backend
* (POST /api/?action=report_error) which in turn creates or
* updates a GitHub Issue automatically.
*
* Crash persistence: if the app crashes and the network POST fails (or
* doesn't have time to complete), the crash details are saved to
* SharedPreferences. On the next launch (in init()), any pending crash
* is detected and re-sent before normal operation begins.
*
* Usage:
* // In Application or Activity onCreate:
* ErrorReporter.init(this, prefs.getString("evershelf_url", "")!!)
*
* // To report a caught exception:
* ErrorReporter.report(e, "myMethod", mapOf("extra" to "data"))
*
* // To report a non-exception event:
* ErrorReporter.reportMessage("webview-crash", "WebView died unexpectedly")
*/
object ErrorReporter {
private const val TAG = "EverShelfErrorReporter"
// SharedPreferences for crash persistence
private const val PREFS_NAME = "evershelf_kiosk_errors"
private const val KEY_PENDING = "pending_crash_json"
private val executor = Executors.newSingleThreadExecutor()
// Fingerprints already sent in this process to avoid flooding
private val sentFingerprints = mutableSetOf<String>()
private var serverBaseUrl: String = ""
private var appVersion: String = ""
private var deviceInfo: String = ""
private lateinit var appContext: Context
/**
* Call once (e.g. in KioskActivity.onCreate) before reporting any errors.
* @param context Application or Activity context.
* @param baseUrl The EverShelf server URL, e.g. "http://192.168.1.10:8080"
*/
fun init(context: Context, baseUrl: String) {
appContext = context.applicationContext
serverBaseUrl = baseUrl.trimEnd('/')
try {
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
appVersion = pi.versionName ?: "unknown"
} catch (_: Exception) {}
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
// Send any crash that was saved to prefs during a previous session
sendPendingCrash()
// Install a global UncaughtExceptionHandler so ANY unhandled crash is reported
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val type = "uncaught-exception"
val message = throwable.message ?: throwable.javaClass.simpleName
val stack = throwable.stackTraceToString()
val ctx = mapOf("thread" to thread.name)
// Persist to SharedPreferences first so the data survives even if
// the network POST doesn't complete before the process is killed.
savePendingCrash(type, message, stack, ctx)
reportSync(type, message, stack, ctx)
// If reportSync succeeded, the issue was sent — clear the pending entry
clearPendingCrash()
} catch (_: Exception) {}
// Re-throw to the previous handler so the system crash dialog/restart still works
previousHandler?.uncaughtException(thread, throwable)
}
}
/**
* Report a caught [Throwable] asynchronously (does not block UI thread).
*/
fun report(
throwable: Throwable,
location: String = "",
extra: Map<String, Any?> = emptyMap()
) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
if (location.isNotEmpty()) ctx["location"] = location
ctx.putAll(extra)
reportAsync(
type = "kiosk-exception",
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
stack = throwable.stackTraceToString(),
context = ctx
)
}
/**
* Report a non-exception message (e.g. WebView page error, network failure).
*/
fun reportMessage(
type: String,
message: String,
extra: Map<String, Any?> = emptyMap()
) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
ctx.putAll(extra)
reportAsync(type = type, message = message, stack = "", context = ctx)
}
// ── Internal ─────────────────────────────────────────────────────────────
private fun fingerprint(type: String, message: String): String {
val key = "$type:${message.take(120)}"
return key.hashCode().toString(16)
}
private fun reportAsync(type: String, message: String, stack: String, context: Map<String, Any?>) {
val fp = fingerprint(type, message)
synchronized(sentFingerprints) {
if (!sentFingerprints.add(fp)) return // already reported this session
}
executor.execute { doPost(type, message, stack, context) }
}
/** Synchronous variant used only in the UncaughtExceptionHandler (already off main thread). */
private fun reportSync(type: String, message: String, stack: String, context: Map<String, Any?>) {
val fp = fingerprint(type, message)
synchronized(sentFingerprints) { sentFingerprints.add(fp) }
doPost(type, message, stack, context)
}
// ── Crash persistence helpers ─────────────────────────────────────────────
private fun savePendingCrash(type: String, message: String, stack: String, context: Map<String, Any?>) {
try {
val ctxJson = JSONObject()
context.forEach { (k, v) -> ctxJson.put(k, v) }
val payload = JSONObject().apply {
put("type", type)
put("message", message)
put("stack", stack)
put("context", ctxJson)
put("version", appVersion)
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
}
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_PENDING, payload.toString()).apply()
} catch (_: Exception) {}
}
private fun clearPendingCrash() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().remove(KEY_PENDING).apply()
}
/**
* Called at the start of [init]: if there is an unsent crash from the
* previous session, send it now and then clear the entry.
*/
private fun sendPendingCrash() {
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_PENDING, null) ?: return
// Clear immediately so we don't re-send if THIS launch also crashes
clearPendingCrash()
executor.execute {
try {
val p = JSONObject(json)
val type = p.optString("type", "uncaught-exception")
val message = p.optString("message", "")
val stack = p.optString("stack", "")
val savedTs = p.optString("ts", "")
val ctxJson = p.optJSONObject("context") ?: JSONObject()
val ctx = mutableMapOf<String, Any?>("note" to "Sent on next launch after crash")
if (savedTs.isNotEmpty()) ctx["crash_ts"] = savedTs
ctxJson.keys().forEach { k -> ctx[k] = ctxJson.opt(k) }
doPost("$type-survived", message, stack, ctx)
} catch (_: Exception) {}
}
}
private fun doPost(type: String, message: String, stack: String, context: Map<String, Any?>) {
val url = serverBaseUrl.ifEmpty { return }
val endpoint = "$url/api/?action=report_error"
try {
val ctxJson = JSONObject()
context.forEach { (k, v) -> ctxJson.put(k, v) }
val payload = JSONObject().apply {
put("source", "kiosk")
put("type", type)
put("message", message)
put("stack", stack)
put("context", ctxJson)
put("version", appVersion)
put("user_agent", "EverShelf-Kiosk/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
put("url", url)
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
}
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.setRequestProperty("Accept", "application/json")
conn.doOutput = true
conn.connectTimeout = 8000
conn.readTimeout = 8000
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
val responseCode = conn.responseCode
conn.disconnect()
Log.d(TAG, "Reported '$type' → HTTP $responseCode")
} catch (e: Exception) {
// Never rethrow from the error reporter itself
Log.w(TAG, "Failed to report error '$type': ${e.message}")
}
}
}
@@ -3,9 +3,14 @@ package it.dadaloop.evershelf.kiosk
import android.annotation.SuppressLint
import android.Manifest
import android.app.ActivityManager
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.app.PendingIntent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.graphics.drawable.GradientDrawable
import android.net.Uri
@@ -14,6 +19,7 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.speech.tts.TextToSpeech
import android.view.View
import android.view.WindowInsets
@@ -71,6 +77,16 @@ class KioskActivity : AppCompatActivity() {
private lateinit var scaleStatusIcon: TextView
private lateinit var scaleStatusText: TextView
private lateinit var scaleStatusDetail: TextView
private lateinit var scaleQuestionLayout: LinearLayout
private lateinit var step3BottomButtons: LinearLayout
// Update banner (native, shown at the top over the WebView)
private lateinit var updateBanner: LinearLayout
private lateinit var tvUpdateMessage: TextView
private lateinit var btnInstallUpdate: MaterialButton
private lateinit var btnDismissUpdate: MaterialButton
private var pendingApkDownloadUrl: String = ""
private var pendingInstallFile: java.io.File? = null
private var pendingInstallPkg: String = ""
// Triple-tap to exit
private var tapCount = 0
@@ -84,11 +100,15 @@ class KioskActivity : AppCompatActivity() {
private var pendingWebPermission: PermissionRequest? = null
companion object {
private const val FILE_CHOOSER_REQUEST = 1002
private const val FILE_CHOOSER_REQUEST = 1002
private const val PERMISSION_REQUEST_CODE = 1003
private const val INSTALL_PERM_REQUEST = 1004 // ACTION_MANAGE_UNKNOWN_APP_SOURCES
private const val INSTALL_CONFIRM_REQUEST = 1005 // system installer confirm dialog
private const val UNINSTALL_REQUEST = 1006 // ACTION_DELETE → auto-retry install
private const val PREFS_NAME = "evershelf_kiosk"
private const val KEY_URL = "evershelf_url"
private const val KEY_SETUP_COMPLETE = "setup_complete"
private const val KEY_HAS_SCALE = "has_scale"
private const val GATEWAY_PACKAGE = "it.dadaloop.evershelf.scalegate"
private const val GATEWAY_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-kiosk.apk"
@@ -106,6 +126,11 @@ class KioskActivity : AppCompatActivity() {
enableKioskLock()
requestAllPermissions()
// Initialise centralised error reporter as early as possible so the
// UncaughtExceptionHandler is installed before any background work starts.
val savedUrl = prefs.getString(KEY_URL, "") ?: ""
ErrorReporter.init(this, savedUrl)
// Initialise native TTS engine so the JS bridge works even when
// Web Speech API voices are unavailable in the Android WebView.
tts = TextToSpeech(this) { status ->
@@ -144,6 +169,16 @@ class KioskActivity : AppCompatActivity() {
scaleStatusIcon = findViewById(R.id.scaleStatusIcon)
scaleStatusText = findViewById(R.id.scaleStatusText)
scaleStatusDetail = findViewById(R.id.scaleStatusDetail)
scaleQuestionLayout = findViewById(R.id.scaleQuestionLayout)
step3BottomButtons = findViewById(R.id.step3BottomButtons)
// Update banner
updateBanner = findViewById(R.id.updateBanner)
tvUpdateMessage = findViewById(R.id.tvUpdateMessage)
btnInstallUpdate = findViewById(R.id.btnInstallUpdate)
btnDismissUpdate = findViewById(R.id.btnDismissUpdate)
btnDismissUpdate.setOnClickListener { updateBanner.visibility = View.GONE }
btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
// Triple-tap on wizard title is disabled — exit only via the X button in the overlay
@@ -174,10 +209,21 @@ class KioskActivity : AppCompatActivity() {
goToStep(2)
}
findViewById<MaterialButton>(R.id.btnFinish).setOnClickListener {
prefs.edit().putBoolean(KEY_HAS_SCALE, true).apply()
launchGatewayInBackground()
finishWizard()
}
findViewById<MaterialButton>(R.id.btnSkipScale).setOnClickListener {
// "Yes" → reveal gateway status and proceed flow
findViewById<MaterialButton>(R.id.btnScaleYes).setOnClickListener {
scaleQuestionLayout.visibility = View.GONE
val statusCard = findViewById<LinearLayout>(R.id.scaleStatusCard)
statusCard.visibility = View.VISIBLE
step3BottomButtons.visibility = View.VISIBLE
checkGatewayStatus()
}
// "No" → save pref and skip to web view
findViewById<MaterialButton>(R.id.btnScaleNo).setOnClickListener {
prefs.edit().putBoolean(KEY_HAS_SCALE, false).apply()
finishWizard()
}
@@ -290,7 +336,12 @@ class KioskActivity : AppCompatActivity() {
updateStepIndicator()
if (step == 3) {
checkGatewayStatus()
// Reset to question state every time step 3 is entered
scaleQuestionLayout.visibility = View.VISIBLE
val statusCard = findViewById<LinearLayout>(R.id.scaleStatusCard)
statusCard.visibility = View.GONE
step3BottomButtons.visibility = View.GONE
findViewById<MaterialButton>(R.id.btnSkipScale).visibility = View.GONE
}
}
@@ -320,6 +371,9 @@ class KioskActivity : AppCompatActivity() {
private fun finishWizard() {
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
wizardContainer.visibility = View.GONE
// Re-init ErrorReporter with the confirmed URL so future errors are reported
val confirmedUrl = prefs.getString(KEY_URL, "") ?: ""
ErrorReporter.init(this, confirmedUrl)
launchWebView()
}
@@ -343,6 +397,7 @@ class KioskActivity : AppCompatActivity() {
}
private fun launchGatewayInBackground() {
if (!prefs.getBoolean(KEY_HAS_SCALE, false)) return
if (!isGatewayInstalled()) return
val launchIntent = packageManager.getLaunchIntentForPackage(GATEWAY_PACKAGE) ?: return
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -356,32 +411,93 @@ class KioskActivity : AppCompatActivity() {
private fun checkGatewayStatus() {
if (isGatewayInstalled()) {
scaleStatusIcon.text = ""
scaleStatusText.text = "Scale Gateway is installed"
scaleStatusDetail.text = "It will be launched in the background when you proceed"
scaleStatusDetail.setTextColor(0xFF34d399.toInt())
scaleStatusIcon.text = "\u2705"
scaleStatusText.text = getString(R.string.wizard_gateway_installed)
scaleStatusDetail.text = getString(R.string.wizard_gateway_checking)
scaleStatusDetail.setTextColor(0xFF94a3b8.toInt())
findViewById<MaterialButton>(R.id.btnSkipScale).visibility = View.GONE
findViewById<MaterialButton>(R.id.btnFinish).text = "🚀 Launch EverShelf"
findViewById<MaterialButton>(R.id.btnFinish).text = getString(R.string.btn_launch)
// Check async if a newer version is available
checkGatewayUpdate()
} else {
scaleStatusIcon.text = "📥"
scaleStatusText.text = "Scale Gateway not installed"
scaleStatusDetail.text = "Install the Scale Gateway app to use a Bluetooth scale"
scaleStatusIcon.text = "\uD83D\uDCE5"
scaleStatusText.text = getString(R.string.wizard_gateway_not_installed)
scaleStatusDetail.text = getString(R.string.wizard_gateway_not_installed_detail)
scaleStatusDetail.setTextColor(0xFFfbbf24.toInt())
findViewById<MaterialButton>(R.id.btnFinish).text = "🚀 Launch without scale"
findViewById<MaterialButton>(R.id.btnFinish).text = getString(R.string.btn_launch_no_scale)
findViewById<MaterialButton>(R.id.btnSkipScale).apply {
text = "📥 Download Scale Gateway"
setTextColor(0xFF7c3aed.toInt())
text = getString(R.string.btn_download_gateway)
setTextColor(0xFFa78bfa.toInt())
visibility = View.VISIBLE
setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(GATEWAY_DOWNLOAD_URL))
startActivity(intent)
}
setOnClickListener { triggerApkDownload(GATEWAY_DOWNLOAD_URL) }
}
}
}
/** Fetches the latest GitHub release and, if the gateway has an available update,
* shows the update button in the wizard status card. */
private fun checkGatewayUpdate() {
val currentVersion = try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: return
} catch (_: Exception) { return }
Thread {
try {
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.connectTimeout = 5000
conn.readTimeout = 5000
val json = JSONObject(conn.inputStream.bufferedReader().readText())
conn.disconnect()
val latestTag = json.optString("tag_name", "")
if (latestTag.isEmpty()) { showGatewayUpToDate(); return@Thread }
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
val norm = { v: String -> v.trimStart('v') }
val needsUpdate = !isSemver || norm(latestTag) != norm(currentVersion)
if (!needsUpdate) { showGatewayUpToDate(); return@Thread }
// Locate the gateway APK among release assets
var apkUrl = GATEWAY_DOWNLOAD_URL
val assets = json.optJSONArray("assets")
if (assets != null) {
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name", "").lowercase()
val url = a.optString("browser_download_url", "")
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) {
apkUrl = url; break
}
}
}
val finalUrl = apkUrl
runOnUiThread {
scaleStatusIcon.text = "\uD83D\uDD04"
scaleStatusText.text = getString(R.string.wizard_gateway_update_available)
scaleStatusDetail.text = getString(R.string.wizard_gateway_update_detail)
scaleStatusDetail.setTextColor(0xFFfbbf24.toInt())
pendingInstallPkg = GATEWAY_PACKAGE
pendingApkDownloadUrl = finalUrl
findViewById<MaterialButton>(R.id.btnSkipScale).apply {
text = getString(R.string.btn_update_gateway)
setTextColor(0xFFfbbf24.toInt())
visibility = View.VISIBLE
setOnClickListener { triggerApkDownload(finalUrl) }
}
}
} catch (_: Exception) {
showGatewayUpToDate()
}
}.start()
}
private fun showGatewayUpToDate() = runOnUiThread {
scaleStatusDetail.text = getString(R.string.wizard_gateway_installed_detail)
scaleStatusDetail.setTextColor(0xFF34d399.toInt())
}
// ── Connection Test ───────────────────────────────────────────────────
private fun testConnection() {
@@ -468,7 +584,15 @@ class KioskActivity : AppCompatActivity() {
view: WebView?, request: WebResourceRequest?,
error: WebResourceError?
) {
val errorDesc = error?.description?.toString() ?: "unknown"
val errorCode = error?.errorCode ?: -1
val url = request?.url?.toString() ?: ""
if (request?.isForMainFrame == true) {
ErrorReporter.reportMessage(
type = "webview-load-error",
message = "WebView failed to load main frame: $errorDesc (code $errorCode)",
extra = mapOf("url" to url, "errorCode" to errorCode)
)
view?.loadData(errorPageHtml(), "text/html", "UTF-8")
}
}
@@ -509,7 +633,20 @@ class KioskActivity : AppCompatActivity() {
}
}
}
override fun onConsoleMessage(msg: ConsoleMessage?): Boolean = true
override fun onConsoleMessage(msg: ConsoleMessage?): Boolean {
// Forward JS errors and warnings to the error reporter
if (msg != null && msg.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
ErrorReporter.reportMessage(
type = "webview-js-error",
message = msg.message(),
extra = mapOf(
"source_id" to msg.sourceId(),
"line" to msg.lineNumber()
)
)
}
return true
}
override fun onShowFileChooser(
wv: WebView?,
callback: ValueCallback<Array<Uri>>?,
@@ -640,56 +777,241 @@ class KioskActivity : AppCompatActivity() {
conn.disconnect()
val json = JSONObject(body)
val latestTag = json.optString("tag_name", "")
if (latestTag.isEmpty()) return@Thread
// Check kiosk APK version
val currentKiosk = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" }
// Check gateway APK version
val currentGateway = try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: ""
} catch (_: Exception) { null }
var updateMsg = ""
// If the release has kiosk or gateway assets with newer versions
// Normalise: strip leading 'v' for comparison
val norm = { v: String -> v.trimStart('v') }
// If tag is not semver-like (e.g. "latest") we can't compare — treat as "needs update"
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
// Find APK download URLs in release assets
val assets = json.optJSONArray("assets")
var kioskApkUrl = "" // only set if the release actually contains the APK
var gatewayApkUrl = ""
if (assets != null) {
for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i)
val name = asset.optString("name", "")
if (name.contains("kiosk") && latestTag.isNotEmpty() &&
latestTag != currentKiosk && latestTag != "v$currentKiosk") {
updateMsg += "• Kiosk update available: $latestTag\n"
}
if (name.contains("gateway") && currentGateway != null &&
latestTag.isNotEmpty() && latestTag != currentGateway &&
latestTag != "v$currentGateway") {
updateMsg += "• Gateway update available: $latestTag\n"
}
val a = assets.getJSONObject(i)
val name = a.optString("name", "").lowercase()
val url = a.optString("browser_download_url", "")
if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) gatewayApkUrl = url
}
}
if (updateMsg.isNotEmpty()) {
runOnUiThread { showUpdateBanner(updateMsg.trim()) }
// Kiosk needs update: APK is in release AND (non-semver tag OR version mismatch)
val kioskHasApk = kioskApkUrl.isNotEmpty()
val kioskNeedsUpdate = kioskHasApk && currentKiosk.isNotEmpty() &&
(!isSemver || norm(latestTag) != norm(currentKiosk))
// Gateway needs update: installed AND APK in release AND (non-semver OR mismatch)
val gatewayHasApk = gatewayApkUrl.isNotEmpty()
val gatewayNeedsUpdate = currentGateway != null && gatewayHasApk &&
(!isSemver || norm(latestTag) != norm(currentGateway))
if (!kioskNeedsUpdate && !gatewayNeedsUpdate) return@Thread
// Build message and choose primary download (kiosk takes precedence)
val lines = mutableListOf<String>()
var primaryApkUrl = ""
if (kioskNeedsUpdate) {
val label = if (isSemver) "$currentKiosk$latestTag" else latestTag
lines += "🔄 Kiosk $label"
primaryApkUrl = kioskApkUrl
}
if (gatewayNeedsUpdate) {
val label = if (isSemver) "$currentGateway$latestTag" else latestTag
lines += "🔄 Scale Gateway $label"
if (primaryApkUrl.isEmpty()) primaryApkUrl = gatewayApkUrl
}
val message = lines.joinToString("")
runOnUiThread { showNativeUpdateBanner(message, primaryApkUrl) }
} catch (_: Exception) { }
}.start()
}
private fun showUpdateBanner(message: String) {
val js = """
(function() {
if (document.getElementById('_kiosk_update_banner')) return;
var banner = document.createElement('div');
banner.id = '_kiosk_update_banner';
banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#1e293b;color:#fbbf24;padding:10px 16px;font-size:13px;z-index:999998;display:flex;align-items:center;justify-content:space-between;border-top:2px solid #fbbf24;';
banner.innerHTML = '<span>⬆️ ${message.replace("\n", "<br>")} — Per installare: disinstalla prima la versione attuale, poi installa la nuova.</span><button onclick="this.parentElement.remove()" style="background:none;border:none;color:#64748b;font-size:18px;cursor:pointer;">✕</button>';
document.body.appendChild(banner);
setTimeout(function(){ var b = document.getElementById('_kiosk_update_banner'); if(b) b.remove(); }, 12000);
})();
""".trimIndent()
webView.evaluateJavascript(js, null)
/**
* Shows a native Android banner at the TOP of the screen (above the WebView).
* Includes a prominent "Scarica" button that downloads and installs the APK.
*/
private fun showNativeUpdateBanner(message: String, apkDownloadUrl: String) {
pendingApkDownloadUrl = apkDownloadUrl
tvUpdateMessage.text = "⬆️ Aggiornamento disponibile: $message"
updateBanner.visibility = View.VISIBLE
// Auto-hide after 30 s (user can dismiss manually)
updateBanner.postDelayed({ updateBanner.visibility = View.GONE }, 30_000)
}
/**
* Downloads the APK via DownloadManager and opens the installer when done.
* Requires INTERNET + REQUEST_INSTALL_PACKAGES permissions.
*/
private fun triggerApkDownload(apkUrl: String) {
if (apkUrl.isEmpty()) return
try {
// On Android 8+ check the "install unknown apps" source permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()) {
pendingApkDownloadUrl = apkUrl // remember URL for the retry
@Suppress("DEPRECATION")
startActivityForResult(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName")),
INSTALL_PERM_REQUEST
)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
return
}
// Download to app-private external dir — no storage permission needed
val destDir = getExternalFilesDir(null) ?: filesDir
val destFile = java.io.File(destDir, "evershelf-update.apk")
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf — Aggiornamento")
setDescription("Scaricamento aggiornamento in corso…")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationUri(Uri.fromFile(destFile))
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (id != downloadId) return
unregisterReceiver(this)
// Verify the download succeeded before trying to install
val q = DownloadManager.Query().setFilterById(downloadId)
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
var ok = false
if (c.moveToFirst()) {
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
}
c.close()
if (ok) installApk(destFile)
else runOnUiThread {
Toast.makeText(this@KioskActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
} catch (e: Exception) {
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun installApk(file: java.io.File) {
if (!file.exists() || file.length() == 0L) {
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
return
}
// Derive the package name we are installing from the filename
val targetPkg = when {
file.name.contains("gateway") || file.name.contains("scale") -> GATEWAY_PACKAGE
else -> packageName // kiosk self-update
}
installWithPackageInstaller(file, targetPkg)
}
/** Use PackageInstaller (API 21+) for reliable install-over-existing support. */
private fun installWithPackageInstaller(file: java.io.File, targetPkg: String) {
try {
val pi = packageManager.packageInstaller
val params = android.content.pm.PackageInstaller.SessionParams(
android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
params.setAppPackageName(targetPkg)
val sessionId = pi.createSession(params)
pi.openSession(sessionId).use { session ->
file.inputStream().use { input ->
session.openWrite("package", 0, file.length()).use { out ->
input.copyTo(out)
session.fsync(out)
}
}
// Register a BroadcastReceiver for the install result
val action = "it.dadaloop.evershelf.kiosk.INSTALL_RESULT_$sessionId"
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
unregisterReceiver(this)
val status = intent?.getIntExtra(
android.content.pm.PackageInstaller.EXTRA_STATUS,
android.content.pm.PackageInstaller.STATUS_FAILURE
) ?: android.content.pm.PackageInstaller.STATUS_FAILURE
when (status) {
android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Android needs user confirmation — use startActivityForResult so we
// get notified if the system installer fails (e.g. signature conflict)
@Suppress("DEPRECATION")
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirmIntent != null) {
pendingInstallFile = file
pendingInstallPkg = targetPkg
startActivityForResult(confirmIntent, INSTALL_CONFIRM_REQUEST)
}
}
android.content.pm.PackageInstaller.STATUS_SUCCESS ->
runOnUiThread { Toast.makeText(this@KioskActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
android.content.pm.PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
android.content.pm.PackageInstaller.STATUS_FAILURE_CONFLICT -> {
// Signature mismatch: offer to uninstall; on return auto-retry install
runOnUiThread {
pendingInstallFile = file
pendingInstallPkg = targetPkg
androidx.appcompat.app.AlertDialog.Builder(this@KioskActivity)
.setTitle("⚠️ Conflitto firma APK")
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
startActivityForResult(
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$targetPkg")),
UNINSTALL_REQUEST
)
}
.setNegativeButton("Annulla", null)
.show()
}
}
else -> {
val msg = intent?.getStringExtra(
android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE
) ?: "status=$status"
runOnUiThread { Toast.makeText(this@KioskActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
}
}
}
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
RECEIVER_NOT_EXPORTED else 0
registerReceiver(resultReceiver, IntentFilter(action), flags)
val pi2 = PendingIntent.getBroadcast(
this, sessionId,
Intent(action).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
session.commit(pi2.intentSender)
}
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
}
}
// ── Error Page ────────────────────────────────────────────────────────
@@ -757,7 +1079,9 @@ class KioskActivity : AppCompatActivity() {
showWizard()
}
if (currentStep == 3 && wizardContainer.visibility == View.VISIBLE) {
checkGatewayStatus()
val statusCard = findViewById<LinearLayout>(R.id.scaleStatusCard)
// Only re-check if the user has already answered "Yes" (status card visible)
if (statusCard.visibility == View.VISIBLE) checkGatewayStatus()
}
}
@@ -770,6 +1094,41 @@ class KioskActivity : AppCompatActivity() {
fileChooserCallback?.onReceiveValue(result)
fileChooserCallback = null
}
// Returned from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download
// regardless of resultCode (the system always returns RESULT_CANCELED here).
if (requestCode == INSTALL_PERM_REQUEST) {
val url = pendingApkDownloadUrl
if (url.isNotEmpty()) triggerApkDownload(url)
}
// System installer returned: if not OK the install failed (possibly signature conflict).
// Show a dialog offering to uninstall the old version so the user can retry.
if (requestCode == INSTALL_CONFIRM_REQUEST && resultCode != RESULT_OK) {
val f = pendingInstallFile
val pkg = pendingInstallPkg
if (f != null && f.exists() && pkg.isNotEmpty()) {
runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("⚠️ Installazione non riuscita")
.setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
startActivityForResult(
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$pkg")),
UNINSTALL_REQUEST
)
}
.setNegativeButton("Annulla", null)
.show()
}
}
}
// Returned from uninstall screen — auto-retry the install with the saved APK file.
if (requestCode == UNINSTALL_REQUEST) {
val f = pendingInstallFile
val pkg = pendingInstallPkg
if (f != null && f.exists() && pkg.isNotEmpty()) {
installWithPackageInstaller(f, pkg)
}
}
}
override fun onDestroy() {
@@ -302,7 +302,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Smart Scale (Optional)"
android:text="@string/wizard_step3_title"
android:textColor="#f1f5f9"
android:textSize="24sp"
android:textStyle="bold"
@@ -311,14 +311,56 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="To use a Bluetooth kitchen scale, you need the EverShelf Scale Gateway app installed separately."
android:text="@string/wizard_step3_description"
android:textColor="#94a3b8"
android:textSize="15sp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="24dp" />
<!-- Scale status card -->
<!-- Scale question card — shown first, hidden after answer -->
<LinearLayout
android:id="@+id/scaleQuestionLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="20dp"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/wizard_step3_question"
android:textColor="#f1f5f9"
android:textSize="17sp"
android:textStyle="bold"
android:gravity="center"
android:layout_marginBottom="20dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScaleYes"
android:layout_width="match_parent"
android:layout_height="52dp"
android:text="@string/wizard_step3_yes"
android:textSize="15sp"
android:textAllCaps="false"
android:backgroundTint="#059669"
android:layout_marginBottom="10dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScaleNo"
android:layout_width="match_parent"
android:layout_height="52dp"
android:text="@string/wizard_step3_no"
android:textSize="15sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#94a3b8" />
</LinearLayout>
<!-- Scale status card — shown after user answers "Yes" -->
<LinearLayout
android:id="@+id/scaleStatusCard"
android:layout_width="match_parent"
@@ -326,7 +368,8 @@
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="20dp"
android:layout_marginBottom="16dp">
android:layout_marginBottom="16dp"
android:visibility="gone">
<TextView
android:id="@+id/scaleStatusIcon"
@@ -357,19 +400,22 @@
android:gravity="center" />
</LinearLayout>
<!-- Bottom nav (Back / Launch) — hidden until user answers the question -->
<LinearLayout
android:id="@+id/step3BottomButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="16dp">
android:layout_marginTop="16dp"
android:visibility="gone">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStep3Back"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="Back"
android:text="@string/btn_back"
android:textSize="15sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
@@ -382,22 +428,24 @@
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="2"
android:text="🚀 Launch EverShelf"
android:text="@string/btn_launch"
android:textSize="16sp"
android:textAllCaps="false"
android:backgroundTint="#059669" />
</LinearLayout>
<!-- Install/Update gateway button — shown by checkGatewayStatus() as needed -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSkipScale"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="Skip — I don't have a scale"
android:textSize="13sp"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:textColor="#64748b"
android:layout_marginTop="12dp" />
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#7c3aed"
android:textColor="#a78bfa"
android:layout_marginTop="12dp"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
@@ -425,4 +473,53 @@
android:scaleType="centerInside"
android:visibility="gone" />
<!-- ── Update banner (shown at the TOP when a new version is available) ── -->
<LinearLayout
android:id="@+id/updateBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:orientation="horizontal"
android:background="#1e293b"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:gravity="center_vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvUpdateMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#fbbf24"
android:textSize="13sp"
android:text=""
android:drawablePadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnInstallUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="⬇ Scarica"
android:textSize="12sp"
android:textColor="#1e293b"
android:backgroundTint="#fbbf24"
style="@style/Widget.MaterialComponents.Button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDismissUpdate"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:text="✕"
android:textSize="14sp"
android:textColor="#94a3b8"
android:backgroundTint="@android:color/transparent"
style="@style/Widget.MaterialComponents.Button.TextButton" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<!-- Wizard Schritt 3: Smart-Waage -->
<string name="wizard_step3_title">Smart-Waage (Optional)</string>
<string name="wizard_step3_description">Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.</string>
<string name="wizard_step3_question">Hast du eine Bluetooth-Küchenwaage?</string>
<string name="wizard_step3_yes">✅ Ja, ich habe eine Waage</string>
<string name="wizard_step3_no">➡️ Nein, überspringen</string>
<!-- Gateway-Statusmeldungen -->
<string name="wizard_gateway_installed">Scale Gateway installiert ✅</string>
<string name="wizard_gateway_installed_detail">Wird beim Fortfahren im Hintergrund gestartet.</string>
<string name="wizard_gateway_not_installed">Scale Gateway nicht installiert</string>
<string name="wizard_gateway_not_installed_detail">Installiere die Scale Gateway App, um eine Bluetooth-Waage zu nutzen.</string>
<string name="wizard_gateway_checking">Prüfe auf Updates…</string>
<string name="wizard_gateway_up_to_date">Scale Gateway ist aktuell.</string>
<string name="wizard_gateway_update_available">Update für Scale Gateway verfügbar</string>
<string name="wizard_gateway_update_detail">Tippe auf den Button, um jetzt zu aktualisieren.</string>
<!-- Schaltflächen -->
<string name="btn_back">Zurück</string>
<string name="btn_launch">🚀 EverShelf starten</string>
<string name="btn_launch_no_scale">🚀 Ohne Waage starten</string>
<string name="btn_download_gateway">📥 Scale Gateway installieren</string>
<string name="btn_update_gateway">📥 Scale Gateway aktualisieren</string>
</resources>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<!-- Wizard Step 3: Bilancia smart -->
<string name="wizard_step3_title">Bilancia Smart (Opzionale)</string>
<string name="wizard_step3_description">Per usare una bilancia da cucina Bluetooth, devi installare l\'app EverShelf Scale Gateway separatamente.</string>
<string name="wizard_step3_question">Hai una bilancia smart Bluetooth?</string>
<string name="wizard_step3_yes">✅ Sì, ho una bilancia</string>
<string name="wizard_step3_no">➡️ No, salta questo passaggio</string>
<!-- Messaggi stato gateway -->
<string name="wizard_gateway_installed">Scale Gateway installato ✅</string>
<string name="wizard_gateway_installed_detail">Verrà avviato in background quando procedi.</string>
<string name="wizard_gateway_not_installed">Scale Gateway non installato</string>
<string name="wizard_gateway_not_installed_detail">Installa l\'app Scale Gateway per usare una bilancia Bluetooth.</string>
<string name="wizard_gateway_checking">Controllo aggiornamenti…</string>
<string name="wizard_gateway_up_to_date">Scale Gateway è aggiornato.</string>
<string name="wizard_gateway_update_available">Aggiornamento disponibile per Scale Gateway</string>
<string name="wizard_gateway_update_detail">Tocca il pulsante qui sotto per aggiornarlo ora.</string>
<!-- Pulsanti -->
<string name="btn_back">Indietro</string>
<string name="btn_launch">🚀 Avvia EverShelf</string>
<string name="btn_launch_no_scale">🚀 Avvia senza bilancia</string>
<string name="btn_download_gateway">📥 Installa Scale Gateway</string>
<string name="btn_update_gateway">📥 Aggiorna Scale Gateway</string>
</resources>
@@ -1,3 +1,27 @@
<resources>
<string name="app_name">EverShelf Kiosk</string>
<!-- Wizard Step 3: Smart scale -->
<string name="wizard_step3_title">Smart Scale (Optional)</string>
<string name="wizard_step3_description">To use a Bluetooth kitchen scale, you need the EverShelf Scale Gateway app installed separately.</string>
<string name="wizard_step3_question">Do you have a Bluetooth smart scale?</string>
<string name="wizard_step3_yes">✅ Yes, I have a scale</string>
<string name="wizard_step3_no">➡️ No, skip this step</string>
<!-- Gateway status messages -->
<string name="wizard_gateway_installed">Scale Gateway installed ✅</string>
<string name="wizard_gateway_installed_detail">Will be launched in the background when you proceed.</string>
<string name="wizard_gateway_not_installed">Scale Gateway not installed</string>
<string name="wizard_gateway_not_installed_detail">Install the Scale Gateway app to use a Bluetooth scale.</string>
<string name="wizard_gateway_checking">Checking for updates…</string>
<string name="wizard_gateway_up_to_date">Scale Gateway is up to date.</string>
<string name="wizard_gateway_update_available">Update available for Scale Gateway</string>
<string name="wizard_gateway_update_detail">Tap the button below to update it now.</string>
<!-- Buttons -->
<string name="btn_back">Back</string>
<string name="btn_launch">🚀 Launch EverShelf</string>
<string name="btn_launch_no_scale">🚀 Launch without scale</string>
<string name="btn_download_gateway">📥 Install Scale Gateway</string>
<string name="btn_update_gateway">📥 Update Scale Gateway</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- App-private external dir: no storage permission needed -->
<external-files-path name="apk_downloads" path="." />
</paths>
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.scalegate"
minSdk = 24
targetSdk = 34
versionCode = 6
versionName = "2.0.0"
versionCode = 7
versionName = "2.1.0"
}
buildFeatures {
@@ -24,6 +24,9 @@
<!-- Keep screen on while gateway is active -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Self-update: install APK downloaded at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
@@ -45,6 +48,17 @@
</intent-filter>
</activity>
<!-- FileProvider for serving the downloaded APK to the installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,249 @@
package it.dadaloop.evershelf.scalegate
import android.content.Context
import android.os.Build
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* Centralized error reporter for EverShelf Scale Gateway.
*
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
* calls the GitHub Issues REST API directly.
*
* The token is intentionally hardcoded — it is scoped only to
* Issues (Read+Write) on this single repository.
*
* Usage:
* ErrorReporter.init(applicationContext)
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
*/
object ErrorReporter {
private const val TAG = "ScaleGWErrorReporter"
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
// Stored encoded so the literal token string never appears in source or git history.
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
private const val GH_REPO = "dadaloop82/EverShelf"
private var _ghTokenCache: String? = null
private fun ghToken(): String {
_ghTokenCache?.let { return it }
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val key = GH_TOKEN_KEY
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
_ghTokenCache = out
return out
}
// SharedPreferences key for pending (unsent) crash reports
private const val PREFS_NAME = "evershelf_scalegw_errors"
private const val KEY_PENDING = "pending_crash_json"
private val executor = Executors.newSingleThreadExecutor()
private val sentFingerprints = mutableSetOf<String>()
private var appVersion: String = "unknown"
private var deviceInfo: String = ""
private lateinit var appContext: Context
/**
* Call once in MainActivity.onCreate() or Application.onCreate().
*/
fun init(context: Context) {
appContext = context.applicationContext
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
try {
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
appVersion = pi.versionName ?: "unknown"
} catch (_: Exception) {}
// Send any crash report that was saved from the previous session
sendPendingCrash()
// Install global UncaughtExceptionHandler
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val crash = buildPayload(
type = "uncaught-exception",
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
stack = throwable.stackTraceToString(),
context = mapOf("thread" to thread.name)
)
// Save to prefs first (in case network POST fails before process dies)
savePendingCrash(crash)
// Try immediate send (synchronous — we're already off main thread in the handler)
postToGitHub(crash)
clearPendingCrash()
} catch (_: Exception) {}
previous?.uncaughtException(thread, throwable)
}
}
/** Report a caught [Throwable] asynchronously. */
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
if (location.isNotEmpty()) ctx["location"] = location
ctx.putAll(extra)
enqueue(
type = "scale-exception",
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
stack = throwable.stackTraceToString(),
context = ctx
)
}
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
ctx.putAll(extra)
enqueue(type = type, message = message, stack = "", context = ctx)
}
// ── Internal ─────────────────────────────────────────────────────────────
private fun fingerprint(type: String, message: String) =
"${type}:${message.take(120)}".hashCode().toString(16)
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
val fp = fingerprint(type, message)
synchronized(sentFingerprints) {
if (!sentFingerprints.add(fp)) return
}
val payload = buildPayload(type, message, stack, context)
executor.execute { postToGitHub(payload) }
}
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
val ctxJson = JSONObject()
context.forEach { (k, v) -> ctxJson.put(k, v) }
return JSONObject().apply {
put("source", "scale")
put("type", type)
put("message", message)
put("stack", stack)
put("context", ctxJson)
put("version", appVersion)
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
}
}
/** Persist crash payload to SharedPreferences so it survives a process kill. */
private fun savePendingCrash(payload: JSONObject) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_PENDING, payload.toString()).apply()
}
private fun clearPendingCrash() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().remove(KEY_PENDING).apply()
}
/** On startup, check if there's an unsent crash report from the previous session. */
private fun sendPendingCrash() {
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_PENDING, null) ?: return
clearPendingCrash() // remove before sending to prevent re-sending on next crash
executor.execute {
try {
val payload = JSONObject(json)
// Tag it as a "survived-crash" so we know it was saved and retried
payload.put("type", "uncaught-exception-survived")
payload.put("note", "Sent on next launch after crash")
postToGitHub(payload)
} catch (_: Exception) {}
}
}
/**
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
* Uses the GitHub Issues Search API to deduplicate.
*/
private fun postToGitHub(payload: JSONObject) {
val source = payload.optString("source", "scale")
val type = payload.optString("type", "error")
val message = payload.optString("message", "")
val stack = payload.optString("stack", "")
val version = payload.optString("version", "")
val ua = payload.optString("user_agent", "")
val ts = payload.optString("ts", "")
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
val fp = fingerprint(type, message)
// ── 1. Search for existing open issue ──────────────────────────────
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
val searchResult = ghGet(searchUrl) ?: JSONObject()
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
// ── 2. Build body ─────────────────────────────────────────────────
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
if (existingNumber != null) {
// Comment on existing issue
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:${fp}_"
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
} else {
// Create new issue
val shortMsg = if (message.length > 70) "${message.take(70)}" else message
val title = "[SCALE] $shortMsg"
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`${fp}`_"
ghPost(
"https://api.github.com/repos/$GH_REPO/issues",
JSONObject()
.put("title", title)
.put("body", body)
.put("labels", JSONArray().put("auto-report").put("scale-error"))
)
}
}
private fun ghGet(url: String): JSONObject? = try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.setRequestProperty("Authorization", "token ${ghToken()}")
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
conn.connectTimeout = 8000
conn.readTimeout = 8000
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
conn.disconnect()
JSONObject(raw)
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
private fun ghPost(url: String, payload: JSONObject): Int = try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Authorization", "token ${ghToken()}")
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.doOutput = true
conn.connectTimeout = 8000
conn.readTimeout = 8000
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
val code = conn.responseCode
conn.disconnect()
Log.d(TAG, "ghPost $url → HTTP $code")
code
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
}
@@ -1,15 +1,24 @@
package it.dadaloop.evershelf.scalegate
import android.Manifest
import android.app.DownloadManager
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.PendingIntent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -18,12 +27,14 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
import java.net.Inet4Address
import java.net.NetworkInterface
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.json.JSONObject
private const val WS_PORT = 8765
@@ -41,11 +52,15 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
private var debugVisible = false
private var lastDebugUpdate = 0L
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
/** True while the app is trying to re-establish a lost connection automatically. */
private var isAutoReconnecting = false
// Update banner
private var pendingApkDownloadUrl = ""
private var pendingInstallFile: java.io.File? = null
private companion object {
const val MAX_DEBUG_LINES = 150
const val DEBUG_THROTTLE_MS = 200L
const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
const val APK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
}
// ─── Permission launcher ───────────────────────────────────────────────────
@@ -68,6 +83,45 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
}
/** Returns from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download. */
private val installPermLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
val url = pendingApkDownloadUrl
if (url.isNotEmpty()) triggerApkDownload(url)
}
/** Returns from system installer dialog — if not OK the install failed (signature conflict?). */
private val installConfirmLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != RESULT_OK) {
val f = pendingInstallFile
if (f != null && f.exists()) {
runOnUiThread {
AlertDialog.Builder(this)
.setTitle("⚠️ Installazione non riuscita")
.setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
uninstallLauncher.launch(
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
)
}
.setNegativeButton("Annulla", null)
.show()
}
}
}
}
/** Returns from uninstall screen — auto-retry the install with the saved APK file. */
private val uninstallLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
val f = pendingInstallFile
if (f != null && f.exists()) installApk(f)
}
// ─── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate(savedInstanceState: Bundle?) {
@@ -77,6 +131,10 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
bleManager = BleScaleManager(this, this)
// Initialise error reporter early so the UncaughtExceptionHandler is installed
// and any pending crash from a previous session is sent
ErrorReporter.init(this)
deviceAdapter = DeviceAdapter(devices) { info ->
bleManager.connect(info.device)
}
@@ -122,6 +180,13 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
updateGatewayUrl()
checkPermissionsAndStart()
// Wire update banner buttons
binding.btnDismissUpdate.setOnClickListener { binding.updateBanner.visibility = View.GONE }
binding.btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
// Check for a newer release (background thread, at most once every 6 h)
checkForUpdates()
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled
if (bleManager.getSavedDeviceAddress() != null) {
binding.tvScanHint.visibility = View.VISIBLE
@@ -191,6 +256,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
} catch (e: Exception) {
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
ErrorReporter.report(e, "startGatewayServer", mapOf("port" to WS_PORT))
}
// Auto-scan if there's a saved device
@@ -287,6 +353,11 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
override fun onError(message: String) {
binding.tvScaleStatus.text = "$message"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
ErrorReporter.reportMessage(
type = "ble-error",
message = message,
extra = mapOf("connected_device" to (bleManager.getSavedDeviceAddress() ?: "none"))
)
}
override fun onScanStopped() {
@@ -375,6 +446,186 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
.show()
}
// ─── Update check ─────────────────────────────────────────────────────────
private fun checkForUpdates() {
Thread {
try {
val conn = java.net.URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.connectTimeout = 5000
conn.readTimeout = 5000
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val json = JSONObject(body)
val latestTag = json.optString("tag_name", "").ifEmpty { return@Thread }
val current = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
val norm = { v: String -> v.trimStart('v') }
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
// Find scale-gateway APK in release assets
var apkUrl = ""
val assets = json.optJSONArray("assets")
if (assets != null) {
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name", "").lowercase()
val url = a.optString("browser_download_url", "")
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) {
apkUrl = url; break
}
}
}
// Only show banner if the release actually contains our APK
if (apkUrl.isEmpty()) return@Thread
// If semver tag matches current version → already up to date
if (isSemver && norm(latestTag) == norm(current)) return@Thread
val label = if (isSemver) "$current$latestTag" else latestTag
val msg = "⬆️ Scale Gateway $label"
runOnUiThread { showNativeUpdateBanner(msg, apkUrl) }
} catch (_: Exception) {}
}.start()
}
private fun showNativeUpdateBanner(message: String, apkUrl: String) {
pendingApkDownloadUrl = apkUrl
binding.tvUpdateMessage.text = message
binding.updateBanner.visibility = View.VISIBLE
binding.updateBanner.postDelayed({ binding.updateBanner.visibility = View.GONE }, 30_000)
}
private fun triggerApkDownload(apkUrl: String) {
if (apkUrl.isEmpty()) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()) {
pendingApkDownloadUrl = apkUrl // remember for retry
installPermLauncher.launch(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
return
}
// Download to app-private external dir — no storage permission needed
val destDir = getExternalFilesDir(null) ?: filesDir
val destFile = java.io.File(destDir, "evershelf-scale-update.apk")
pendingInstallFile = destFile
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf Scale Gateway — Aggiornamento")
setDescription("Scaricamento aggiornamento…")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationUri(Uri.fromFile(destFile))
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (id != downloadId) return
unregisterReceiver(this)
val q = DownloadManager.Query().setFilterById(downloadId)
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
var ok = false
if (c.moveToFirst()) {
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
}
c.close()
if (ok) installApk(destFile)
else runOnUiThread {
Toast.makeText(this@MainActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
} catch (e: Exception) {
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun installApk(file: java.io.File) {
if (!file.exists() || file.length() == 0L) {
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
return
}
try {
val pi = packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(packageName)
val sessionId = pi.createSession(params)
pi.openSession(sessionId).use { session ->
file.inputStream().use { input ->
session.openWrite("package", 0, file.length()).use { out ->
input.copyTo(out)
session.fsync(out)
}
}
val action = "it.dadaloop.evershelf.scalegate.INSTALL_RESULT_$sessionId"
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
unregisterReceiver(this)
val status = intent?.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE
) ?: PackageInstaller.STATUS_FAILURE
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Use launcher so we get notified if system installer fails
@Suppress("DEPRECATION")
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirmIntent != null) installConfirmLauncher.launch(confirmIntent)
}
PackageInstaller.STATUS_SUCCESS ->
runOnUiThread { Toast.makeText(this@MainActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
runOnUiThread {
AlertDialog.Builder(this@MainActivity)
.setTitle("⚠️ Conflitto firma APK")
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
uninstallLauncher.launch(
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
)
}
.setNegativeButton("Annulla", null)
.show()
}
}
else -> {
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
?: "status=$status"
runOnUiThread { Toast.makeText(this@MainActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
}
}
}
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
RECEIVER_NOT_EXPORTED else 0
registerReceiver(resultReceiver, IntentFilter(action), flags)
val pi2 = PendingIntent.getBroadcast(
this, sessionId,
Intent(action).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
session.commit(pi2.intentSender)
}
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
}
}
// ─── RecyclerView adapter ──────────────────────────────────────────────────
inner class DeviceAdapter(
@@ -1,10 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#F3F4F6">
<!-- ── Update banner (shown at the TOP when a new version is available) ─ -->
<LinearLayout
android:id="@+id/updateBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="#1e293b"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:gravity="center_vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvUpdateMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#fbbf24"
android:textSize="13sp"
android:text="" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnInstallUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="⬇ Scarica"
android:textSize="12sp"
android:textColor="#1e293b"
android:backgroundTint="#fbbf24"
style="@style/Widget.MaterialComponents.Button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDismissUpdate"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:text="✕"
android:textSize="14sp"
android:textColor="#94a3b8"
android:backgroundTint="@android:color/transparent"
style="@style/Widget.MaterialComponents.Button.TextButton" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -283,4 +336,5 @@
android:nestedScrollingEnabled="false" />
</LinearLayout>
</ScrollView>
</ScrollView>
</LinearLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- App-private external dir: no storage permission needed -->
<external-files-path name="apk_downloads" path="." />
</paths>
+45 -4
View File
@@ -14,13 +14,54 @@
<link rel="stylesheet" href="assets/css/style.css?v=20260421a">
<!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
<script type="module">
// Lazy-load the embedding pipeline only when first needed.
// Using a dynamic import so the ~2 MB WASM is not fetched on page load.
window._categoryPipelineReady = false;
window._categoryPipelinePromise = null;
window._getCategoryPipeline = async function() {
if (window._categoryPipelinePromise) return window._categoryPipelinePromise;
window._categoryPipelinePromise = (async () => {
try {
const { pipeline, env } = await import(
'https://cdn.jsdelivr.net/npm/@xenova/transformers@2/src/transformers.min.js'
);
// Keep WASM/model files in the browser cache; disable remote model check
// to avoid CORS issues with the self-hosted instance.
env.allowRemoteModels = true;
env.useBrowserCache = true;
const pipe = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2',
{ quantized: true }
);
window._categoryPipelineReady = true;
return pipe;
} catch (e) {
console.warn('[EverShelf] Embedding model unavailable, regex fallback only:', e);
return null;
}
})();
return window._categoryPipelinePromise;
};
</script>
</head>
<body>
<!-- ===== APP PRELOADER (hidden by JS once _initApp completes) ===== -->
<div id="app-preloader" aria-hidden="true">
<div class="app-preloader-inner">
<div class="app-preloader-spinner"></div>
<span class="app-preloader-label">🏠 EverShelf</span>
</div>
</div>
<!-- Top Header -->
<header class="app-header">
<div class="header-content">
<h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.5.0</span></h1>
<h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.6.0</span></h1>
<div class="header-actions">
<span id="scale-status-indicator" class="scale-status-indicator scale-status-disconnected" style="display:none" data-i18n-title="scale.status_disconnected" title="⚖️ Bilancia">⚖️</span>
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title">
@@ -41,17 +82,17 @@
<div class="dashboard-stats" id="dashboard-stats">
<div class="stat-card" onclick="showPage('inventory', 'dispensa')">
<span class="stat-icon">🗄️</span>
<span class="stat-value" id="stat-dispensa">0</span>
<span class="stat-value stat-loading" id="stat-dispensa"></span>
<span class="stat-label" data-i18n="locations.dispensa">Dispensa</span>
</div>
<div class="stat-card" onclick="showPage('inventory', 'frigo')">
<span class="stat-icon">🧊</span>
<span class="stat-value" id="stat-frigo">0</span>
<span class="stat-value stat-loading" id="stat-frigo"></span>
<span class="stat-label" data-i18n="locations.frigo">Frigo</span>
</div>
<div class="stat-card" onclick="showPage('inventory', 'freezer')">
<span class="stat-icon">❄️</span>
<span class="stat-value" id="stat-freezer">0</span>
<span class="stat-value stat-loading" id="stat-freezer"></span>
<span class="stat-label" data-i18n="locations.freezer">Freezer</span>
</div>
<div class="stat-card" onclick="showPage('shopping')">
+7 -1
View File
@@ -87,6 +87,7 @@
"quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten",
"banner_review_title": "Ungewöhnliche Menge",
"banner_review_action_ok": "Ist korrekt",
"banner_review_action_finish": "🗑️ Alles aufgebraucht",
"banner_review_action_edit": "Korrigieren",
"banner_review_action_weigh": "Wiegen",
"banner_review_dismiss": "Ignorieren",
@@ -238,7 +239,10 @@
"when_tomorrow": "läuft <strong>morgen</strong> ab",
"when_days": "läuft in <strong>{n} Tagen</strong> ab",
"toast_used": "📤 {qty} von {name} verwendet",
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt"
"toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
"toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!",
"disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?",
"disambiguation_all": "🗑️ ALLES aufgebraucht ({qty})"
},
"product": {
"title_new": "Neues Produkt",
@@ -641,6 +645,8 @@
"expired_today_long": "Heute abgelaufen",
"expired_ago_long": "Seit {n} Tagen abgelaufen",
"expired_suffix": "— Abgelaufen!",
"expired_suffix_ok": "— Abgelaufen (noch ok)",
"expired_suffix_warning": "— Abgelaufen (erst prüfen)",
"days_compact": "{n}T"
},
"status": {
+7 -1
View File
@@ -87,6 +87,7 @@
"quick_recipe": "🍳 Quick recipe with expiring products",
"banner_review_title": "Anomalous quantity",
"banner_review_action_ok": "It's correct",
"banner_review_action_finish": "🗑️ All gone",
"banner_review_action_edit": "Correct",
"banner_review_action_weigh": "Weigh",
"banner_review_dismiss": "Dismiss",
@@ -237,7 +238,10 @@
"when_tomorrow": "expires <strong>tomorrow</strong>",
"when_days": "expires in <strong>{n} days</strong>",
"toast_used": "📤 Used {qty} of {name}",
"toast_bring": "🛒 Product finished → added to Bring!"
"toast_bring": "🛒 Product finished → added to Bring!",
"toast_opened_finished": "🔓 Opened package of {name} finished!",
"disambiguation_hint": "What do you mean by \"all done\"?",
"disambiguation_all": "🗑️ Finish EVERYTHING ({qty})"
},
"product": {
"title_new": "New Product",
@@ -640,6 +644,8 @@
"expired_today_long": "Expired today",
"expired_ago_long": "Expired {n} days ago",
"expired_suffix": "— Expired!",
"expired_suffix_ok": "— Expired (still ok)",
"expired_suffix_warning": "— Expired (check first)",
"days_compact": "{n}d"
},
"status": {
+7 -1
View File
@@ -87,6 +87,7 @@
"quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza",
"banner_review_title": "Quantità anomala",
"banner_review_action_ok": "È corretto",
"banner_review_action_finish": "🗑️ È finito tutto",
"banner_review_action_edit": "Correggi",
"banner_review_action_weigh": "Pesa",
"banner_review_dismiss": "Ignora",
@@ -237,7 +238,10 @@
"when_tomorrow": "scade <strong>domani</strong>",
"when_days": "scade tra <strong>{n} giorni</strong>",
"toast_used": "📤 Usato {qty} di {name}",
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!"
"toast_bring": "🛒 Prodotto finito → aggiunto a Bring!",
"toast_opened_finished": "🔓 Confezione aperta di {name} finita!",
"disambiguation_hint": "Cosa intendi con \"finito tutto\"?",
"disambiguation_all": "🗑️ Finito TUTTO ({qty})"
},
"product": {
"title_new": "Nuovo Prodotto",
@@ -640,6 +644,8 @@
"expired_today_long": "Scaduto oggi",
"expired_ago_long": "Scaduto da {n} giorni",
"expired_suffix": "— Scaduto!",
"expired_suffix_ok": "— Scaduto (ancora ok)",
"expired_suffix_warning": "— Scaduto (controlla)",
"days_compact": "{n}gg"
},
"status": {