Compare commits

...

50 Commits

Author SHA1 Message Date
dadaloop82 6ab1da4bd5 ci(kiosk): trigger APK build — versionName 1.7.13 fix 2026-05-16 12:51:43 +00:00
dadaloop82 1566e32a85 ci(kiosk): trigger APK build for v1.7.13 (versionName fix) 2026-05-16 12:50:59 +00:00
github-actions[bot] fe7a047656 chore: auto-merge develop → main
Triggered by: 9c285b4 fix(tts): guard getVoices() against browser extension crash (Brave anti-fingerprinting, issue #61)
2026-05-16 12:48:12 +00:00
dadaloop82 9c285b426f fix(tts): guard getVoices() against browser extension crash (Brave anti-fingerprinting, issue #61) 2026-05-16 12:46:31 +00:00
github-actions[bot] c58705f35c chore: auto-merge develop → main
Triggered by: 8d87494 fix(kiosk): versionName 1.7.2→1.7.13, versionCode 13→14 (stops false update loop)
2026-05-16 12:44:27 +00:00
dadaloop82 8d874944b5 fix(kiosk): versionName 1.7.2→1.7.13, versionCode 13→14 (stops false update loop) 2026-05-16 12:42:46 +00:00
github-actions[bot] b6f85b8e29 chore: auto-merge develop → main
Triggered by: 68693e7 fix(expiry): sealed potatoes shelf life 14→30 days (aligns with JS)
2026-05-16 12:33:04 +00:00
dadaloop82 68693e7168 fix(expiry): sealed potatoes shelf life 14→30 days (aligns with JS) 2026-05-16 12:31:26 +00:00
github-actions[bot] 84c3bb6e4c chore: auto-merge develop → main
Triggered by: d8aec91 fix(cooking): extract tools from step text as fallback for old cached recipes
2026-05-16 10:02:40 +00:00
dadaloop82 d8aec91599 fix(cooking): extract tools from step text as fallback for old cached recipes 2026-05-16 10:01:05 +00:00
github-actions[bot] 11d3209482 chore: auto-merge develop → main
Triggered by: e19c256 feat(cooking): show required tools/appliances bar in cooking mode
2026-05-16 10:00:18 +00:00
dadaloop82 e19c2564f6 feat(cooking): show required tools/appliances bar in cooking mode 2026-05-16 09:58:39 +00:00
github-actions[bot] 6c0ae6627b chore: auto-merge develop → main
Triggered by: 8928c75 feat(recipes): add tools_needed field — appliances shown as chips above ingredients
2026-05-16 09:57:43 +00:00
dadaloop82 8928c75a9d feat(recipes): add tools_needed field — appliances shown as chips above ingredients 2026-05-16 09:56:10 +00:00
dadaloop82 b09b485e80 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-16 09:36:15 +00:00
dadaloop82 9e9528054e merge: develop → main (v1.7.13 — cooking mode kiosk fix, potato shelf life, move-after-use preference) 2026-05-16 09:36:05 +00:00
github-actions[bot] 12cbcb1a29 chore: auto-merge develop → main
Triggered by: 9b9a196 fix(ux): skip move-after-use modal after 2 consistent choices; hide single-location picker
2026-05-16 09:34:22 +00:00
dadaloop82 9b9a196f73 fix(ux): skip move-after-use modal after 2 consistent choices; hide single-location picker 2026-05-16 09:32:46 +00:00
github-actions[bot] 9ce3fbcb9e chore: auto-merge develop → main
Triggered by: 3065b80 fix(expiry): potato shelf life 14→30 days in pantry; add explicit rules for onion/garlic/carrot
2026-05-16 09:26:41 +00:00
dadaloop82 3065b80370 fix(expiry): potato shelf life 14→30 days in pantry; add explicit rules for onion/garlic/carrot 2026-05-16 09:25:04 +00:00
github-actions[bot] 93acc58191 chore: auto-merge develop → main
Triggered by: d9f7755 fix(ux): hide kiosk overlay during cooking mode
2026-05-16 09:21:28 +00:00
dadaloop82 d9f775562f fix(ux): hide kiosk overlay during cooking mode 2026-05-16 09:19:51 +00:00
github-actions[bot] 85d957be2b chore: auto-merge develop → main
Triggered by: 7774fc4 docs: remove stale scale-gateway reference from README
2026-05-16 09:13:50 +00:00
dadaloop82 7774fc4cc8 docs: remove stale scale-gateway reference from README 2026-05-16 09:12:18 +00:00
dadaloop82 a0b0ed0cd7 Merge branch 'develop' 2026-05-16 09:11:31 +00:00
dadaloop82 1e831f05db ci: auto-create GitHub Release on main with version from index.html
After every develop→main merge, reads the version tag from index.html
(e.g. v1.7.13), checks if that tag already exists, and creates a new
GitHub Release if not. Body is pulled from CHANGELOG.md.

This powers the in-app update badge (`check_update` action) so
self-hosted Docker users see a notification when a new version is
available.
2026-05-16 09:10:41 +00:00
dadaloop82 855300cca1 Merge branch 'develop' 2026-05-16 08:47:06 +00:00
dadaloop82 141fca27cf docs: add Ko-fi sponsor button to README 2026-05-16 08:47:04 +00:00
dadaloop82 0ee540210a Merge branch 'develop' 2026-05-16 08:44:55 +00:00
dadaloop82 71c5b16d48 chore: fix Ko-fi username in FUNDING.yml (evershelfproject) 2026-05-16 08:44:53 +00:00
dadaloop82 5ed1fc9ac0 Merge branch 'develop' 2026-05-16 08:35:44 +00:00
dadaloop82 42149012a1 chore: remove deprecated scale-gateway app
The BLE scale gateway is fully integrated into the EverShelf Kiosk app
since v1.6.0. This standalone Android app is no longer needed or maintained.

Removal also resolves GitHub secret scanning alert #1 (legacy plain-text
GitHub PAT in ErrorReporter.kt — already revoked by GitHub automatically).
2026-05-16 08:35:36 +00:00
dadaloop82 c050ec9fa3 Merge branch 'develop' 2026-05-16 07:38:17 +00:00
dadaloop82 3cd439e068 fix(tts): filter null/undefined voices to handle Brave anti-fingerprinting
Brave browser's anti-fingerprinting user-script (makeFakeVoiceFromVoice)
intercepts the SpeechSynthesis voices array and crashes with
'undefined is not an object (evaluating Object.getPrototypeOf(voice))'
when iterating over null voice entries.

Defensive fix: filter null/undefined/no-lang entries from getVoices()
before processing, so Brave's proxy never receives invalid objects.

Fixes #58
2026-05-16 07:38:15 +00:00
dadaloop82 3430e56dfc Merge branch 'develop' 2026-05-16 07:30:36 +00:00
dadaloop82 e75b004ebc ci: trigger security scan also on security.yml changes 2026-05-16 07:30:34 +00:00
dadaloop82 f3b62ed3a1 Merge branch 'develop' 2026-05-16 07:28:33 +00:00
dadaloop82 ba5a52c5dc fix(ci): trivy-action version 0.31.0 → v0.36.0 (correct tag format) 2026-05-16 07:28:31 +00:00
dadaloop82 8366e0691d Merge branch 'develop' 2026-05-16 07:24:03 +00:00
dadaloop82 68906b2f28 fix: switch to php:8.2-apache-bookworm, add apt upgrade, Trivy ignore-unfixed
- Base image: php:8.2-apache → php:8.2-apache-bookworm (Debian 12)
  Reduces OS-level CVEs from ~1200+ to only fixable ones
- Add apt-get upgrade -y before package installs
- Trivy: add ignore-unfixed: true (suppress CVEs with no available fix)
- Pin trivy-action@0.31.0 instead of @master
- Upgrade codeql-action upload-sarif v3 → v4
2026-05-16 07:23:39 +00:00
dadaloop82 5f7d3e71ae merge: fix migration crash 'no such column: undone' (#56) 2026-05-16 07:16:03 +00:00
dadaloop82 6b982b6730 fix: migration crash 'no such column: undone' on old DBs
When migrateDB() upgraded the transactions table to add the 'waste'
CHECK constraint, the new table was created WITHOUT the 'undone' column.
The migration then tried to build idx_transactions_pid_type_undone, which
references 'undone' → PDOException SQLSTATE[HY000].

Fix:
- Add undone INTEGER DEFAULT 0 to the migration CREATE TABLE
- Replace 'INSERT INTO transactions SELECT * FROM transactions_old'
  with explicit column list (transactions_old may predate undone column)

Fixes: #56
2026-05-16 07:15:03 +00:00
dadaloop82 ef0c10ca6b merge: v1.7.14 — shelf life fix + README roadmap 2026-05-16 06:38:26 +00:00
dadaloop82 f121b8804c fix: jam/confiture opened shelf life in fridge 60→180 days
Both PHP and JS rules for opened confettura/marmellata in
section G (fridge condiments) were returning 60 days — too short.
An opened jar of jam lasts ~6 months in the fridge.

Also: update README roadmap with comprehensive, grouped view
matching the internal memory roadmap (high/medium/low/completed).

Fixes: database.php line ~412, app.js line ~1707
2026-05-16 06:38:18 +00:00
dadaloop82 bab6993e5b chore: merge develop into main (wiki English pass + v1.7.13 docs) 2026-05-16 06:33:03 +00:00
dadaloop82 80303f7900 docs(wiki): full English pass + update for v1.7.13 and built-in scale gateway
- Features.md: translate all Italian UI strings to English (chat examples,
  Avvia cottura → Start Cooking, Spiega → Explain, La quantità è giusta → correct)
- Android-Kiosk.md: translate Italian button labels (Concedi permessi →
  Grant permissions, Rileva automaticamente → Auto-discover); fix
  REQUEST_INSTALL_PACKAGES description (OTA kiosk self-updates, not scale APK);
  fix REORDER_TASKS description; add 'Header Overlay Buttons' section documenting
  the three web overlay buttons (✕ ↻ ⚙️) and the permanent native button hiding
- Scale-Gateway.md: translate Italian button labels (Cerca Bilance Bluetooth →
  Find Bluetooth Scales, Leggi bilancia → Read Scale, Disconnetti/Riconnetti →
  Disconnect/Reconnect)
- FAQ.md: translate all Italian strings (AI non disponibile → AI not available,
  Bring! non configurato, Leggi bilancia, Carica altri → Load more); replace
  outdated 'Gateway install fails' section (separate APK no longer exists for
  kiosk users) with 'Kiosk app update fails'; update ✕ button description to
  reflect the new 3-button overlay (✕ ↻ ⚙️); restore missing Getting Help section
- Home.md: update What's New v1.7.13 with complete list of changes; mark
  evershelf-scale-gateway/ as DEPRECATED in repo structure
2026-05-16 06:32:53 +00:00
dadaloop82 46ba537bec chore: merge develop into main (v1.7.13 + cooking wheel UI) 2026-05-16 06:14:03 +00:00
dadaloop82 e21b76ad7f feat(cooking): 3D wheel UI for recipe steps + cooking mode polish
- Replace flat .cooking-step-text with a perspective-based cooking wheel
  (.cooking-wheel) that shows current step, previous ghost (amber/warm)
  and next ghost (blue/cool) in a 3D card-flip layout
- CSS-only 3D: perspective 1100px, rotateX transforms for prev/next ghosts
- Smooth turn-next / turn-prev / snap animations via keyframes
- Float animation on the active step card (subtle translateY loop)
- Radial gradient glow overlay on the wheel container (CSS variable
  --wheel-glow) ready for JS tilt interaction
- prefers-reduced-motion: all animations/transitions disabled
- Mobile (<= 640px): smaller min-height and padding adjustments
- gitignore: add data/category_ai_cache.json (runtime AI cache)
2026-05-16 06:13:53 +00:00
github-actions[bot] 5f69967c7a chore: auto-merge develop → main
Triggered by: 24954cb fix: kiosk settings button position + opened-item expiry badge consistency
2026-05-16 06:11:35 +00:00
dadaloop82 24954cb893 fix: kiosk settings button position + opened-item expiry badge consistency
- kiosk: add gear button (⚙) to the left overlay (between ✕ and ↻)
  so settings are reachable from within kiosk mode without a native
  Android button. The web button calls showPage('settings').
- kiosk: permanently hide the native Android settings button via
  setNativeSettingsVisible(false) after overlay injection. Removes the
  touch bleed-through that caused the camera button tap to open kiosk
  settings instead of the scan page.
- kiosk: closeModal() no longer restores native settings visibility
  (native button is replaced, must stay hidden)
- dashboard opened-items panel: items expired by opened shelf-life but
  classified as safe by getExpiredSafety (level='ok', e.g. jam,
  condiments) now show a gentler amber 'Check soon' badge instead of
  the red  'Scaduto!' that was misleading users. Red  is now
  reserved for warning/danger safety levels only, consistent with the
  top banner which already filtered out safe-level expired items.
- header: version label corrected to v1.7.13
- translations: added expiry.badge_check_soon (it/en/de)
2026-05-16 06:10:01 +00:00
53 changed files with 560 additions and 2558 deletions
+1 -1
View File
@@ -1 +1 @@
ko_fi: dadaloop82
ko_fi: evershelfproject
+60
View File
@@ -119,3 +119,63 @@ jobs:
Triggered by: $LAST"
git push origin main
# ── Auto-create GitHub Release on main ───────────────────────────────────
# Runs after auto-merge succeeds. Reads version from index.html,
# creates a release tag vX.Y.Z if it doesn't exist yet.
# This powers the in-app update badge for self-hosted users.
create-release:
name: Create GitHub Release
needs: [auto-merge-to-main]
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from index.html
id: version
run: |
VER=$(grep -oP 'header-version">v\K[\d.]+' index.html | head -1)
echo "version=v${VER}" >> $GITHUB_OUTPUT
echo "Detected version: v${VER}"
- name: Check if tag already exists
id: tag_check
run: |
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.version }}" | grep -q .; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Read CHANGELOG entry for this version
id: changelog
if: steps.tag_check.outputs.exists == 'false'
run: |
VER="${{ steps.version.outputs.version }}"
# Extract the section for this version from CHANGELOG.md
BODY=$(awk "/^## \[?${VER#v}\]?|^## ${VER}/,/^## [0-9]/" CHANGELOG.md | head -50 | tail -n +1 | grep -v "^## [0-9]" || true)
if [ -z "$BODY" ]; then
BODY="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
fi
# Multiline output
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create release
if: steps.tag_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
name: "EverShelf ${{ steps.version.outputs.version }}"
body: ${{ steps.changelog.outputs.body }}
target_commitish: main
make_latest: true
+7 -4
View File
@@ -7,6 +7,7 @@ on:
- 'Dockerfile'
- 'docker-compose.yml'
- 'api/**'
- '.github/workflows/security.yml'
schedule:
# Run weekly on Monday at 07:00 UTC
- cron: '0 7 * * 1'
@@ -27,16 +28,17 @@ jobs:
run: docker build -t evershelf:scan .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: 'evershelf:scan'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '0' # don't fail the build, just report
- name: Upload Trivy SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-results.sarif'
category: 'trivy-docker'
@@ -52,17 +54,18 @@ jobs:
uses: actions/checkout@v4
- name: Run Trivy filesystem scanner
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '0'
- name: Upload Trivy FS SARIF
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-fs-results.sarif'
category: 'trivy-fs'
+1
View File
@@ -49,3 +49,4 @@ evershelf-kiosk/local.properties
data/error_reports.log
data/latest_release_cache.json
data/food_facts_cache.json
data/category_ai_cache.json
+2 -2
View File
@@ -1,7 +1,7 @@
FROM php:8.2-apache
FROM php:8.2-apache-bookworm
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
libsqlite3-dev \
libcurl4-openssl-dev \
libonig-dev \
+32 -5
View File
@@ -32,6 +32,8 @@
[![GitHub Discussions](https://img.shields.io/github/discussions/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/discussions)
[![CI](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml/badge.svg)](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J01ZNETZ)
---
## ✨ Features
@@ -102,7 +104,7 @@
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
- **Real-time status** — Scale connection indicator always visible in the header
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed. The standalone gateway app in [`evershelf-scale-gateway/`](evershelf-scale-gateway/) is deprecated but kept for non-kiosk use cases.
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed.
### 📺 Android Kiosk Mode (Add-on)
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
@@ -350,10 +352,35 @@ The application uses no build tools — edit files directly and refresh.
## 📋 Roadmap
- [ ] User authentication / multi-user support
- [ ] Offline mode with service worker
- [ ] Export/import inventory data
- [ ] Notification system (Telegram, email) for expiring products
### High Priority
- [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
- [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
- [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
- [ ] **Quick search / quick-add bar** — always-visible search above the nav, PWA shortcuts
### Medium Priority
- [ ] **Receipt OCR → bulk add** — photo of receipt → Gemini Vision → auto-fill inventory
- [ ] **CSV/JSON export & import** — download/upload inventory from Settings
- [ ] **Custom storage locations** — user-defined locations beyond Fridge/Freezer/Pantry
- [ ] **Multi-user support** — PIN-based user distinction, action log with user label
- [ ] **AI optimal purchase prediction** — suggest "buy X units of Y within Z days"
- [ ] **Price history sparklines** — per-product price chart from the AI cache data
### Low Priority / Nice to Have
- [ ] **Dark mode** — CSS custom properties are already structured to support it
- [ ] **Full offline mode** — Service Worker cache to show inventory read-only when server is down
- [ ] **French & Spanish translations** (`fr.json`, `es.json`)
- [ ] **Swipe actions on inventory rows** — swipe left to use/discard, right to edit
- [ ] **PHP unit tests** — PHPUnit coverage for shelf-life, price calc, and key helpers
### Completed ✅
- ✅ AI price estimation in shopping list
- ✅ Server heartbeat + offline banner
- ✅ In-app bug reporter → automatic GitHub issue creation
- ✅ Cooking mode (start, steps, 3D wheel CSS)
- ✅ Kiosk ⚙️ Settings overlay button (replaces Android native button)
- ✅ Adaptive consumption anomaly detection
- ✅ CI/CD pipeline (PHP lint, JS lint, Docker build, Trivy security scan)
---
+10 -3
View File
@@ -138,11 +138,14 @@ function migrateDB(PDO $db): void {
quantity REAL NOT NULL,
location TEXT NOT NULL DEFAULT 'dispensa',
notes TEXT DEFAULT '',
undone INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
)
");
$db->exec("INSERT INTO transactions SELECT * FROM transactions_old");
// Insert with explicit columns: transactions_old may lack 'undone' (pre-v1.7.x DB)
$db->exec("INSERT INTO transactions (id, product_id, type, quantity, location, notes, created_at)
SELECT id, product_id, type, quantity, location, notes, created_at FROM transactions_old");
$db->exec("DROP TABLE transactions_old");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)");
@@ -360,6 +363,10 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
if (preg_match('/\blatte\b/', $n)) return 1;
if (preg_match('/\bformaggio\b/', $n)) return 2;
// Root vegetables / tubers in pantry: sfusi in un sacchetto, durano 3-5 settimane
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 30;
if (preg_match('/\b(cipolla|cipolle|aglio|scalogno|porro)\b/', $n)) return 30;
if (preg_match('/\b(carota|carote)\b/', $n)) return 14;
return 60; // generic pantry fallback
}
@@ -409,7 +416,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(senape|mustard)\b/', $n)) return 90;
if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90;
if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180;
if (preg_match('/confettura|marmellata/', $n)) return 60;
if (preg_match('/confettura|marmellata/', $n)) return 180;
if (preg_match('/nutella|cioccolat/', $n)) return 60;
// ── H: Category fallbacks ────────────────────────────────────────────
@@ -467,7 +474,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
elseif (preg_match('/patata|patate/', $n)) $days = 14;
elseif (preg_match('/patata|patate/', $n)) $days = 30; // whole tubers in a bag, pantry: 3-5 weeks
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
+9 -4
View File
@@ -3020,6 +3020,7 @@ PROMPT;
'error_empty_reply' => 'Risposta vuota da Gemini',
'prompt_lang_rule' => 'IMPORTANTE: scrivi tutti i campi testuali della ricetta in Italiano.',
'prompt_step_example' => 'Passo 1…',
'tools_title' => 'Strumenti necessari',
],
'en' => [
'status_analyze_pantry' => '📦 Analyzing pantry...',
@@ -3042,6 +3043,7 @@ PROMPT;
'error_empty_reply' => 'Empty response from Gemini',
'prompt_lang_rule' => 'IMPORTANT: write all textual recipe fields in English only. Do not use Italian or German.',
'prompt_step_example' => 'Step 1…',
'tools_title' => 'Equipment needed',
],
'de' => [
'status_analyze_pantry' => '📦 Vorrat wird analysiert...',
@@ -3064,6 +3066,7 @@ PROMPT;
'error_empty_reply' => 'Leere Antwort von Gemini',
'prompt_lang_rule' => 'WICHTIG: schreibe alle textuellen Rezeptfelder nur auf Deutsch. Verwende kein Italienisch oder Englisch.',
'prompt_step_example' => 'Schritt 1…',
'tools_title' => 'Benötigte Geräte',
],
];
$text = $dict[$lang][$key] ?? $dict['it'][$key] ?? $key;
@@ -3441,14 +3444,15 @@ REGOLE:
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
DISPENSA:
$ingredientsText
Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
PROMPT;
$payload = [
@@ -4317,14 +4321,15 @@ REGOLE:
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
DISPENSA:
$ingredientsText
Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
PROMPT;
$genConfig = [
+217 -7
View File
@@ -3918,6 +3918,29 @@ body.server-offline .bottom-nav {
line-height: 1.5;
}
.recipe-tools-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
background: #f0f4ff;
border: 1px solid #c7d2fe;
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 0.85rem;
color: #3730a3;
margin-bottom: 12px;
line-height: 1.5;
}
.recipe-tool-chip {
background: #e0e7ff;
border-radius: 20px;
padding: 2px 10px;
font-size: 0.8rem;
color: #3730a3;
white-space: nowrap;
}
/* Recipe ingredient use buttons */
.recipe-ingredients {
list-style: none;
@@ -4294,14 +4317,153 @@ body.cooking-mode-active .app-header {
transform: scale(1.35);
}
.cooking-step-text {
font-size: clamp(1.4rem, 5vw, 2.2rem);
line-height: 1.5;
font-weight: 500;
color: #fff;
.cooking-wheel {
position: relative;
--wheel-tilt-x: 0deg;
--wheel-tilt-y: 0deg;
--wheel-glow: 0.45;
width: min(90vw, 680px);
max-width: 680px;
min-height: clamp(240px, 36vh, 340px);
display: flex;
align-items: center;
justify-content: center;
perspective: 1100px;
overflow: hidden;
border-radius: 24px;
background: radial-gradient(circle at 50% 50%, rgba(255,255,255,0.07), rgba(255,255,255,0.02) 58%, transparent 100%);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: inset 0 0 48px rgba(0,0,0,0.35);
touch-action: pan-y;
}
.cooking-wheel::before,
.cooking-wheel::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
}
.cooking-wheel::before {
background: radial-gradient(circle at 50% 50%, rgba(56, 189, 248, 0.18), rgba(251, 191, 36, 0.08) 36%, transparent 72%);
opacity: var(--wheel-glow);
transition: opacity 0.2s ease;
}
.cooking-wheel::after {
background: linear-gradient(180deg, rgba(0,0,0,0.34) 0%, transparent 20%, transparent 80%, rgba(0,0,0,0.34) 100%);
}
.cooking-step-text,
.cooking-step-ghost {
width: min(88vw, 620px);
text-align: center;
max-width: 560px;
width: 100%;
line-height: 1.5;
padding: 18px 20px;
border-radius: 20px;
transform-style: preserve-3d;
transition: transform 0.22s ease, opacity 0.22s ease;
}
.cooking-step-text {
position: relative;
z-index: 3;
font-size: clamp(1.35rem, 4.6vw, 2.1rem);
font-weight: 600;
color: #fff;
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.06));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 18px 35px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.24);
transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y));
animation: cookingCardFloat 4.2s ease-in-out infinite;
}
.cooking-step-ghost {
position: absolute;
left: 50%;
top: 0;
transform-origin: center center;
font-size: clamp(1.08rem, 3.9vw, 1.52rem);
font-weight: 560;
color: rgba(255,255,255,0.95);
background: linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.08));
border: 1px solid rgba(255,255,255,0.22);
box-shadow: 0 12px 28px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.22);
text-shadow: 0 1px 2px rgba(0,0,0,0.34);
opacity: 0.66;
max-height: 42%;
overflow: hidden;
text-overflow: ellipsis;
}
.cooking-step-prev {
color: rgba(255, 243, 210, 0.97);
background: linear-gradient(180deg, rgba(251, 191, 36, 0.24), rgba(251, 191, 36, 0.10));
border-color: rgba(251, 191, 36, 0.36);
transform: translateX(-50%) rotateX(56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
}
.cooking-step-next {
color: rgba(220, 243, 255, 0.97);
background: linear-gradient(180deg, rgba(56, 189, 248, 0.24), rgba(56, 189, 248, 0.10));
border-color: rgba(56, 189, 248, 0.36);
transform: translateX(-50%) rotateX(-56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
}
.cooking-step-ghost.is-empty {
opacity: 0;
}
.cooking-wheel.turn-next .cooking-step-text {
animation: cookingWheelCenterNext 0.34s ease;
}
.cooking-wheel.turn-prev .cooking-step-text {
animation: cookingWheelCenterPrev 0.34s ease;
}
.cooking-wheel.turn-next .cooking-step-prev {
animation: cookingWheelGhostNext 0.34s ease;
}
.cooking-wheel.turn-prev .cooking-step-next {
animation: cookingWheelGhostPrev 0.34s ease;
}
.cooking-wheel.snap .cooking-step-text {
animation: cookingWheelSnap 0.28s ease;
}
@keyframes cookingWheelCenterNext {
from { transform: translateY(20px) rotateX(-10deg); opacity: 0.75; }
to { transform: translateY(0) rotateX(0); opacity: 1; }
}
@keyframes cookingWheelCenterPrev {
from { transform: translateY(-20px) rotateX(10deg); opacity: 0.75; }
to { transform: translateY(0) rotateX(0); opacity: 1; }
}
@keyframes cookingWheelGhostNext {
from { opacity: 0.1; transform: translateX(-50%) rotateX(68deg) scale(0.84); }
to { opacity: 0.66; transform: translateX(-50%) rotateX(56deg) scale(0.9); }
}
@keyframes cookingWheelGhostPrev {
from { opacity: 0.1; transform: translateX(-50%) rotateX(-68deg) scale(0.84); }
to { opacity: 0.66; transform: translateX(-50%) rotateX(-56deg) scale(0.9); }
}
@keyframes cookingCardFloat {
0%, 100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(0); }
50% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(-3px); }
}
@keyframes cookingWheelSnap {
0% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(0.97); }
70% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1.018); }
100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1); }
}
.cooking-replay-btn {
@@ -4341,6 +4503,28 @@ body.cooking-mode-active .app-header {
}
.cooking-timers-bar::-webkit-scrollbar { display: none; }
.cooking-tools-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: rgba(99,102,241,0.15);
border-bottom: 1px solid rgba(99,102,241,0.25);
flex-shrink: 0;
font-size: 0.78rem;
color: #c7d2fe;
}
.cooking-tool-chip {
background: rgba(99,102,241,0.25);
border: 1px solid rgba(99,102,241,0.4);
border-radius: 20px;
padding: 2px 9px;
font-size: 0.76rem;
color: #e0e7ff;
white-space: nowrap;
}
.cooking-timer-card {
display: flex;
align-items: center;
@@ -4576,6 +4760,32 @@ body.cooking-mode-active .app-header {
background: rgba(255,255,255,0.2);
}
@media (max-width: 640px) {
.cooking-wheel {
min-height: clamp(210px, 34vh, 300px);
border-radius: 18px;
}
.cooking-step-text,
.cooking-step-ghost {
padding: 14px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.cooking-step-text,
.cooking-step-ghost,
.cooking-wheel.turn-next .cooking-step-text,
.cooking-wheel.turn-prev .cooking-step-text,
.cooking-wheel.turn-next .cooking-step-prev,
.cooking-wheel.turn-prev .cooking-step-next,
.cooking-wheel.snap .cooking-step-text {
animation: none !important;
transition: none !important;
}
}
/* Cooking button in recipe dialog */
.btn-cooking {
background: linear-gradient(135deg, #1e3a5f, #2d5016);
+159 -10
View File
@@ -1553,7 +1553,7 @@ function estimateExpiryDays(product, location) {
else if (/carota|carote|zucchina|zucchine|peperoni|melanzane/.test(name)) days = 7;
else if (/broccoli|cavolfiore|cavolo|spinaci|bietola/.test(name)) days = 5;
else if (/cipolla|cipolle/.test(name)) days = 10;
else if (/patata|patate/.test(name)) days = 14;
else if (/patata|patate/.test(name)) days = 30; // whole tubers in a bag, pantry: 3-5 weeks
else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180;
else if (/nutella|marmellata|miele/.test(name)) days = 365;
else if (/passata|pelati|pomodor/.test(name)) days = 730;
@@ -1573,7 +1573,7 @@ function estimateExpiryDays(product, location) {
else if (/arancia|arance|agrumi|mandarini|limone|limoni/.test(name)) days = Math.max(days, 21);
else if (/carota|carote/.test(name)) days = Math.max(days, 21);
else if (/cipolla/.test(name)) days = Math.max(days, 14);
else if (/patata|patate/.test(name)) days = Math.max(days, 21);
else if (/patata|patate/.test(name)) days = Math.max(days, 30);
else if (/pera|pere/.test(name)) days = Math.max(days, 21);
else if (/kiwi/.test(name)) days = Math.max(days, 28);
else if (/uva/.test(name)) days = Math.max(days, 14);
@@ -1704,7 +1704,7 @@ function estimateOpenedExpiryDays(product, location) {
if (/\b(senape|mustard)\b/.test(name)) return 90;
if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90;
if (/\b(tabasco|worcestershire|sriracha)\b/.test(name)) return 180;
if (/confettura|marmellata/.test(name)) return 60;
if (/confettura|marmellata/.test(name)) return 180;
if (/nutella|cioccolat/.test(name)) return 60;
// ── H: Category fallbacks ────────────────────────────────────────────
@@ -2499,9 +2499,24 @@ function _injectKioskOverlay() {
_kioskBridge.hardReload();
});
// Settings button — replaces the native Android settings button
const settBtn = document.createElement('button');
settBtn.id = '_kiosk_settings_btn';
settBtn.textContent = '\u2699\uFE0F';
settBtn.title = t('settings.title') || 'Settings';
settBtn.style.cssText = btnStyle.replace('font-size:15px', 'font-size:16px');
settBtn.addEventListener('click', (e) => {
e.stopPropagation();
showPage('settings');
});
wrap.appendChild(exitBtn);
wrap.appendChild(refBtn);
wrap.appendChild(settBtn);
headerLeft.appendChild(wrap);
// Permanently hide the native Android settings button — replaced by the web overlay button above.
try { _kioskBridge.setNativeSettingsVisible(false); } catch(_) {}
}
function renderAppliances(appliances) {
@@ -3604,8 +3619,16 @@ async function loadDashboard() {
if (days !== null && days !== undefined) {
let expiryClass, expiryText;
if (!isEdible) {
expiryClass = 'opened-expiry-spoiled';
expiryText = t('expiry.badge_expired');
// Only show the red ⛔ badge for items that are genuinely dangerous.
// For conserve/condiments classified as safe, use a gentler amber badge.
const spoiledSafety = getExpiredSafety(item, Math.abs(item.days_to_expiry ?? 1));
if (spoiledSafety.level === 'ok') {
expiryClass = 'opened-expiry-soon';
expiryText = '\u26A0\uFE0F ' + t('expiry.badge_check_soon');
} else {
expiryClass = 'opened-expiry-spoiled';
expiryText = t('expiry.badge_expired');
}
} else if (days > 365) {
expiryClass = 'opened-expiry-ok';
expiryText = t('expiry.badge_stable');
@@ -4887,8 +4910,8 @@ function showItemDetail(inventoryId, productId) {
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
clearMoveModalTimer();
// Restore native kiosk settings button visibility
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {}
// Native kiosk settings button is permanently replaced by the web overlay button — keep hidden.
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
_cancelScaleAutoConfirm(false);
_scaleRecipeAutoFillPaused = false;
_scaleUserDismissed = false;
@@ -7603,6 +7626,9 @@ async function loadUseInventoryInfo() {
// Build location buttons only for locations where the product exists
const productLocations = [...new Set(items.map(i => i.location))];
const locSelector = document.getElementById('use-location-selector');
// Hide the location row when the product is in only one location (nothing to choose)
const locGroup = document.getElementById('use-location-group');
if (locGroup) locGroup.style.display = productLocations.length > 1 ? '' : 'none';
// Prefer the remembered location (if confirmed), else use the opened-package heuristic
const prefLoc = _getPreferredUseLocation(currentProduct.id);
@@ -7801,6 +7827,42 @@ function selectUseLocation(btn, loc) {
const _PREF_LOC_KEY = '_prefUseLoc';
const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference
// ── PREFERRED MOVE-AFTER-USE LOCATION ────────────────────────────────────
// Tracks where the user puts the remainder after using a product.
// After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely.
const _PREF_MOVE_KEY = '_prefMoveLoc';
const _PREF_MOVE_NEEDED = 2;
let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal
function _getMoveLocHistory(productId, fromLoc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
return all[`${productId}|${fromLoc}`] || [];
} catch { return []; }
}
function _recordMoveLocChoice(productId, fromLoc, toLoc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
const key = `${productId}|${fromLoc}`;
const hist = all[key] || [];
hist.push(toLoc);
if (hist.length > 8) hist.splice(0, hist.length - 8);
all[key] = hist;
localStorage.setItem(_PREF_MOVE_KEY, JSON.stringify(all));
} catch { }
}
function _getPreferredMoveLoc(productId, fromLoc) {
const hist = _getMoveLocHistory(productId, fromLoc);
if (hist.length < _PREF_MOVE_NEEDED) return null;
const recent = hist.slice(-5);
const counts = {};
for (const loc of recent) counts[loc] = (counts[loc] || 0) + 1;
const [topLoc, topCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
return topCount >= _PREF_MOVE_NEEDED ? topLoc : null;
}
function _getPrefLocHistory(productId) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
@@ -8153,6 +8215,22 @@ function startMoveModalCountdown(btnId, onExpire) {
}
function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacuumSealed, unit) {
// Store context so _saveVacuumAndStay can record the choice
_pendingMoveCtx = { productId: product.id, fromLoc, openedId };
// If a preference is established, skip the modal entirely and auto-apply
const prefMoveLoc = _getPreferredMoveLoc(product.id, fromLoc);
if (prefMoveLoc) {
if (prefMoveLoc === fromLoc) {
// Preference: stay in place — silent, no modal
_saveVacuumAndStay(openedId || 0);
} else {
// Preference: move to another location — apply silently
confirmMoveAfterUse(product.id, fromLoc, prefMoveLoc, openedId || 0, !!(openedVacuumSealed ?? product.vacuum_sealed));
}
return;
}
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
const locButtons = otherLocs.map(([k, v]) =>
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
@@ -8186,6 +8264,11 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
/** Save vacuum state when user chooses to keep the item at the current location. */
async function _saveVacuumAndStay(openedId) {
// Record the "stay" preference before closing
if (_pendingMoveCtx) {
_recordMoveLocChoice(_pendingMoveCtx.productId, _pendingMoveCtx.fromLoc, _pendingMoveCtx.fromLoc);
_pendingMoveCtx = null;
}
closeModal();
if (openedId) {
const isVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
@@ -8197,9 +8280,14 @@ async function _saveVacuumAndStay(openedId) {
showPage('dashboard');
}
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVacuum) {
clearMoveModalTimer();
const newVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0);
// Record preference
if (_pendingMoveCtx && _pendingMoveCtx.productId === productId) {
_recordMoveLocChoice(productId, fromLoc, toLoc);
_pendingMoveCtx = null;
}
closeModal();
showLoading(true);
try {
@@ -11849,6 +11937,36 @@ async function confirmRecipeMove(productId, fromLoc, toLoc, openedId) {
}
}
/**
* Extract tools/appliances from recipe steps text when tools_needed is absent (old cached recipes).
* Returns an array of localised tool names found in the steps.
*/
function _extractToolsFromSteps(steps) {
const text = (steps || []).join(' ').toLowerCase();
// Map: regex keyword → display name per language
const patterns = [
{ re: /\bforn[oi]\b|oven|backofen/, it: 'Forno', en: 'Oven', de: 'Backofen' },
{ re: /\bmicroond[ea]\b|microwave|mikrowelle/, it: 'Microonde', en: 'Microwave', de: 'Mikrowelle' },
{ re: /\bfrullator[ei]\b|blender|mixer\b|pimer|frullatore a immersione|stabmixer/,
it: 'Frullatore', en: 'Blender', de: 'Mixer' },
{ re: /\bfritteuse\b|friggitrici[ae]\b|air\s*fry|friggitric[ae]\b|friggi\b/, it: 'Friggitrice', en: 'Air fryer', de: 'Fritteuse' },
{ re: /\bpentola\s+a\s+pressione\b|pressure\s+cook|schnellkochtopf|cookeo|instant\s*pot/, it: 'Pentola a pressione', en: 'Pressure cooker', de: 'Schnellkochtopf' },
{ re: /\bbimby\b|thermomix\b|monsieur\s+cuisine/,it: 'Bimby/Thermomix', en: 'Thermomix', de: 'Thermomix' },
{ re: /\bimpastatric[ae]\b|planetari[ao]\b|stand\s*mixer|knetmaschine/, it: 'Impastatrice', en: 'Stand mixer', de: 'Knetmaschine' },
{ re: /\bvapore\b|steamer\b|dampfgarer\b/, it: 'Vaporiera', en: 'Steamer', de: 'Dampfgarer' },
{ re: /\bslow\s*cook|cottura\s+lenta\b|schongarer/, it: 'Slow cooker', en: 'Slow cooker', de: 'Schongarer' },
{ re: /\bgrill[eo]?\b|griglia\b|grillpfanne/, it: 'Griglia', en: 'Grill', de: 'Grill' },
{ re: /\bmacchina\s+del\s+pane\b|bread\s*machine|brotbackautomat/, it: 'Macchina del pane', en: 'Bread machine', de: 'Brotbackautomat' },
{ re: /\bessiccator[ei]\b|dehydrator\b|dörrgerät/, it: 'Essiccatore', en: 'Dehydrator', de: 'Dörrgerät' },
];
const lang = _currentLang || 'it';
const found = [];
for (const p of patterns) {
if (p.re.test(text)) found.push(p[lang] || p.it);
}
return found;
}
function renderRecipe(r) {
let html = `<h2>${r.title}</h2>`;
@@ -11866,6 +11984,14 @@ function renderRecipe(r) {
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
}
// Tools/appliances banner (shown only when specific equipment is needed)
const tools = (r.tools_needed && r.tools_needed.length > 0)
? r.tools_needed.filter(t => t && t.trim())
: _extractToolsFromSteps(r.steps);
if (tools.length > 0) {
html += `<div class="recipe-tools-banner">🔧 <strong>${t('recipes.tools_title')}:</strong> ${tools.map(t => `<span class="recipe-tool-chip">${t}</span>`).join('')}</div>`;
}
// Ingredients
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
(r.ingredients || []).forEach((ing, idx) => {
@@ -12001,8 +12127,25 @@ function startCookingMode() {
_cookingTTS = true;
document.getElementById('cooking-title').textContent = _cookingRecipe.title || '';
document.getElementById('cooking-tts-btn').textContent = '🔊';
// Tools bar
const toolsBar = document.getElementById('cooking-tools-bar');
if (toolsBar) {
const tools = (_cookingRecipe.tools_needed && _cookingRecipe.tools_needed.length > 0)
? _cookingRecipe.tools_needed.filter(t => t && t.trim())
: _extractToolsFromSteps(_cookingRecipe.steps);
if (tools.length > 0) {
toolsBar.innerHTML = '🔧 ' + tools.map(t => `<span class="cooking-tool-chip">${t}</span>`).join('');
toolsBar.style.display = '';
} else {
toolsBar.style.display = 'none';
toolsBar.innerHTML = '';
}
}
document.getElementById('cooking-overlay').style.display = 'flex';
document.body.classList.add('cooking-mode-active');
// Hide kiosk overlay — it lives outside <body> with z-index:2147483647 and would overlap cooking UI
const _kioskOvl = document.getElementById('_kiosk_overlay');
if (_kioskOvl) _kioskOvl.style.display = 'none';
_bindCookingWheelControls();
const wheelEl = document.getElementById('cooking-wheel');
if (wheelEl) setTimeout(() => wheelEl.focus(), 20);
@@ -12016,6 +12159,9 @@ function startCookingMode() {
function closeCookingMode() {
document.getElementById('cooking-overlay').style.display = 'none';
document.body.classList.remove('cooking-mode-active');
// Restore kiosk overlay
const _kioskOvl = document.getElementById('_kiosk_overlay');
if (_kioskOvl) _kioskOvl.style.display = 'flex';
// NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited
// so the user can resume from the same step when they reopen
try { screen.orientation?.unlock().catch(() => {}); } catch (_) { /* ignore */ }
@@ -12348,7 +12494,10 @@ function _initBrowserTtsVoices(selectedVoice) {
sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
const populate = () => {
const voices = window.speechSynthesis.getVoices();
let voices = [];
try {
voices = (window.speechSynthesis.getVoices() || []).filter(v => v != null && v.lang);
} catch (_) { return false; }
if (!voices.length) return false;
// Italian voices first, then others
const it = voices.filter(v => v.lang.startsWith('it'));
+19 -5
View File
@@ -36,12 +36,12 @@ Overview of what the wizard will configure.
### Step 3 — Permissions
Grant camera, microphone, and storage permissions needed by the web app.
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (green) once all permissions are granted.
The button transforms from **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
### Step 4 — Server URL
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
**Or tap "Rileva automaticamente"** to let the wizard scan your LAN:
**Or tap "Auto-discover"** to let the wizard scan your LAN:
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
- Real-time feedback as hosts are tested
@@ -62,9 +62,23 @@ All done — the web app loads in full-screen kiosk mode.
---
## Header Overlay Buttons
Three buttons are injected into the top-left of the web header by the kiosk app:
| Button | Action |
|--------|--------|
| **✕** | Exit kiosk mode (confirmation dialog) |
| **↻** | Hard-refresh — clears WebView cache and reloads the app |
| **⚙️** | Open EverShelf Settings |
The native Android settings button is permanently hidden once the overlay is injected — the **⚙️** web button replaces it entirely.
---
## Exiting Kiosk Mode
Tap the **✕** button in the header (top-left). A confirmation dialog appears.
Tap the **✕** button in the header overlay (top-left). A confirmation dialog appears.
---
@@ -133,6 +147,6 @@ Requires Android Studio or JDK 17+ with the Android SDK.
| `CAMERA` | Barcode scanning and AI photo identification |
| `RECORD_AUDIO` | Voice input in AI chat |
| `WAKE_LOCK` | Keep the screen on |
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK |
| `REQUEST_INSTALL_PACKAGES` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
| `REORDER_TASKS` | Bring app to foreground after gateway launch |
| `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
+12 -14
View File
@@ -43,15 +43,13 @@ docker compose up -d
## AI Features
### AI features don't work / "AI non disponibile"
### "AI not available" error
1. Check that `GEMINI_API_KEY` is set in `.env`
2. Verify the key is valid at [aistudio.google.com](https://aistudio.google.com)
3. Check that you haven't exceeded the free tier quota (15 req/min, 1500 req/day)
4. Look for errors in the PHP error log
### Recipe generation stops midway
This is usually a Gemini API timeout. The app streams results via SSE — if the server PHP timeout is too low, the stream is cut short. Increase `max_execution_time` in `php.ini`:
```ini
@@ -62,7 +60,7 @@ max_execution_time = 120
## Shopping List (Bring!)
### "Bring! non configurato" message in the shopping tab
### "Bring! not configured" message in the shopping tab
Add your Bring! credentials to `.env`:
@@ -90,7 +88,7 @@ BRING_PASSWORD=yourpassword
### Scale shows weight but form doesn't auto-fill
- The auto-fill only triggers for products with unit `g` or `ml`
- Make sure you tapped "⚖️ Leggi bilancia" first to activate the scale modal
- Make sure you tapped **"⚖️ Read Scale"** first to activate the scale modal
- The weight must stabilize (stay within 10g) for the countdown to start
### Bluetooth scale not appearing in the gateway app
@@ -109,19 +107,19 @@ BRING_PASSWORD=yourpassword
- Try entering the URL manually instead of using auto-discovery
- Check that the server responds on the expected port (80/443/8080/8443)
### Gateway install fails with an error dialog
### Kiosk app update fails
The dialog shows the exact failure code. Common causes:
The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
| Code | Cause | Fix |
|------|-------|-----|
| `STATUS_FAILURE` (1) | Generic install failure — often OEM restriction | Enable "Install from unknown sources" for the kiosk app in Android Settings |
| `STATUS_FAILURE_CONFLICT` (3) | Signature mismatch with existing install | Uninstall the old gateway app, then retry |
| `STATUS_FAILURE_STORAGE` (6) | Not enough storage | Free up space on the device |
| Symptom | Fix |
|---------|-----|
| "Install from unknown sources" dialog | Enable the setting for the EverShelf Kiosk app in Android Settings |
| Persistent failure after download | Force-stop the app, clear its data, and relaunch the update flow |
| Not enough space | Free up storage on the device |
### Exit button (✕) is not visible
The ✕ button is injected into the header by the kiosk app. If the web app's header is covered or the page failed to load, try the hard refresh (↻) button. If neither is visible, triple-tap the page title area to access the developer settings.
Three buttons are always visible in the kiosk header overlay: **✕** (exit), **↻** (refresh), **⚙️** (settings). If the page failed to load entirely, tap **↻** first. If nothing is visible, restart the device.
### App is stuck in kiosk mode after a crash
@@ -139,7 +137,7 @@ The version is cached by the browser. Do a hard refresh:
### Transactions are missing from the log
The log shows the last 50 entries by default. Tap "Carica altri" to load more. Entries older than the database creation date won't appear.
The log shows the last 50 entries by default. Tap **"Load more"** to load more. Entries older than the database creation date won't appear.
### "Can only undo transactions within 24 hours"
+7 -7
View File
@@ -83,9 +83,9 @@ Recipes stream live via Server-Sent Events so results appear as they are generat
### AI Chat Assistant
Open **💬 Chat** to ask questions like:
- "Cosa posso fare con le uova e la pasta?"
- "Quanti giorni dura il prosciutto cotto aperto in frigo?"
- "Suggeriscimi uno spuntino veloce"
- "What can I make with eggs and pasta?"
- "How long does cooked ham last once opened in the fridge?"
- "Suggest a quick snack"
The assistant knows your current inventory.
@@ -121,7 +121,7 @@ Configure `BRING_EMAIL` and `BRING_PASSWORD` in `.env` to enable.
## 🍳 Cooking Mode
Start cooking mode from any recipe by tapping **Avvia cottura**.
Start cooking mode from any recipe by tapping **Start Cooking**.
### Features
@@ -132,7 +132,7 @@ Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
- Custom REST endpoint (e.g. Home Assistant)
- **Built-in timers** — automatic timer suggestions based on recipe text; 10-second vocal countdown warning before expiry
- **Ingredient tracking** — mark ingredients as used; leftover quantities prompt a "move to another location" flow
- **Recipe completion** — "Buon appetito!" spoken on the last step
- **Recipe completion** — "Buon appetito!" *(Enjoy your meal!)* spoken on the last step
---
@@ -155,8 +155,8 @@ Actions per item: Use, Throw away, Edit, Dismiss. Swipe or tap arrows to navigat
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
- One-tap correction to the suggested quantity
- Inline edit with free-form quantity
- "🤖 Spiega" for AI explanation
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)")
- "🤖 Explain" for AI explanation
- Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
### Anti-Waste Report
+6 -3
View File
@@ -47,8 +47,11 @@ All data stays on your server. No cloud, no subscriptions.
## 🆕 What's New
### v1.7.13 (2026-05-16)
- **Critical fix:** Fresh-install crash resolved — `transactions` schema was missing the `undone` column, causing a database failure on every new installation
- **Fix:** Race condition in DB migrations no longer causes `duplicate column name` errors on concurrent first requests
- **Fix:** Kiosk Settings button (⚙️) added to the web overlay — tapping the camera button no longer accidentally opens kiosk settings
- **Fix:** Opened-item expiry badge is now consistent with the top banner: low-risk items (jams, condiments) show amber ⚠️ "Check soon" instead of misleading red ⛔ "Expired"
- **Cooking Mode:** 3D wheel UI with perspective card flip, ghost steps (prev/next), float animation, and full `prefers-reduced-motion` support
- **CI:** `data/category_ai_cache.json` added to `.gitignore`
- **Critical fix (DB):** Fresh-install crash resolved — `transactions` schema was missing the `undone` column
### v1.7.12 (2026-05-13)
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
@@ -81,7 +84,7 @@ EverShelf/
├── translations/ # i18n JSON files (it, en, de)
├── docs/openapi.yaml # OpenAPI 3.0 spec
├── evershelf-kiosk/ # Android kiosk app (Kotlin)
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin)
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin) — DEPRECATED, built into kiosk since v1.6.0
```
---
+3 -3
View File
@@ -75,7 +75,7 @@ In EverShelf **Settings → Scale**:
### 4. Connect your scale
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect.
Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
---
@@ -84,7 +84,7 @@ Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale
When scale integration is enabled:
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
2. A **"⚖️ Leggi bilancia"** button appears
2. A **"⚖️ Read Scale"** button appears
3. Tap it — a live weight display appears with a stability indicator
4. Step on or place the product on the scale
5. When the reading stabilizes, a **5-second countdown** starts
@@ -127,7 +127,7 @@ Every 6 hours the gateway app checks GitHub releases. If a newer version is avai
### Weight not appearing in EverShelf
- Confirm the Gateway URL in EverShelf Settings matches the URL shown in the gateway app
- Check that the Android device and the EverShelf server are on the same network
- Tap "Disconnetti / Riconnetti" in the gateway app to refresh the WebSocket connection
- Tap "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
### "Mixed content" error in browser
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 13
versionName = "1.7.2"
versionCode = 14
versionName = "1.7.13"
}
signingConfigs {
+1 -1
View File
@@ -1,3 +1,3 @@
android.useAndroidX=true
android.enableJetifier=true
# Build trigger: TTS bridge fix (95389eb)
# Build trigger: versionName 1.7.13 fix (8d87494)
-7
View File
@@ -1,7 +0,0 @@
.gradle/
build/
local.properties
*.apk
*.aab
*.class
*.dex
-156
View File
@@ -1,156 +0,0 @@
# ~~EverShelf Scale Gateway~~ — DEPRECATED
> ⚠️ **This app is deprecated and no longer maintained.**
>
> As of **EverShelf Kiosk v1.6.0**, BLE scale support is fully integrated into the kiosk app itself. You no longer need to install or configure this separate gateway app.
>
> **If you are using the EverShelf Kiosk app** → the scale gateway runs automatically as a background service. Configure your Bluetooth scale in **step 4 of the setup wizard**.
>
> **If you are NOT using the kiosk app** (standalone Android tablet) → you may still use this APK, but no new releases will be published.
---
# EverShelf Scale Gateway (legacy)
> Android gateway app that bridges Bluetooth LE smart scales with EverShelf via WebSocket.
---
## How it works
```
Smart Scale ──(BLE)──► Android Gateway App ──(WebSocket/LAN)──► EverShelf Server ──(SSE)──► Browser
```
The app runs a local WebSocket server (port **8765**) on your Android device. The EverShelf server connects to it via a server-side relay (`api/scale_relay.php` SSE + `api/scale_ping.php` WebSocket client), avoiding mixed-content (HTTPS→WS) issues. Weight readings are streamed to the browser in real time.
> **Kiosk integration (v1.6.0+):** The gateway is now **built into the EverShelf Kiosk app** as a foreground service. This separate app is not needed when using the kiosk.
---
## Supported scale protocols
| Protocol | Service UUID | Notes |
|---|---|---|
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible; works with most smart scales |
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Reports weight + body fat %, BMI |
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
### Verified compatible scales (community list)
- Xiaomi Mi Body Composition Scale 2
- Renpho Smart Body Fat Scale
- INEVIFIT Smart Body Fat Scale
- Any OpenScale-compatible scale (see [openScale supported devices](https://github.com/oliexdev/openScale/wiki/Supported-scales))
> **Your scale (B09MRXVBV6):** If it implements the standard BLE Weight Scale or Body Composition profile (very likely for modern Amazon smart scales), the gateway will connect automatically. If not, check the [openScale wiki](https://github.com/oliexdev/openScale/wiki/Supported-scales) and open an issue.
---
## Download
Download the latest APK directly: **[evershelf-scale-gateway.apk](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
---
## Requirements
- Android **7.0** (API 24) or later
- Bluetooth LE (BLE) support
- Both the Android device and the device running EverShelf must be on the **same Wi-Fi network**
---
## Setup (step by step)
### 1. Install the APK
Download and install the APK from the Releases page. You may need to allow "Install from unknown sources" in Android settings.
### 2. Launch the app
The app starts the WebSocket gateway server immediately. You will see the **gateway URL** (e.g. `ws://192.168.1.100:8765`) at the top.
### 3. Connect your scale
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is turned on. Tap it in the list to connect.
### 4. Configure EverShelf
In EverShelf → ⚙️ Settings → **⚖️ Bilancia Smart**:
1. Enable the toggle
2. Paste the gateway URL shown in the Android app
3. Tap **"Testa connessione"** — you should see ✅
### 5. Use it
When adding or consuming a product with unit **g** or **ml**, a **"⚖️ Leggi dalla bilancia"** button appears. Tap it, place the product on the scale, and the weight is filled in automatically.
---
## WebSocket protocol reference
All messages are JSON. The server sends these to connected clients:
```json
// Scale status update
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
{"type":"status","state":"disconnected"}
// Weight reading (broadcast continuously while scale is active)
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
// Response to ping
{"type":"pong"}
```
Clients can send:
```json
{"type":"get_status"} // Request current status
{"type":"get_weight"} // Request next stable weight reading
{"type":"ping"} // Keep-alive
```
---
## Build from source
### Prerequisites
- Android Studio Hedgehog (2023.1) or later
- Java 8+
### Steps
```bash
# 1. Clone the repo
git clone https://github.com/dadaloop82/EverShelf.git
cd EverShelf/evershelf-scale-gateway
# 2. Download the Gradle wrapper (if not included)
gradle wrapper --gradle-version 8.4
# 3. Build debug APK
./gradlew assembleDebug
# APK is at: app/build/outputs/apk/debug/app-debug.apk
```
---
## Project structure
```
evershelf-scale-gateway/
├── app/src/main/
│ ├── kotlin/it/dadaloop/evershelf/scalegate/
│ │ ├── MainActivity.kt — UI, orchestration
│ │ ├── BleScaleManager.kt — BLE scanning & GATT connection
│ │ ├── ScaleProtocol.kt — Parsing for all supported protocols
│ │ └── GatewayWebSocketServer.kt — WebSocket server (Java-WebSocket)
│ ├── res/layout/
│ │ ├── activity_main.xml
│ │ └── item_device.xml
│ └── AndroidManifest.xml
├── build.gradle.kts
└── settings.gradle.kts
```
---
## License
MIT — see [LICENSE](../LICENSE)
@@ -1,41 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "it.dadaloop.evershelf.scalegate"
compileSdk = 34
defaultConfig {
applicationId = "it.dadaloop.evershelf.scalegate"
minSdk = 24
targetSdk = 34
versionCode = 8
versionName = "2.1.1"
}
buildFeatures {
viewBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.recyclerview:recyclerview:1.3.2")
// WebSocket server
implementation("org.java-websocket:Java-WebSocket:1.5.5")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- BLE permissions for Android < 12 -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE permissions for Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location (required for BLE scanning on Android 611) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Network (for WebSocket server) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- 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
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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>
@@ -1,455 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.Manifest
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
private const val TAG = "BleScaleManager"
private const val SCAN_PERIOD_MS = 15_000L
private const val PREFS_NAME = "evershelf_gateway"
private const val PREF_LAST_DEVICE = "last_device_address"
/**
* Represents a discovered BLE device during scan.
*/
data class BleDeviceInfo(
val device: BluetoothDevice,
val name: String,
val rssi: Int,
val proximity: String,
val scaleScore: Int,
)
/**
* Callback interface for BLE events dispatched back to the UI.
*/
interface BleScaleListener {
fun onDeviceFound(info: BleDeviceInfo)
fun onConnecting(device: BluetoothDevice)
fun onConnected(deviceName: String)
fun onDisconnected()
fun onWeightReceived(reading: WeightReading)
fun onBatteryReceived(level: Int)
fun onError(message: String)
fun onScanStopped()
fun onDebugEvent(message: String)
}
/**
* Manages BLE scanning and connection to a smart scale.
* All listener callbacks are dispatched on the main thread.
*/
class BleScaleManager(
private val context: Context,
private val listener: BleScaleListener,
) {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? get() = bluetoothManager.adapter
private val mainHandler = Handler(Looper.getMainLooper())
private var leScanner: BluetoothLeScanner? = null
private var gatt: BluetoothGatt? = null
private var isScanning = false
private var connectedDeviceName: String = ""
private var autoConnectAddress: String? = null
// The characteristics we will subscribe to (multiple may exist).
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
// ─── Public state ──────────────────────────────────────────────────────────
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
// ─── Saved device (auto-reconnect) ─────────────────────────────────────────
fun getSavedDeviceAddress(): String? {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_LAST_DEVICE, null)
}
private fun saveDeviceAddress(address: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(PREF_LAST_DEVICE, address).apply()
}
fun enableAutoConnect() {
autoConnectAddress = getSavedDeviceAddress()
}
// ─── Permissions helper ────────────────────────────────────────────────────
fun hasRequiredPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
// ─── Scanning ──────────────────────────────────────────────────────────────
fun startScan() {
val adapter = bluetoothAdapter ?: run {
listener.onError("Bluetooth not available on this device.")
return
}
if (!adapter.isEnabled) {
listener.onError("Bluetooth is off. Enable it and try again.")
return
}
if (isScanning) stopScan()
leScanner = adapter.bluetoothLeScanner
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
// No service UUID filters — many consumer scales use proprietary UUIDs
// and would be invisible with strict filtering. We show all named BLE devices.
isScanning = true
try {
leScanner?.startScan(null, settings, scanCallback)
} catch (e: Exception) {
leScanner?.startScan(scanCallback)
}
// Auto-stop after SCAN_PERIOD_MS
mainHandler.postDelayed({
stopScan()
listener.onScanStopped()
}, SCAN_PERIOD_MS)
}
fun stopScan() {
if (!isScanning) return
isScanning = false
try {
leScanner?.stopScan(scanCallback)
} catch (e: Exception) { /* ignore */ }
leScanner = null
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
?: getDeviceName(device)
val proximity = rssiToProximity(result.rssi)
val score = scoreLikelyScale(name, result.scanRecord)
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
mainHandler.post { listener.onDeviceFound(info) }
// Auto-connect to saved device
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
autoConnectAddress = null // prevent re-trigger
mainHandler.post {
listener.onDebugEvent("\uD83D\uDD04 Auto-connecting to $name (${device.address})")
connect(device)
}
}
}
override fun onScanFailed(errorCode: Int) {
isScanning = false
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
}
}
private fun getDeviceName(device: BluetoothDevice): String {
return try {
device.name?.takeIf { it.isNotBlank() } ?: "Unnamed"
} catch (e: SecurityException) {
"Unnamed"
}
}
private fun rssiToProximity(rssi: Int) = when {
rssi >= -60 -> "📶 Near"
rssi >= -80 -> "📶 Medium"
else -> "📶 Far"
}
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
var score = 0
val lower = name.lowercase()
// Kitchen / food scale brand and model keywords
val foodKeywords = listOf(
"scale", "bilancia", "kitchen", "food", "cucina",
"coffee", "caffe", "balance", "weight", "waage",
"arboleaf", "ck10", "ck20", "ek-",
"acaia", "felicita", "decent", "skale",
"timemore", "brewista", "hario",
"greater goods", "ozeri", "etekcity", "nutri",
"nicewell", "koios", "renpho", "eatsmart",
)
if (foodKeywords.any { lower.contains(it) }) score += 10
// Negative: body/fitness scale keywords (demote but don't hide)
val bodyKeywords = listOf(
"body", "fat", "bmi", "composition", "fitness",
"mi body", "lepulse", "qardio", "garmin", "withings",
)
if (bodyKeywords.any { lower.contains(it) }) score -= 5
// Service UUID scoring
scanRecord?.serviceUuids?.let { uuids ->
val us = uuids.map { it.uuid.toString().lowercase() }
// SIG Weight Scale service
if (us.any { it.startsWith("0000181d") }) score += 15
// Common vendor services on kitchen scales
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
// Acaia coffee scale
if (us.any { it.startsWith("49535343") }) score += 20
// Body Composition service = body scale, demote
if (us.any { it.startsWith("0000181b") }) score -= 10
}
return score
}
// ─── Connection ────────────────────────────────────────────────────────────
fun connect(device: BluetoothDevice) {
stopScan()
disconnect()
connectedDeviceName = ""
ScaleProtocol.resetState()
mainHandler.post { listener.onConnecting(device) }
try {
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} else {
device.connectGatt(context, false, gattCallback)
}
} catch (e: SecurityException) {
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
}
}
fun disconnect() {
pendingSubscriptions.clear()
try {
gatt?.disconnect()
gatt?.close()
} catch (e: Exception) { /* ignore */ }
gatt = null
connectedDeviceName = ""
}
// ─── GATT callbacks ────────────────────────────────────────────────────────
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.d(TAG, "Connected — discovering services…")
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.d(TAG, "Disconnected (status=$status)")
this@BleScaleManager.gatt?.close()
this@BleScaleManager.gatt = null
connectedDeviceName = ""
mainHandler.post { listener.onDisconnected() }
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
mainHandler.post { listener.onError("Servizi GATT non trovati (status=$status)") }
return
}
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
// Priority 1: BLE SIG Weight Scale Service
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
?.let { targetChars.add(it) }
// Priority 2: Common vendor service FFE0 (arboleaf, generic kitchen scales)
gatt.getService(BleUuids.FFE0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
}
// Priority 3: Common vendor service FFF0
gatt.getService(BleUuids.FFF0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
}
// Priority 4: Acaia coffee scale
gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc ->
svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
}
// Fallback: any notifiable characteristic from remaining services
if (targetChars.isEmpty()) {
for (service in gatt.services) {
if (service.uuid.toString().startsWith("00001800") ||
service.uuid.toString().startsWith("00001801")) continue
for (char in service.characteristics) {
val props = char.properties
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
if (!targetChars.contains(char)) targetChars.add(char)
}
}
}
}
if (targetChars.isEmpty()) {
mainHandler.post { listener.onError("No weight characteristic found. Make sure it's a BLE kitchen scale.") }
return
}
// Battery (optional)
gatt.getService(BleUuids.BATTERY_SERVICE)
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
?.let { targetChars.add(it) }
// Debug: log all discovered services and characteristics
val dbg = buildString {
append("GATT services (${gatt.services.size}):\n")
for (svc in gatt.services) {
append(" SVC: ${svc.uuid}\n")
for (ch in svc.characteristics) {
val p = ch.properties
val flags = buildString {
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) append("N")
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) append("I")
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) append("R")
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) append("W")
}
append(" CHAR: ${ch.uuid} [$flags]\n")
}
}
append("Subscribed to ${targetChars.size} characteristics")
}
mainHandler.post { listener.onDebugEvent(dbg) }
// Save device for auto-reconnect
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
pendingSubscriptions.clear()
pendingSubscriptions.addAll(targetChars)
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
connectedDeviceName = deviceName
mainHandler.post { listener.onConnected(deviceName) }
// Subscribe one at a time (Android BLE requires sequential descriptor writes)
subscribeNext(gatt)
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
// Subscribe to the next characteristic
subscribeNext(gatt)
}
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
val data = characteristic.value ?: return
processCharacteristicData(characteristic, data)
}
// Android 13+ override
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
processCharacteristicData(characteristic, value)
}
@Suppress("DEPRECATION")
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
val level = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)
if (level != null) mainHandler.post { listener.onBatteryReceived(level) }
}
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private fun subscribeNext(gatt: BluetoothGatt) {
val char = pendingSubscriptions.removeFirstOrNull() ?: return
// Battery characteristic — read once instead of notify
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
try { gatt.readCharacteristic(char) } catch (e: SecurityException) { /* ignore */ }
return
}
val props = char.properties
val notifyType = when {
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 ->
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
}
try {
gatt.setCharacteristicNotification(char, true)
val descriptor = char.getDescriptor(CCCD_UUID) ?: run {
// No CCCD — skip and try next
subscribeNext(gatt)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, notifyType)
} else {
@Suppress("DEPRECATION")
descriptor.value = notifyType
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException enabling notification", e)
}
}
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
// Battery level
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
val level = data[0].toInt() and 0xFF
mainHandler.post { listener.onBatteryReceived(level) }
return
}
// Debug: log raw bytes received
val hex = data.joinToString(" ") { "%02X".format(it) }
mainHandler.post { listener.onDebugEvent("📡 ${char.uuid}\n HEX [${data.size}B]: $hex") }
// Parse weight data
val reading = ScaleProtocol.parse(char, data) { msg ->
mainHandler.post { listener.onDebugEvent(msg) }
}
if (reading != null && reading.value > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
} else {
val rawDump = data.mapIndexed { i, b ->
val v = b.toInt() and 0xFF
val h = "%02X".format(v)
"[$i]=$v(0x$h)"
}.joinToString(" ")
mainHandler.post { listener.onDebugEvent("\u26a0\ufe0f Weight not decoded\n RAW: $rawDump") }
}
}
}
@@ -1,249 +0,0 @@
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,151 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.util.Log
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import org.json.JSONObject
import java.net.InetSocketAddress
import java.util.Collections
private const val TAG = "GatewayWsServer"
/**
* Callbacks for the WebSocket server, dispatched on the server's internal thread.
* The caller (MainActivity) is responsible for switching to the main thread if needed.
*/
interface ServerEventListener {
fun onClientConnected(address: String)
fun onClientDisconnected(address: String)
fun onClientRequestedWeight()
}
/**
* WebSocket server that exposes smart-scale data to EverShelf running in a browser.
*
* Message protocol (JSON):
*
* Server -> Client:
* {"type":"status","state":"connected"|"disconnected","device":"QN-KS","battery":80}
* {"type":"weight","value":17.0,"unit":"g","stable":true,"timestamp":1712345678000}
* {"type":"pong"}
*
* Client → Server:
* {"type":"get_status"} → server responds with current status message
* {"type":"get_weight"} → server will push the next stable weight reading
* {"type":"ping"} → server responds with {"type":"pong"}
*/
class GatewayWebSocketServer(
port: Int,
private val eventListener: ServerEventListener?,
) : WebSocketServer(InetSocketAddress(port)) {
// Thread-safe set of clients waiting for the next stable weight reading
private val pendingWeightRequests: MutableSet<WebSocket> =
Collections.synchronizedSet(mutableSetOf())
// Last known scale state (to send to new clients immediately)
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
@Volatile private var lastWeightJson: String? = null
// ─── Server lifecycle ──────────────────────────────────────────────────────
override fun onStart() {
Log.i(TAG, "WebSocket server started on port ${address.port}")
connectionLostTimeout = 30
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
Log.d(TAG, "Client connected: $addr")
// Immediately send current status so the web app knows the scale state
conn.send(lastStatusJson)
lastWeightJson?.let { conn.send(it) }
eventListener?.onClientConnected(addr)
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
Log.d(TAG, "Client disconnected: $addr (code=$code)")
pendingWeightRequests.remove(conn)
eventListener?.onClientDisconnected(addr)
}
override fun onMessage(conn: WebSocket, message: String) {
try {
val json = JSONObject(message)
when (json.optString("type")) {
"ping" -> conn.send("""{"type":"pong"}""")
"get_status" -> conn.send(lastStatusJson)
"get_weight" -> {
// Add to pending set; next stable weight will be sent to this client
pendingWeightRequests.add(conn)
eventListener?.onClientRequestedWeight()
// If we already have a recent weight, send it immediately
lastWeightJson?.let { conn.send(it) }
}
}
} catch (e: Exception) {
Log.w(TAG, "Malformed message: $message")
}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket error on ${conn?.remoteSocketAddress}", ex)
ErrorReporter.report(ex, "GatewayWebSocketServer.onError",
mapOf("remote_addr" to (conn?.remoteSocketAddress?.toString() ?: "null")))
}
// ─── Publishing API ────────────────────────────────────────────────────────
/**
* Broadcast scale connection status to all connected WebSocket clients.
*/
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
lastStatusJson = buildStatusJson(state, deviceName, battery)
broadcast(lastStatusJson)
}
/**
* Broadcast a weight reading to all clients.
* If [stable] is true, also fulfil pending on-demand weight requests.
*/
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
val json = buildWeightJson(value, unit, stable)
lastWeightJson = json
broadcast(json)
if (stable) {
synchronized(pendingWeightRequests) {
// Clients that requested on-demand readings are already served by broadcast;
// just clear the pending set.
pendingWeightRequests.clear()
}
}
}
// ─── JSON builders ─────────────────────────────────────────────────────────
private fun buildStatusJson(state: String, device: String?, battery: Int?): String {
val obj = JSONObject()
obj.put("type", "status")
obj.put("state", state)
if (device != null) obj.put("device", device)
if (battery != null) obj.put("battery", battery)
return obj.toString()
}
private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String {
val obj = JSONObject()
obj.put("type", "weight")
// Round to 1 decimal to avoid floating point noise (e.g. 17.000001)
val rounded = Math.round(value * 10f) / 10.0
obj.put("value", rounded)
obj.put("unit", unit)
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
}
}
@@ -1,674 +0,0 @@
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
import androidx.appcompat.app.AlertDialog
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
class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener {
private lateinit var binding: ActivityMainBinding
private lateinit var bleManager: BleScaleManager
private var wsServer: GatewayWebSocketServer? = null
private val devices = mutableListOf<BleDeviceInfo>()
private lateinit var deviceAdapter: DeviceAdapter
private var batteryLevel: Int? = null
private val debugLines = mutableListOf<String>()
private var debugVisible = false
private var lastDebugUpdate = 0L
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
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 ───────────────────────────────────────────────────
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
if (granted.values.all { it }) {
startGatewayServer()
} else {
showDialog("Missing permissions",
"The app requires Bluetooth and Location permissions to function.")
}
}
private val enableBtLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
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?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
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)
}
binding.rvDevices.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = deviceAdapter
}
binding.btnScan.setOnClickListener { startScanIfPermitted() }
binding.btnDisconnect.setOnClickListener {
bleManager.disconnect()
updateUiDisconnected()
}
binding.btnDebug.setOnClickListener {
debugVisible = !debugVisible
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnCopyLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnShareLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Hide Debug" else "\uD83D\uDC1B Debug"
}
binding.btnCopyLog.setOnClickListener {
val log = debugLines.joinToString("\n")
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Scale Log", log))
Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show()
}
binding.btnShareLog.setOnClickListener {
val log = debugLines.joinToString("\n")
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "EverShelf Scale Gateway - Debug Log")
putExtra(Intent.EXTRA_TEXT, log)
}
startActivity(Intent.createChooser(intent, "Share log"))
}
// Show app version
try {
val pInfo = packageManager.getPackageInfo(packageName, 0)
binding.tvVersion.text = "v${pInfo.versionName} (${pInfo.longVersionCode})"
} catch (_: Exception) { }
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
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale\u2026"
}
}
override fun onDestroy() {
super.onDestroy()
bleManager.disconnect()
wsServer?.stop(1000)
}
// ─── Permissions & startup ─────────────────────────────────────────────────
private fun checkPermissionsAndStart() {
val required = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
add(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
val missing = required.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
when {
missing.isNotEmpty() -> permissionLauncher.launch(missing.toTypedArray())
!isBluetoothEnabled() -> enableBtLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
else -> startGatewayServer()
}
}
private fun isBluetoothEnabled(): Boolean {
val adapter = android.bluetooth.BluetoothManager::class.java.let {
getSystemService(it)
} as? android.bluetooth.BluetoothManager
return adapter?.adapter?.isEnabled == true
}
private fun startScanIfPermitted() {
if (!bleManager.hasRequiredPermissions()) {
checkPermissionsAndStart()
return
}
devices.clear()
deviceAdapter.notifyDataSetChanged()
debugLines.clear()
binding.tvDebugLog.text = ""
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "Scanning for BLE scales\u2026"
binding.btnScan.isEnabled = false
bleManager.enableAutoConnect()
isAutoReconnecting = false // manual scan — stop any pending auto-reconnect cycle
bleManager.startScan()
}
// ─── WebSocket gateway ─────────────────────────────────────────────────────
private fun startGatewayServer() {
if (wsServer != null) return
try {
wsServer = GatewayWebSocketServer(WS_PORT, this)
wsServer!!.start()
updateGatewayUrl()
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
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
bleManager.enableAutoConnect()
bleManager.startScan()
}
}
private fun updateGatewayUrl() {
val ip = getLocalIpAddress() ?: ""
val url = "ws://$ip:$WS_PORT"
binding.tvGatewayUrl.text = url
binding.tvGatewayUrlHint.text = "Paste this URL in EverShelf \u2192 Settings \u2192 Smart Scale"
binding.btnCopyUrl.setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
binding.btnCopyUrl.text = "\u2705 Copied!"
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
}
}
// ─── BleScaleListener ─────────────────────────────────────────────────────
override fun onDeviceFound(info: BleDeviceInfo) {
if (devices.none { it.device.address == info.device.address }) {
// Insert keeping descending scaleScore order (scale-likely devices first)
val insertAt = devices.indexOfFirst { it.scaleScore < info.scaleScore }
.let { if (it < 0) devices.size else it }
devices.add(insertAt, info)
deviceAdapter.notifyItemInserted(insertAt)
}
}
override fun onConnecting(device: BluetoothDevice) {
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
binding.tvScaleStatus.text = "\u23f3 Connecting to $name\u2026"
binding.tvWeight.text = "— — —"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
}
override fun onConnected(deviceName: String) {
isAutoReconnecting = false
binding.tvScaleStatus.text = "\u2705 Connected: $deviceName"
binding.tvWeight.text = "Waiting for weight\u2026"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_green_light))
binding.btnDisconnect.visibility = View.VISIBLE
binding.rvDevices.visibility = View.GONE
binding.btnScan.visibility = View.GONE
binding.tvScanHint.visibility = View.GONE
wsServer?.publishStatus("connected", deviceName, batteryLevel)
}
override fun onDisconnected() {
wsServer?.publishStatus("disconnected", null, null)
updateUiDisconnected()
// Auto-reconnect: if a saved device exists, restart scan after a short delay.
// This handles the scale turning off by itself (auto-off) — when it powers
// back on it will start advertising again and we will pick it up.
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
isAutoReconnecting = true
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale in 5 s\u2026"
binding.root.postDelayed({
if (!bleManager.isConnected && isAutoReconnecting) {
bleManager.enableAutoConnect()
bleManager.startScan()
}
}, 5_000L)
}
}
override fun onWeightReceived(reading: WeightReading) {
val displayValue = if (reading.value % 1f == 0f) reading.value.toInt().toString()
else "%.1f".format(reading.value)
binding.tvWeight.text = "$displayValue ${reading.unit}"
if (reading.stable) {
binding.tvWeightHint.text = "\u2713 Stable reading"
} else {
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
}
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
}
override fun onBatteryReceived(level: Int) {
batteryLevel = level
binding.tvBattery.text = "🔋 $level%"
binding.tvBattery.visibility = View.VISIBLE
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
.removePrefix("\u2705 Connected: "), level)
}
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() {
binding.btnScan.isEnabled = true
if (isAutoReconnecting && !bleManager.isConnected && bleManager.getSavedDeviceAddress() != null) {
// Scale not found yet — retry scan after 10 s indefinitely until reconnected
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "\uD83D\uDD04 Bilancia non trovata, riprovo tra 10 s\u2026"
binding.root.postDelayed({
if (!bleManager.isConnected && isAutoReconnecting) {
binding.tvScanHint.text = "\uD83D\uDD04 Cerco la bilancia\u2026"
bleManager.enableAutoConnect()
bleManager.startScan()
}
}, 10_000L)
} else if (devices.isEmpty()) {
binding.tvScanHint.text = "No scale found. Make sure it's on, then scan again."
} else {
binding.tvScanHint.text = "Tap a scale to connect."
}
}
override fun onDebugEvent(message: String) {
runOnUiThread {
val ts = debugTimeFmt.format(Date())
debugLines.add("[$ts] $message")
// Keep only last MAX_DEBUG_LINES
while (debugLines.size > MAX_DEBUG_LINES) debugLines.removeAt(0)
// Throttle UI updates to avoid freezing
val now = System.currentTimeMillis()
if (now - lastDebugUpdate >= DEBUG_THROTTLE_MS) {
lastDebugUpdate = now
binding.tvDebugLog.text = debugLines.joinToString("\n")
if (debugVisible) {
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
}
}
}
}
// ─── ServerEventListener ──────────────────────────────────────────────────
override fun onClientConnected(address: String) {
runOnUiThread {
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
binding.tvClientCount.visibility = View.VISIBLE
}
}
override fun onClientDisconnected(address: String) {
runOnUiThread {
binding.tvClientCount.visibility = View.GONE
}
}
override fun onClientRequestedWeight() { /* Nothing extra needed */ }
// ─── UI helpers ───────────────────────────────────────────────────────────
private fun updateUiDisconnected() {
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
binding.tvWeight.text = "— — —"
binding.tvWeightHint.text = ""
binding.tvBattery.visibility = View.GONE
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.darker_gray))
binding.btnDisconnect.visibility = View.GONE
binding.rvDevices.visibility = View.VISIBLE
binding.btnScan.visibility = View.VISIBLE
}
private fun getLocalIpAddress(): String? {
return try {
NetworkInterface.getNetworkInterfaces().toList()
.flatMap { it.inetAddresses.toList() }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
} catch (e: Exception) { null }
}
private fun showDialog(title: String, message: String) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton("OK", null)
.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
// Proper semver comparison: only update if remote is strictly newer
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val len = maxOf(r.size, l.size)
for (i in 0 until len) {
val rv = r.getOrElse(i) { 0 }
val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
if (current.isEmpty()) return@Thread
if (isSemver && !semverNewer(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) {
// RECEIVER_EXPORTED required: ACTION_DOWNLOAD_COMPLETE is sent by the system DownloadManager
// (an external process), so NOT_EXPORTED would silently block the broadcast on API 33+.
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_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(
private val items: List<BleDeviceInfo>,
private val onClick: (BleDeviceInfo) -> Unit,
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val tvName: TextView = view.findViewById(R.id.tv_device_name)
val tvAddr: TextView = view.findViewById(R.id.tv_device_addr)
val tvRssi: TextView = view.findViewById(R.id.tv_device_rssi)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_device, parent, false)
return VH(view)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val info = items[position]
holder.tvName.text = info.name
holder.tvAddr.text = info.device.address
holder.tvRssi.text = info.proximity
holder.itemView.setOnClickListener { onClick(info) }
}
override fun getItemCount() = items.size
}
}
@@ -1,208 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
// --- Data model ---
/**
* A single weight reading from a BLE scale.
* [value] is in the scale's current display unit (grams, oz, ml, lb).
* [unit] is "g", "oz", "ml", or "lb".
*/
data class WeightReading(
val value: Float,
val unit: String,
val stable: Boolean,
)
// --- UUIDs ---
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
object BleUuids {
// BLE SIG Weight Scale (some kitchen scales use this)
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
// Battery
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
// Common vendor services used by kitchen scales
val FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
val FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
val FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
// Acaia / Brewista coffee scales
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
// QN/Yolanda food scale secondary service (QN-KS, etc.)
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
}
// --- Food scale protocol parser ---
object ScaleProtocol {
// Plausible kitchen scale range
private const val MAX_GRAMS = 15000f
private const val MIN_GRAMS = 0.5f // allow tare/small values
fun resetState() { /* reserved for future use */ }
fun parse(
char: BluetoothGattCharacteristic,
data: ByteArray,
debug: ((String) -> Unit)? = null,
): WeightReading? {
if (data.size < 2) {
debug?.invoke("skip: packet too short (" + data.size + "B)")
return null
}
// UUID-specific parsers
when (char.uuid) {
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
}
// QN/Yolanda food scale (QN-KS, BC-KS, etc.):
// 18-byte frame starting with 0x10 0x12 on FFF1
if (data.size == 18
&& (data[0].toInt() and 0xFF) == 0x10
&& (data[1].toInt() and 0xFF) == 0x12) {
return parseQNFood(data, debug)
}
return parseGeneric(data, debug)
}
// -------------------------------------------------------------------------
// BLE SIG 0x2A9D Weight Measurement
// -------------------------------------------------------------------------
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) return null
val flags = data[0].toInt() and 0xFF
val isImperial = (flags and 0x01) != 0
val raw = u16le(data, 1)
return if (isImperial) {
val lb = raw * 0.01f
debug?.invoke("SIG 2A9D: raw=$raw -> ${lb}lb")
if (lb < 0.01f || lb > 33f) null
else WeightReading(lb, "lb", stable = true)
} else {
val g = raw * 5f // 0.005 kg resolution = 5 g/unit
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
if (g < MIN_GRAMS || g > MAX_GRAMS) null
else WeightReading(g, "g", stable = true)
}
}
// -------------------------------------------------------------------------
// QN / Yolanda food scale (QN-KS, BC-KS, YolandaKS, ...)
//
// 18-byte notification on service 0xFFF0, char 0xFFF1:
// [0x10][0x12][00][??][unit][02][05][01][flags][w_hi][w_lo][7E][1F][02][58][02][01][crc]
// index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
//
// weight = u16BE(data, 9) / 10.0 (0.1-unit resolution)
// unit = byte[4]: 0x01=g, 0x02=oz, 0x03=ml(water), 0x04=ml(milk)
// stable = bit3 of byte[8] != 0 (0xF8=stable, 0xF0=settling)
// crc = sum(bytes[0..16]) mod 256
// -------------------------------------------------------------------------
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
// Verify checksum
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
if (calc != (data[17].toInt() and 0xFF)) {
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(calc, data[17].toInt() and 0xFF))
return null
}
val rawValue = u16be(data, 9)
val stable = (data[8].toInt() and 0x08) != 0
val unit = when (data[4].toInt() and 0xFF) {
0x01 -> "g"
0x02 -> "oz"
0x03 -> "ml" // water mode
0x04 -> "ml" // milk mode
else -> "g"
}
// Resolution is 0.1 unit (e.g. 170 raw = 17.0 g, 195 raw = 19.5 g)
val value = rawValue / 10f
debug?.invoke("QN-KS: ${value}${unit} stable=$stable (raw=$rawValue unit_byte=0x%02X)".format(data[4].toInt() and 0xFF))
if (rawValue == 0) return null
// Convert to grams for range check
val valueG = if (unit == "oz") value * 28.3495f else value
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
return WeightReading(value, unit, stable)
}
// -------------------------------------------------------------------------
// Generic fallback parser
// Tries common frame layouts used by many BLE kitchen scales.
// -------------------------------------------------------------------------
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) {
debug?.invoke("generic: skip short packet (" + data.size + "B)")
return null
}
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
val candidates = listOf(
// Direct grams (1g resolution)
C(1, false, 1f, "pos1 LE g"),
C(1, true, 1f, "pos1 BE g"),
C(2, false, 1f, "pos2 LE g"),
C(2, true, 1f, "pos2 BE g"),
C(3, false, 1f, "pos3 LE g"),
C(3, true, 1f, "pos3 BE g"),
// 0.1g resolution (high-precision scales)
C(1, false, 10f, "pos1 LE 0.1g"),
C(1, true, 10f, "pos1 BE 0.1g"),
C(2, false, 10f, "pos2 LE 0.1g"),
C(2, true, 10f, "pos2 BE 0.1g"),
C(3, false, 10f, "pos3 LE 0.1g"),
C(3, true, 10f, "pos3 BE 0.1g"),
// 0.5g resolution
C(1, false, 2f, "pos1 LE 0.5g"),
C(1, true, 2f, "pos1 BE 0.5g"),
// Raw = centgrams (raw*10 = g)
C(1, false, 0.1f, "pos1 LE cg"),
C(1, true, 0.1f, "pos1 BE cg"),
C(3, false, 0.1f, "pos3 LE cg"),
C(3, true, 0.1f, "pos3 BE cg"),
)
for (c in candidates) {
if (c.pos + 1 >= data.size) continue
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
if (raw == 0) continue
val g = raw / c.div
if (g in MIN_GRAMS..MAX_GRAMS) {
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
return WeightReading(g, "g", stable = false)
}
}
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
return null
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun u16le(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
private fun u16be(b: ByteArray, off: Int): Int =
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6C63FF"
android:pathData="M0,0h108v108H0z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30L54,70 M40,38L68,38 M36,54L44,38 M64,54L72,38 M36,54C36,56 44,56 44,54 M64,54C64,56 72,56 72,54" />
</vector>
@@ -1,340 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<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"
android:orientation="vertical"
android:padding="16dp">
<!-- ── Header ─────────────────────────────────────────────────────── -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚖️ EverShelf Scale Gateway"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="#1E293B"
android:paddingBottom="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="16dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Connect your smart scale to EverShelf via Bluetooth"
android:textSize="13sp"
android:textColor="#64748B" />
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="v?.?.?"
android:textSize="11sp"
android:textColor="#94A3B8"
android:fontFamily="monospace"
android:gravity="end" />
</LinearLayout>
<!-- ── Gateway URL card ───────────────────────────────────────────── -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="#EFF6FF">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🌐 Gateway URL (paste into EverShelf)"
android:textSize="12sp"
android:textColor="#64748B"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_gateway_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="ws://…:8765"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#1D4ED8"
android:fontFamily="monospace"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_gateway_url_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Settings → Smart Scale"
android:textSize="11sp"
android:textColor="#94A3B8"
android:paddingBottom="8dp" />
<Button
android:id="@+id/btn_copy_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📋 Copy URL"
android:backgroundTint="#1D4ED8"
android:textColor="#FFFFFF"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- ── Gateway status ────────────────────────────────────────────── -->
<TextView
android:id="@+id/tv_gateway_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⏳ Starting gateway…"
android:textSize="13sp"
android:textColor="#64748B"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_client_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#059669"
android:paddingBottom="12dp"
android:visibility="gone" />
<!-- ── Scale connection card ──────────────────────────────────────── -->
<androidx.cardview.widget.CardView
android:id="@+id/card_connection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="@android:color/darker_gray">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_scale_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚡ Ready — scan for a scale"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:paddingBottom="8dp" />
<TextView
android:id="@+id/tv_weight"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="— — —"
android:textSize="46sp"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_weight_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#E2E8F0"
android:gravity="center"
android:paddingBottom="8dp" />
<TextView
android:id="@+id/tv_battery"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#E2E8F0"
android:visibility="gone" />
<Button
android:id="@+id/btn_disconnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔌 Disconnect scale"
android:backgroundTint="#EF4444"
android:textColor="#FFFFFF"
android:visibility="gone"
android:layout_marginTop="8dp"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- ── Scan controls ──────────────────────────────────────────────── -->
<Button
android:id="@+id/btn_scan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔍 Scan for Bluetooth Scales"
android:backgroundTint="#7C3AED"
android:textColor="#FFFFFF"
android:layout_marginBottom="8dp"
style="@style/Widget.MaterialComponents.Button" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<Button
android:id="@+id/btn_debug"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="\uD83D\uDC1B Debug"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:layout_marginEnd="4dp"
style="@style/Widget.MaterialComponents.Button" />
<Button
android:id="@+id/btn_copy_log"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\uD83D\uDCCB"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:minWidth="48dp"
android:layout_marginEnd="4dp"
android:visibility="gone"
style="@style/Widget.MaterialComponents.Button" />
<Button
android:id="@+id/btn_share_log"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\uD83D\uDCE4"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:minWidth="48dp"
android:visibility="gone"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
<ScrollView
android:id="@+id/sv_debug_log"
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_marginBottom="12dp"
android:background="#111827"
android:visibility="gone">
<TextView
android:id="@+id/tv_debug_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="10sp"
android:fontFamily="monospace"
android:textColor="#4ADE80"
android:padding="8dp" />
</ScrollView>
<TextView
android:id="@+id/tv_scan_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Press to scan for nearby BLE scales.\nMake sure the scale is turned on."
android:textSize="12sp"
android:textColor="#64748B"
android:paddingBottom="12dp"
android:visibility="gone" />
<!-- ── Device list ─────────────────────────────────────────────────── -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView 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="wrap_content"
android:layout_marginBottom="8dp"
app:cardCornerRadius="10dp"
app:cardElevation="1dp"
app:cardBackgroundColor="#FFFFFF">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="14dp"
android:gravity="center_vertical">
<TextView
android:layout_width="32dp"
android:layout_height="32dp"
android:text="⚖️"
android:textSize="20sp"
android:gravity="center"
android:layout_marginEnd="12dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#1E293B" />
<TextView
android:id="@+id/tv_device_addr"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="11sp"
android:fontFamily="monospace"
android:textColor="#94A3B8" />
</LinearLayout>
<TextView
android:id="@+id/tv_device_rssi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="#64748B" />
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
<color name="accent">#7C3AED</color>
<color name="green">#059669</color>
<color name="red">#EF4444</color>
<color name="blue">#1D4ED8</color>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Scale Gateway</string>
</resources>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- App-private external dir: no storage permission needed -->
<external-files-path name="apk_downloads" path="." />
</paths>
-5
View File
@@ -1,5 +0,0 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
@@ -1,2 +0,0 @@
android.useAndroidX=true
android.enableJetifier=true
@@ -1,6 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -1,17 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "EverShelf Scale Gateway"
include(":app")
+3 -2
View File
@@ -67,7 +67,7 @@
<!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.12</span>
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.13</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -407,7 +407,7 @@
<div class="use-inventory-info" id="use-inventory-info"></div>
<div id="use-expiry-hint" style="display:none"></div>
<form class="form" onsubmit="submitUse(event)">
<div class="form-group">
<div class="form-group" id="use-location-group">
<label>📍 Da dove?</label>
<div class="location-selector" id="use-location-selector">
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button>
@@ -1522,6 +1522,7 @@
<button class="cooking-tts-btn" id="cooking-tts-btn" onclick="toggleCookingTTS()" title="Leggi ad alta voce">🔊</button>
</div>
<div id="cooking-timers-bar" class="cooking-timers-bar" style="display:none"></div>
<div id="cooking-tools-bar" class="cooking-tools-bar" style="display:none"></div>
<div class="cooking-body">
<div class="cooking-step-header">
<div class="cooking-step-num" id="cooking-step-num">1 / 1</div>
+3 -1
View File
@@ -341,6 +341,7 @@
"regenerate": "🔄 Noch eins generieren",
"close_btn": "✅ Schließen",
"ingredients_title": "🧾 Zutaten",
"tools_title": "Benötigte Geräte",
"steps_title": "👨‍🍳 Zubereitung",
"no_steps": "Keine Zubereitungsschritte verfügbar",
"generate_error": "Fehler bei der Generierung",
@@ -717,7 +718,8 @@
"opened_suffix": "— Zu lange geöffnet!",
"opened_suffix_ok": "— Geöffnet (noch ok)",
"opened_suffix_warning": "— Geöffnet (erst prüfen)",
"days_compact": "{n}T"
"days_compact": "{n}T",
"badge_check_soon": "Bald prüfen"
},
"status": {
"ok": "OK",
+3 -1
View File
@@ -341,6 +341,7 @@
"regenerate": "🔄 Generate another one",
"close_btn": "✅ Close",
"ingredients_title": "🧾 Ingredients",
"tools_title": "Equipment needed",
"steps_title": "👨‍🍳 Steps",
"no_steps": "No steps available",
"generate_error": "Generation error",
@@ -717,7 +718,8 @@
"opened_suffix": "— Opened too long!",
"opened_suffix_ok": "— Opened (still ok)",
"opened_suffix_warning": "— Opened (check first)",
"days_compact": "{n}d"
"days_compact": "{n}d",
"badge_check_soon": "Check soon"
},
"status": {
"ok": "OK",
+3 -1
View File
@@ -341,6 +341,7 @@
"regenerate": "🔄 Generane un'altra",
"close_btn": "✅ Chiudi",
"ingredients_title": "🧾 Ingredienti",
"tools_title": "Strumenti necessari",
"steps_title": "👨‍🍳 Procedimento",
"no_steps": "Nessun procedimento disponibile",
"generate_error": "Errore nella generazione",
@@ -717,7 +718,8 @@
"opened_suffix": "— Aperto da troppo tempo!",
"opened_suffix_ok": "— Aperto (ancora ok)",
"opened_suffix_warning": "— Aperto (controlla)",
"days_compact": "{n}gg"
"days_compact": "{n}gg",
"badge_check_soon": "Controlla presto"
},
"status": {
"ok": "OK",