Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb4ae13f1 |
@@ -1 +1 @@
|
||||
ko_fi: evershelfproject
|
||||
ko_fi: dadaloop82
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: '8.6'
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: '8.4'
|
||||
|
||||
|
||||
@@ -119,63 +119,3 @@ 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,7 +7,6 @@ on:
|
||||
- 'Dockerfile'
|
||||
- 'docker-compose.yml'
|
||||
- 'api/**'
|
||||
- '.github/workflows/security.yml'
|
||||
schedule:
|
||||
# Run weekly on Monday at 07:00 UTC
|
||||
- cron: '0 7 * * 1'
|
||||
@@ -28,17 +27,16 @@ jobs:
|
||||
run: docker build -t evershelf:scan .
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
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@v4
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
category: 'trivy-docker'
|
||||
@@ -54,18 +52,17 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy filesystem scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
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@v4
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-fs-results.sarif'
|
||||
category: 'trivy-fs'
|
||||
|
||||
@@ -49,4 +49,3 @@ evershelf-kiosk/local.properties
|
||||
data/error_reports.log
|
||||
data/latest_release_cache.json
|
||||
data/food_facts_cache.json
|
||||
data/category_ai_cache.json
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM php:8.2-apache-bookworm
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libonig-dev \
|
||||
|
||||
@@ -32,8 +32,6 @@
|
||||
[](https://github.com/dadaloop82/EverShelf/discussions)
|
||||
[](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
|
||||
|
||||
[](https://ko-fi.com/J3J01ZNETZ)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
@@ -104,7 +102,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.
|
||||
- **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.
|
||||
|
||||
### 📺 Android Kiosk Mode (Add-on)
|
||||
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
|
||||
@@ -352,35 +350,10 @@ The application uses no build tools — edit files directly and refresh.
|
||||
|
||||
## 📋 Roadmap
|
||||
|
||||
### 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)
|
||||
- [ ] User authentication / multi-user support
|
||||
- [ ] Offline mode with service worker
|
||||
- [ ] Export/import inventory data
|
||||
- [ ] Notification system (Telegram, email) for expiring products
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -138,14 +138,11 @@ 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
|
||||
)
|
||||
");
|
||||
// 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("INSERT INTO transactions SELECT * 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)");
|
||||
@@ -363,10 +360,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -416,7 +409,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 180;
|
||||
if (preg_match('/confettura|marmellata/', $n)) return 60;
|
||||
if (preg_match('/nutella|cioccolat/', $n)) return 60;
|
||||
|
||||
// ── H: Category fallbacks ────────────────────────────────────────────
|
||||
@@ -474,7 +467,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 = 30; // whole tubers in a bag, pantry: 3-5 weeks
|
||||
elseif (preg_match('/patata|patate/', $n)) $days = 14;
|
||||
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;
|
||||
|
||||
@@ -3020,7 +3020,6 @@ 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...',
|
||||
@@ -3043,7 +3042,6 @@ 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...',
|
||||
@@ -3066,7 +3064,6 @@ 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;
|
||||
@@ -3444,15 +3441,14 @@ 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`, `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.
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
|
||||
Rispondi SOLO JSON valido (no markdown):
|
||||
{$promptLanguageRule}
|
||||
{"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":"…"}
|
||||
{"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":"…"}
|
||||
PROMPT;
|
||||
|
||||
$payload = [
|
||||
@@ -4321,15 +4317,14 @@ 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`, `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.
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
|
||||
Rispondi SOLO JSON valido (no markdown):
|
||||
{$promptLanguageRule}
|
||||
{"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":"…"}
|
||||
{"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":"…"}
|
||||
PROMPT;
|
||||
|
||||
$genConfig = [
|
||||
|
||||
@@ -3918,29 +3918,6 @@ 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;
|
||||
@@ -4317,153 +4294,14 @@ body.cooking-mode-active .app-header {
|
||||
transform: scale(1.35);
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
font-size: clamp(1.4rem, 5vw, 2.2rem);
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
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); }
|
||||
text-align: center;
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cooking-replay-btn {
|
||||
@@ -4503,28 +4341,6 @@ 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;
|
||||
@@ -4760,32 +4576,6 @@ 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);
|
||||
|
||||
@@ -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 = 30; // whole tubers in a bag, pantry: 3-5 weeks
|
||||
else if (/patata|patate/.test(name)) days = 14;
|
||||
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, 30);
|
||||
else if (/patata|patate/.test(name)) days = Math.max(days, 21);
|
||||
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 180;
|
||||
if (/confettura|marmellata/.test(name)) return 60;
|
||||
if (/nutella|cioccolat/.test(name)) return 60;
|
||||
|
||||
// ── H: Category fallbacks ────────────────────────────────────────────
|
||||
@@ -2499,24 +2499,9 @@ 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) {
|
||||
@@ -3619,16 +3604,8 @@ async function loadDashboard() {
|
||||
if (days !== null && days !== undefined) {
|
||||
let expiryClass, expiryText;
|
||||
if (!isEdible) {
|
||||
// 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');
|
||||
@@ -4910,8 +4887,8 @@ function showItemDetail(inventoryId, productId) {
|
||||
function closeModal() {
|
||||
document.getElementById('modal-overlay').style.display = 'none';
|
||||
clearMoveModalTimer();
|
||||
// Native kiosk settings button is permanently replaced by the web overlay button — keep hidden.
|
||||
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
|
||||
// Restore native kiosk settings button visibility
|
||||
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {}
|
||||
_cancelScaleAutoConfirm(false);
|
||||
_scaleRecipeAutoFillPaused = false;
|
||||
_scaleUserDismissed = false;
|
||||
@@ -7626,9 +7603,6 @@ 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);
|
||||
@@ -7827,42 +7801,6 @@ 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) || '{}');
|
||||
@@ -8215,22 +8153,6 @@ 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>`
|
||||
@@ -8264,11 +8186,6 @@ 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;
|
||||
@@ -8280,14 +8197,9 @@ async function _saveVacuumAndStay(openedId) {
|
||||
showPage('dashboard');
|
||||
}
|
||||
|
||||
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVacuum) {
|
||||
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
|
||||
clearMoveModalTimer();
|
||||
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;
|
||||
}
|
||||
const newVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
|
||||
closeModal();
|
||||
showLoading(true);
|
||||
try {
|
||||
@@ -11937,36 +11849,6 @@ 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>`;
|
||||
|
||||
@@ -11984,14 +11866,6 @@ 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) => {
|
||||
@@ -12127,25 +12001,8 @@ 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);
|
||||
@@ -12159,9 +12016,6 @@ 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 */ }
|
||||
@@ -12494,10 +12348,7 @@ function _initBrowserTtsVoices(selectedVoice) {
|
||||
sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
|
||||
|
||||
const populate = () => {
|
||||
let voices = [];
|
||||
try {
|
||||
voices = (window.speechSynthesis.getVoices() || []).filter(v => v != null && v.lang);
|
||||
} catch (_) { return false; }
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
if (!voices.length) return false;
|
||||
// Italian voices first, then others
|
||||
const it = voices.filter(v => v.lang.startsWith('it'));
|
||||
|
||||
@@ -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 **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
|
||||
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (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 "Auto-discover"** to let the wizard scan your LAN:
|
||||
**Or tap "Rileva automaticamente"** 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,23 +62,9 @@ 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 overlay (top-left). A confirmation dialog appears.
|
||||
Tap the **✕** button in the header (top-left). A confirmation dialog appears.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,6 +133,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` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
|
||||
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK |
|
||||
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
|
||||
| `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
|
||||
| `REORDER_TASKS` | Bring app to foreground after gateway launch |
|
||||
|
||||
@@ -43,13 +43,15 @@ docker compose up -d
|
||||
|
||||
## AI Features
|
||||
|
||||
### "AI not available" error
|
||||
### AI features don't work / "AI non disponibile"
|
||||
|
||||
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
|
||||
@@ -60,7 +62,7 @@ max_execution_time = 120
|
||||
|
||||
## Shopping List (Bring!)
|
||||
|
||||
### "Bring! not configured" message in the shopping tab
|
||||
### "Bring! non configurato" message in the shopping tab
|
||||
|
||||
Add your Bring! credentials to `.env`:
|
||||
|
||||
@@ -88,7 +90,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 **"⚖️ Read Scale"** first to activate the scale modal
|
||||
- Make sure you tapped "⚖️ Leggi bilancia" 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
|
||||
@@ -107,19 +109,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)
|
||||
|
||||
### Kiosk app update fails
|
||||
### Gateway install fails with an error dialog
|
||||
|
||||
The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
|
||||
The dialog shows the exact failure code. Common causes:
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### Exit button (✕) is not visible
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### App is stuck in kiosk mode after a crash
|
||||
|
||||
@@ -137,7 +139,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 **"Load more"** to load more. Entries older than the database creation date won't appear.
|
||||
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.
|
||||
|
||||
### "Can only undo transactions within 24 hours"
|
||||
|
||||
|
||||
@@ -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:
|
||||
- "What can I make with eggs and pasta?"
|
||||
- "How long does cooked ham last once opened in the fridge?"
|
||||
- "Suggest a quick snack"
|
||||
- "Cosa posso fare con le uova e la pasta?"
|
||||
- "Quanti giorni dura il prosciutto cotto aperto in frigo?"
|
||||
- "Suggeriscimi uno spuntino veloce"
|
||||
|
||||
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 **▶ Start Cooking**.
|
||||
Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -132,7 +132,7 @@ Start cooking mode from any recipe by tapping **▶ Start Cooking**.
|
||||
- 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!" *(Enjoy your meal!)* spoken on the last step
|
||||
- **Recipe completion** — "Buon appetito!" 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
|
||||
- "🤖 Explain" for AI explanation
|
||||
- Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
|
||||
- "🤖 Spiega" for AI explanation
|
||||
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)")
|
||||
|
||||
### Anti-Waste Report
|
||||
|
||||
|
||||
@@ -47,11 +47,8 @@ All data stays on your server. No cloud, no subscriptions.
|
||||
## 🆕 What's New
|
||||
|
||||
### v1.7.13 (2026-05-16)
|
||||
- **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
|
||||
- **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
|
||||
|
||||
### v1.7.12 (2026-05-13)
|
||||
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
|
||||
@@ -84,7 +81,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) — DEPRECATED, built into kiosk since v1.6.0
|
||||
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +75,7 @@ In EverShelf **Settings → Scale**:
|
||||
|
||||
### 4. Connect your scale
|
||||
|
||||
Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +84,7 @@ Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in t
|
||||
When scale integration is enabled:
|
||||
|
||||
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
|
||||
2. A **"⚖️ Read Scale"** button appears
|
||||
2. A **"⚖️ Leggi bilancia"** 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 "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
|
||||
- Tap "Disconnetti / Riconnetti" 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)
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 14
|
||||
versionName = "1.7.13"
|
||||
versionCode = 13
|
||||
versionName = "1.7.2"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# Build trigger: versionName 1.7.13 fix (8d87494)
|
||||
# Build trigger: TTS bridge fix (95389eb)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
*.apk
|
||||
*.aab
|
||||
*.class
|
||||
*.dex
|
||||
@@ -0,0 +1,156 @@
|
||||
# ~~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)
|
||||
@@ -0,0 +1,41 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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 6–11) -->
|
||||
<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>
|
||||
@@ -0,0 +1,455 @@
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Centralized error reporter for EverShelf Scale Gateway.
|
||||
*
|
||||
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
|
||||
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
|
||||
* calls the GitHub Issues REST API directly.
|
||||
*
|
||||
* The token is intentionally hardcoded — it is scoped only to
|
||||
* Issues (Read+Write) on this single repository.
|
||||
*
|
||||
* Usage:
|
||||
* ErrorReporter.init(applicationContext)
|
||||
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
|
||||
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
|
||||
*/
|
||||
object ErrorReporter {
|
||||
|
||||
private const val TAG = "ScaleGWErrorReporter"
|
||||
|
||||
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
|
||||
// Stored encoded so the literal token string never appears in source or git history.
|
||||
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
|
||||
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
|
||||
private const val GH_REPO = "dadaloop82/EverShelf"
|
||||
|
||||
private var _ghTokenCache: String? = null
|
||||
private fun ghToken(): String {
|
||||
_ghTokenCache?.let { return it }
|
||||
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val key = GH_TOKEN_KEY
|
||||
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
|
||||
_ghTokenCache = out
|
||||
return out
|
||||
}
|
||||
|
||||
// SharedPreferences key for pending (unsent) crash reports
|
||||
private const val PREFS_NAME = "evershelf_scalegw_errors"
|
||||
private const val KEY_PENDING = "pending_crash_json"
|
||||
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val sentFingerprints = mutableSetOf<String>()
|
||||
|
||||
private var appVersion: String = "unknown"
|
||||
private var deviceInfo: String = ""
|
||||
private lateinit var appContext: Context
|
||||
|
||||
/**
|
||||
* Call once in MainActivity.onCreate() or Application.onCreate().
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
|
||||
try {
|
||||
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
appVersion = pi.versionName ?: "unknown"
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Send any crash report that was saved from the previous session
|
||||
sendPendingCrash()
|
||||
|
||||
// Install global UncaughtExceptionHandler
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val crash = buildPayload(
|
||||
type = "uncaught-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = mapOf("thread" to thread.name)
|
||||
)
|
||||
// Save to prefs first (in case network POST fails before process dies)
|
||||
savePendingCrash(crash)
|
||||
// Try immediate send (synchronous — we're already off main thread in the handler)
|
||||
postToGitHub(crash)
|
||||
clearPendingCrash()
|
||||
} catch (_: Exception) {}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a caught [Throwable] asynchronously. */
|
||||
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
if (location.isNotEmpty()) ctx["location"] = location
|
||||
ctx.putAll(extra)
|
||||
enqueue(
|
||||
type = "scale-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = ctx
|
||||
)
|
||||
}
|
||||
|
||||
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
|
||||
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
ctx.putAll(extra)
|
||||
enqueue(type = type, message = message, stack = "", context = ctx)
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun fingerprint(type: String, message: String) =
|
||||
"${type}:${message.take(120)}".hashCode().toString(16)
|
||||
|
||||
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val fp = fingerprint(type, message)
|
||||
synchronized(sentFingerprints) {
|
||||
if (!sentFingerprints.add(fp)) return
|
||||
}
|
||||
val payload = buildPayload(type, message, stack, context)
|
||||
executor.execute { postToGitHub(payload) }
|
||||
}
|
||||
|
||||
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
|
||||
val ctxJson = JSONObject()
|
||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
||||
return JSONObject().apply {
|
||||
put("source", "scale")
|
||||
put("type", type)
|
||||
put("message", message)
|
||||
put("stack", stack)
|
||||
put("context", ctxJson)
|
||||
put("version", appVersion)
|
||||
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
|
||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist crash payload to SharedPreferences so it survives a process kill. */
|
||||
private fun savePendingCrash(payload: JSONObject) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_PENDING, payload.toString()).apply()
|
||||
}
|
||||
|
||||
private fun clearPendingCrash() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().remove(KEY_PENDING).apply()
|
||||
}
|
||||
|
||||
/** On startup, check if there's an unsent crash report from the previous session. */
|
||||
private fun sendPendingCrash() {
|
||||
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PENDING, null) ?: return
|
||||
clearPendingCrash() // remove before sending to prevent re-sending on next crash
|
||||
executor.execute {
|
||||
try {
|
||||
val payload = JSONObject(json)
|
||||
// Tag it as a "survived-crash" so we know it was saved and retried
|
||||
payload.put("type", "uncaught-exception-survived")
|
||||
payload.put("note", "Sent on next launch after crash")
|
||||
postToGitHub(payload)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
|
||||
* Uses the GitHub Issues Search API to deduplicate.
|
||||
*/
|
||||
private fun postToGitHub(payload: JSONObject) {
|
||||
val source = payload.optString("source", "scale")
|
||||
val type = payload.optString("type", "error")
|
||||
val message = payload.optString("message", "")
|
||||
val stack = payload.optString("stack", "")
|
||||
val version = payload.optString("version", "")
|
||||
val ua = payload.optString("user_agent", "")
|
||||
val ts = payload.optString("ts", "")
|
||||
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
|
||||
|
||||
val fp = fingerprint(type, message)
|
||||
|
||||
// ── 1. Search for existing open issue ──────────────────────────────
|
||||
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
|
||||
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
|
||||
val searchResult = ghGet(searchUrl) ?: JSONObject()
|
||||
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
|
||||
|
||||
// ── 2. Build body ─────────────────────────────────────────────────
|
||||
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
|
||||
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
|
||||
|
||||
if (existingNumber != null) {
|
||||
// Comment on existing issue
|
||||
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:${fp}_"
|
||||
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
|
||||
} else {
|
||||
// Create new issue
|
||||
val shortMsg = if (message.length > 70) "${message.take(70)}…" else message
|
||||
val title = "[SCALE] $shortMsg"
|
||||
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`${fp}`_"
|
||||
ghPost(
|
||||
"https://api.github.com/repos/$GH_REPO/issues",
|
||||
JSONObject()
|
||||
.put("title", title)
|
||||
.put("body", body)
|
||||
.put("labels", JSONArray().put("auto-report").put("scale-error"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ghGet(url: String): JSONObject? = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
|
||||
conn.disconnect()
|
||||
JSONObject(raw)
|
||||
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
|
||||
|
||||
private fun ghPost(url: String, payload: JSONObject): Int = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
Log.d(TAG, "ghPost $url → HTTP $code")
|
||||
code
|
||||
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
@@ -0,0 +1,340 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 546 B |
|
After Width: | Height: | Size: 546 B |
@@ -0,0 +1,12 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Scale Gateway</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- App-private external dir: no storage permission needed -->
|
||||
<external-files-path name="apk_downloads" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,5 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "EverShelf Scale Gateway"
|
||||
include(":app")
|
||||
@@ -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.13</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.12</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" id="use-location-group">
|
||||
<div class="form-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,7 +1522,6 @@
|
||||
<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>
|
||||
|
||||
@@ -341,7 +341,6 @@
|
||||
"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",
|
||||
@@ -718,8 +717,7 @@
|
||||
"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",
|
||||
"badge_check_soon": "Bald prüfen"
|
||||
"days_compact": "{n}T"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
|
||||
@@ -341,7 +341,6 @@
|
||||
"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",
|
||||
@@ -718,8 +717,7 @@
|
||||
"opened_suffix": "— Opened too long!",
|
||||
"opened_suffix_ok": "— Opened (still ok)",
|
||||
"opened_suffix_warning": "— Opened (check first)",
|
||||
"days_compact": "{n}d",
|
||||
"badge_check_soon": "Check soon"
|
||||
"days_compact": "{n}d"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
|
||||
@@ -341,7 +341,6 @@
|
||||
"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",
|
||||
@@ -718,8 +717,7 @@
|
||||
"opened_suffix": "— Aperto da troppo tempo!",
|
||||
"opened_suffix_ok": "— Aperto (ancora ok)",
|
||||
"opened_suffix_warning": "— Aperto (controlla)",
|
||||
"days_compact": "{n}gg",
|
||||
"badge_check_soon": "Controlla presto"
|
||||
"days_compact": "{n}gg"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
|
||||