kiosk: integrate BLE scale gateway + fix logo/branding

- Kiosk v1.6.0 (versionCode 10)
  - Integrate BLE scale gateway directly into kiosk app (no external app needed)
    - New scale/ package: BleScaleManager, GatewayWebSocketServer, ScaleProtocol, GatewayService
    - GatewayService: foreground service, runs BLE scan + WebSocket :8765 server
    - Auto-reconnect on BLE disconnect; protocol compatible with old gateway app
  - Setup step 4: replace gateway install flow with BLE device scan + selection (mandatory)
  - Permissions: added BLUETOOTH_SCAN, BLUETOOTH_CONNECT, ACCESS_FINE_LOCATION (pre-S),
    FOREGROUND_SERVICE, FOREGROUND_SERVICE_CONNECTED_DEVICE
  - KioskActivity: replace launchGatewayInBackground() with startGatewayService()
  - checkForUpdates: remove gateway APK check (gateway is now internal)
  - Remove GATEWAY_PACKAGE / GATEWAY_DOWNLOAD_URL constants

- Logo / branding
  - logo.png + logo_icon.png: transparent background (no more black)
  - ic_logo.png regenerated in all densities
  - Removed house emoji (🏠) from web UI: favicon, bottom nav, setup wizard header
  - Removed 🏠 prefix from all translations (it/en/de) and manifest
  - Setup wizard: logo shown in language + welcome steps
  - Setup wizard: footer with credits ('Creato da Stimpfl Daniel • Open Source')
  - CSS: .nav-logo-icon for bottom nav logo sizing

- Scale Gateway v2.1.1 (versionCode 8)
  - Fix false update notification: replace == comparison with proper semverNewer()
    (was reporting 'update available' whenever tag != current, e.g. v2.1.0 != 2.1.0)
This commit is contained in:
dadaloop82
2026-05-05 17:24:24 +00:00
parent 8ee6fe8770
commit 9cb29de1f0
24 changed files with 1125 additions and 765 deletions
+10
View File
@@ -2575,6 +2575,16 @@ body {
.nav-icon { .nav-icon {
font-size: 1.4rem; font-size: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
}
.nav-logo-icon {
height: 24px;
width: auto;
object-fit: contain;
display: block;
} }
.nav-label { .nav-label {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 261 KiB

+4 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk" applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = 9 versionCode = 10
versionName = "1.5.3" versionName = "1.6.0"
} }
signingConfigs { signingConfigs {
@@ -59,4 +59,6 @@ dependencies {
implementation("com.google.android.material:material:1.11.0") implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.webkit:webkit:1.10.0") implementation("androidx.webkit:webkit:1.10.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("org.java-websocket:Java-WebSocket:1.5.5")
} }
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network --> <!-- Network -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -20,16 +21,28 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Move task to front (bring kiosk back after gateway launch) --> <!-- Move task to front -->
<uses-permission android:name="android.permission.REORDER_TASKS" /> <uses-permission android:name="android.permission.REORDER_TASKS" />
<!-- Self-update: install APK downloaded at runtime --> <!-- Self-update: install own APK at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Query gateway app visibility (required Android 11+) --> <!-- ── BLE Scale Gateway (integrated) ───────────────────────────── -->
<queries> <!-- Legacy BLE permissions (Android ≤ 11) -->
<package android:name="it.dadaloop.evershelf.scalegate" /> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
</queries> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Fine location required for BLE scan on Android < 12 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<!-- Android 12+ BLE permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Foreground service for keeping BLE+WebSocket alive -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- BLE hardware — not required, app gracefully disables scale if absent -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -64,6 +77,12 @@
android:exported="false" android:exported="false"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" /> android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" />
<!-- GatewayService: runs BLE scan + WebSocket server as a foreground service -->
<service
android:name=".scale.GatewayService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<!-- FileProvider for serving the downloaded APK to the installer --> <!-- FileProvider for serving the downloaded APK to the installer -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -2,7 +2,6 @@ package it.dadaloop.evershelf.kiosk
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.Manifest import android.Manifest
import android.app.ActivityManager
import android.app.DownloadManager import android.app.DownloadManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@@ -97,8 +96,6 @@ class KioskActivity : AppCompatActivity() {
private const val KEY_SETUP_COMPLETE = "setup_complete" private const val KEY_SETUP_COMPLETE = "setup_complete"
private const val KEY_HAS_SCALE = "has_scale" private const val KEY_HAS_SCALE = "has_scale"
private const val KEY_SCREENSAVER = "screensaver_enabled" private const val KEY_SCREENSAVER = "screensaver_enabled"
private const val GATEWAY_PACKAGE = "it.dadaloop.evershelf.scalegate"
private const val GATEWAY_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
private const val SPLASH_DURATION = 1500L private const val SPLASH_DURATION = 1500L
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest" private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
@@ -222,25 +219,13 @@ class KioskActivity : AppCompatActivity() {
} }
} }
// ── Gateway ──────────────────────────────────────────────────────────── // ── Gateway Service ────────────────────────────────────────────────────
private fun isGatewayInstalled(): Boolean { private fun startGatewayService() {
return try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0)
true
} catch (e: PackageManager.NameNotFoundException) { false }
}
private fun launchGatewayInBackground() {
if (!prefs.getBoolean(KEY_HAS_SCALE, false)) return if (!prefs.getBoolean(KEY_HAS_SCALE, false)) return
if (!isGatewayInstalled()) return val intent = Intent(this, it.dadaloop.evershelf.kiosk.scale.GatewayService::class.java)
val launchIntent = packageManager.getLaunchIntentForPackage(GATEWAY_PACKAGE) ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent)
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) else startService(intent)
startActivity(launchIntent)
Handler(Looper.getMainLooper()).postDelayed({
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
am.moveTaskToFront(taskId, ActivityManager.MOVE_TASK_WITH_HOME)
}, 1500)
} }
// ── Install UI ──────────────────────────────────────────────────────── // ── Install UI ────────────────────────────────────────────────────────
@@ -312,9 +297,9 @@ class KioskActivity : AppCompatActivity() {
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
private fun launchWebView() { private fun launchWebView() {
// Start gateway BEFORE entering kiosk lock — in lock task mode Android blocks // Start BLE gateway service BEFORE entering kiosk lock — in lock task mode Android blocks
// startActivity() for other packages, so the gateway would never launch. // startForegroundService() for foreground services, so we must start before lockTask.
launchGatewayInBackground() startGatewayService()
// Ensure kiosk lock and permissions are active // Ensure kiosk lock and permissions are active
enableKioskLock() enableKioskLock()
@@ -510,9 +495,6 @@ class KioskActivity : AppCompatActivity() {
val currentKiosk = try { val currentKiosk = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: "" packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" } } catch (_: Exception) { "" }
val currentGateway = try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: ""
} catch (_: Exception) { null }
val norm = { v: String -> v.trimStart('v') } val norm = { v: String -> v.trimStart('v') }
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*")) val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
@@ -531,39 +513,24 @@ class KioskActivity : AppCompatActivity() {
} }
val assets = json.optJSONArray("assets") val assets = json.optJSONArray("assets")
var kioskApkUrl = "" var kioskApkUrl = ""
var gatewayApkUrl = ""
if (assets != null) { if (assets != null) {
for (i in 0 until assets.length()) { for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i) val a = assets.getJSONObject(i)
val name = a.optString("name", "").lowercase() val name = a.optString("name", "").lowercase()
val url = a.optString("browser_download_url", "") val url = a.optString("browser_download_url", "")
if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) gatewayApkUrl = url
} }
} }
val kioskNeedsUpdate = kioskApkUrl.isNotEmpty() && currentKiosk.isNotEmpty() && val kioskNeedsUpdate = kioskApkUrl.isNotEmpty() && currentKiosk.isNotEmpty() &&
(!isSemver || semverNewer(norm(latestTag), norm(currentKiosk))) (!isSemver || semverNewer(norm(latestTag), norm(currentKiosk)))
val gatewayNeedsUpdate = currentGateway != null && gatewayApkUrl.isNotEmpty() &&
(!isSemver || semverNewer(norm(latestTag), norm(currentGateway)))
if (!kioskNeedsUpdate && !gatewayNeedsUpdate) return@Thread if (!kioskNeedsUpdate) return@Thread
val lines = mutableListOf<String>() val label = if (isSemver) "$currentKiosk$latestTag" else latestTag
var primaryApkUrl = "" val message = "🔄 Kiosk $label"
if (kioskNeedsUpdate) { runOnUiThread { showNativeUpdateBanner(message, kioskApkUrl) }
val label = if (isSemver) "$currentKiosk$latestTag" else latestTag
lines += "\uD83D\uDD04 Kiosk $label"
primaryApkUrl = kioskApkUrl
}
if (gatewayNeedsUpdate) {
val label = if (isSemver) "$currentGateway$latestTag" else latestTag
lines += "\uD83D\uDD04 Scale Gateway $label"
if (primaryApkUrl.isEmpty()) primaryApkUrl = gatewayApkUrl
}
val message = lines.joinToString("")
runOnUiThread { showNativeUpdateBanner(message, primaryApkUrl) }
} catch (_: Exception) { } } catch (_: Exception) { }
}.start() }.start()
} }
@@ -650,11 +617,8 @@ class KioskActivity : AppCompatActivity() {
file.delete() file.delete()
return return
} }
val targetPkg = when { // Only kiosk self-update is handled; gateway is now integrated
pendingApkDownloadUrl.contains("gateway", ignoreCase = true) || val targetPkg = packageName
pendingApkDownloadUrl.contains("scale", ignoreCase = true) -> GATEWAY_PACKAGE
else -> packageName
}
installWithPackageInstaller(file, targetPkg) installWithPackageInstaller(file, targetPkg)
} }
@@ -2,33 +2,35 @@ package it.dadaloop.evershelf.kiosk
import android.Manifest import android.Manifest
import android.app.AlertDialog import android.app.AlertDialog
import android.app.DownloadManager import android.bluetooth.BluetoothDevice
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.Settings import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.EditText import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
import it.dadaloop.evershelf.kiosk.scale.BleDeviceInfo
import it.dadaloop.evershelf.kiosk.scale.BleScaleListener
import it.dadaloop.evershelf.kiosk.scale.BleScaleManager
import it.dadaloop.evershelf.kiosk.scale.WeightReading
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.NetworkInterface import java.net.NetworkInterface
@@ -83,18 +85,19 @@ class SetupActivity : AppCompatActivity() {
private lateinit var btnDiscover: MaterialButton private lateinit var btnDiscover: MaterialButton
private lateinit var discoverStatus: TextView private lateinit var discoverStatus: TextView
// Scale step // Scale step (BLE)
private lateinit var scaleQuestionCard: LinearLayout private lateinit var scaleQuestionCard: LinearLayout
private lateinit var gatewayInfoCard: LinearLayout private lateinit var bleSetupCard: LinearLayout
private lateinit var gatewayInstallCard: LinearLayout private lateinit var tvScanStatus: TextView
private lateinit var gatewayStatusIcon: TextView private lateinit var btnScanBle: MaterialButton
private lateinit var gatewayStatusText: TextView private lateinit var tvSelectedScale: TextView
private lateinit var gatewayStatusDetail: TextView private lateinit var rvScaleDevices: RecyclerView
private lateinit var btnInstallGateway: MaterialButton private lateinit var step3NextButtons: LinearLayout
private lateinit var btnConfigureGateway: MaterialButton
private lateinit var gatewayProgressBar: ProgressBar private var bleManager: BleScaleManager? = null
private lateinit var gatewayProgressText: TextView private val discoveredDevices = mutableListOf<BleDeviceInfo>()
private lateinit var step3NextButtons: LinearLayout private var selectedDevice: BleDeviceInfo? = null
private var deviceAdapter: DeviceAdapter? = null
// Screensaver step // Screensaver step
private lateinit var setupSwitchScreensaver: SwitchMaterial private lateinit var setupSwitchScreensaver: SwitchMaterial
@@ -106,13 +109,6 @@ class SetupActivity : AppCompatActivity() {
private lateinit var permsGrantedCard: LinearLayout private lateinit var permsGrantedCard: LinearLayout
private lateinit var btnGrantPerms: MaterialButton private lateinit var btnGrantPerms: MaterialButton
// APK install state (for gateway)
private var pendingApkDownloadUrl = ""
private var pendingInstallFile: java.io.File? = null
private var pendingInstallPkg = ""
private val pollHandler = Handler(Looper.getMainLooper())
private var activeDownloadId: Long = -1
// Auto-discover cancellation flag // Auto-discover cancellation flag
private val discoverCancelled = AtomicBoolean(false) private val discoverCancelled = AtomicBoolean(false)
@@ -123,14 +119,8 @@ class SetupActivity : AppCompatActivity() {
private const val KEY_HAS_SCALE = "has_scale" private const val KEY_HAS_SCALE = "has_scale"
private const val KEY_LANGUAGE = "kiosk_language" private const val KEY_LANGUAGE = "kiosk_language"
private const val KEY_SCREENSAVER = "screensaver_enabled" private const val KEY_SCREENSAVER = "screensaver_enabled"
private const val GATEWAY_PACKAGE = "it.dadaloop.evershelf.scalegate"
private const val GATEWAY_DOWNLOAD_URL =
"https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
private const val INSTALL_PERM_REQUEST = 2001
private const val INSTALL_CONFIRM_REQUEST = 2002
private const val UNINSTALL_REQUEST = 2003
private const val PERMISSION_REQUEST_CODE = 2004 private const val PERMISSION_REQUEST_CODE = 2004
private const val INSTALL_FALLBACK_REQUEST = 2005 private const val BLE_PERMISSION_REQUEST = 2006
fun applyLocale(base: Context, lang: String): Context { fun applyLocale(base: Context, lang: String): Context {
val locale = Locale(lang) val locale = Locale(lang)
@@ -184,16 +174,22 @@ class SetupActivity : AppCompatActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
pollHandler.removeCallbacksAndMessages(null) bleManager?.stopScan()
bleManager?.disconnect()
discoverCancelled.set(true) discoverCancelled.set(true)
super.onDestroy() super.onDestroy()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// When returning from the gateway app (after pressing "Configura"), refresh status // If we're on step 4 with a saved device, reflect it in the UI
if (currentStep == 4 && gatewayInstallCard.visibility == View.VISIBLE) { if (currentStep == 4) {
checkGatewayStatus() val savedName = bleManager?.getSavedDeviceName()
if (savedName != null) {
tvSelectedScale.text = "$savedName"
tvSelectedScale.visibility = View.VISIBLE
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
}
} }
} }
@@ -217,17 +213,13 @@ class SetupActivity : AppCompatActivity() {
discoverStatus = findViewById(R.id.discoverStatus) discoverStatus = findViewById(R.id.discoverStatus)
// Scale step // Scale step
scaleQuestionCard = findViewById(R.id.scaleQuestionCard) scaleQuestionCard = findViewById(R.id.scaleQuestionCard)
gatewayInfoCard = findViewById(R.id.gatewayInfoCard) bleSetupCard = findViewById(R.id.bleSetupCard)
gatewayInstallCard = findViewById(R.id.gatewayInstallCard) tvScanStatus = findViewById(R.id.tvScanStatus)
gatewayStatusIcon = findViewById(R.id.gatewayStatusIcon) btnScanBle = findViewById(R.id.btnScanBle)
gatewayStatusText = findViewById(R.id.gatewayStatusText) tvSelectedScale = findViewById(R.id.tvSelectedScale)
gatewayStatusDetail = findViewById(R.id.gatewayStatusDetail) rvScaleDevices = findViewById(R.id.rvScaleDevices)
btnInstallGateway = findViewById(R.id.btnInstallGateway) step3NextButtons = findViewById(R.id.step3NextButtons)
btnConfigureGateway = findViewById(R.id.btnConfigureGateway)
gatewayProgressBar = findViewById(R.id.gatewayProgressBar)
gatewayProgressText = findViewById(R.id.gatewayProgressText)
step3NextButtons = findViewById(R.id.step3NextButtons)
// Screensaver step // Screensaver step
setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver) setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver)
@@ -277,32 +269,41 @@ class SetupActivity : AppCompatActivity() {
} }
// ── Scale ───────────────────────────────────────────────────────── // ── Scale ─────────────────────────────────────────────────────────
// Init BLE manager (lazy — needs context)
bleManager = BleScaleManager(this, makeBleListener())
// RecyclerView for discovered devices
deviceAdapter = DeviceAdapter { info -> onDeviceSelected(info) }
rvScaleDevices.layoutManager = LinearLayoutManager(this)
rvScaleDevices.adapter = deviceAdapter
findViewById<MaterialButton>(R.id.btnScaleYes).setOnClickListener { findViewById<MaterialButton>(R.id.btnScaleYes).setOnClickListener {
scaleQuestionCard.visibility = View.GONE prefs.edit().putBoolean(KEY_HAS_SCALE, true).apply()
gatewayInfoCard.visibility = View.VISIBLE scaleQuestionCard.visibility = View.GONE
gatewayInstallCard.visibility = View.VISIBLE bleSetupCard.visibility = View.VISIBLE
step3NextButtons.visibility = View.VISIBLE step3NextButtons.visibility = View.VISIBLE
checkGatewayStatus() // Disable Next until device selected
val savedName = bleManager?.getSavedDeviceName()
if (savedName != null) {
tvSelectedScale.text = "$savedName"
tvSelectedScale.visibility = View.VISIBLE
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
} else {
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = false
startBleScan()
}
} }
findViewById<MaterialButton>(R.id.btnScaleNo).setOnClickListener { findViewById<MaterialButton>(R.id.btnScaleNo).setOnClickListener {
prefs.edit().putBoolean(KEY_HAS_SCALE, false).apply() prefs.edit().putBoolean(KEY_HAS_SCALE, false).apply()
bleManager?.stopScan()
showStep(5) showStep(5)
} }
btnInstallGateway.setOnClickListener { btnScanBle.setOnClickListener { startBleScan() }
pendingApkDownloadUrl = GATEWAY_DOWNLOAD_URL findViewById<MaterialButton>(R.id.btnScaleBack).setOnClickListener {
triggerApkDownload(GATEWAY_DOWNLOAD_URL) bleManager?.stopScan()
showStep(3)
} }
btnConfigureGateway.setOnClickListener {
val intent = packageManager.getLaunchIntentForPackage(GATEWAY_PACKAGE)
if (intent != null) {
startActivity(intent)
} else {
Toast.makeText(this, "Gateway non trovato", Toast.LENGTH_SHORT).show()
}
}
findViewById<MaterialButton>(R.id.btnScaleBack).setOnClickListener { showStep(3) }
findViewById<MaterialButton>(R.id.btnScaleNext).setOnClickListener { findViewById<MaterialButton>(R.id.btnScaleNext).setOnClickListener {
prefs.edit().putBoolean(KEY_HAS_SCALE, true).apply() bleManager?.stopScan()
showStep(5) showStep(5)
} }
@@ -355,19 +356,26 @@ class SetupActivity : AppCompatActivity() {
// Reset scale step when entering it // Reset scale step when entering it
if (step == 4) { if (step == 4) {
val scaleAlreadyConfiguredYes = prefs.contains(KEY_HAS_SCALE) && prefs.getBoolean(KEY_HAS_SCALE, false) val hasScaleYes = prefs.contains(KEY_HAS_SCALE) && prefs.getBoolean(KEY_HAS_SCALE, false)
if (scaleAlreadyConfiguredYes) { if (hasScaleYes) {
// User already confirmed they have a scale — skip the question // Already said YES — go straight to BLE scan card
scaleQuestionCard.visibility = View.GONE scaleQuestionCard.visibility = View.GONE
gatewayInfoCard.visibility = View.VISIBLE bleSetupCard.visibility = View.VISIBLE
gatewayInstallCard.visibility = View.VISIBLE step3NextButtons.visibility = View.VISIBLE
step3NextButtons.visibility = View.VISIBLE val savedName = bleManager?.getSavedDeviceName()
checkGatewayStatus() if (savedName != null) {
tvSelectedScale.text = "$savedName"
tvSelectedScale.visibility = View.VISIBLE
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
} else {
tvSelectedScale.visibility = View.GONE
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = false
startBleScan()
}
} else { } else {
scaleQuestionCard.visibility = View.VISIBLE scaleQuestionCard.visibility = View.VISIBLE
gatewayInfoCard.visibility = View.GONE bleSetupCard.visibility = View.GONE
gatewayInstallCard.visibility = View.GONE step3NextButtons.visibility = View.GONE
step3NextButtons.visibility = View.GONE
} }
} }
@@ -414,7 +422,7 @@ class SetupActivity : AppCompatActivity() {
.setTitle(getString(R.string.setup_exit_title)) .setTitle(getString(R.string.setup_exit_title))
.setMessage(getString(R.string.setup_exit_message)) .setMessage(getString(R.string.setup_exit_message))
.setPositiveButton(getString(R.string.setup_exit_confirm)) { _, _ -> .setPositiveButton(getString(R.string.setup_exit_confirm)) { _, _ ->
pollHandler.removeCallbacksAndMessages(null) bleManager?.stopScan()
discoverCancelled.set(true) discoverCancelled.set(true)
finishAffinity() finishAffinity()
} }
@@ -443,6 +451,16 @@ class SetupActivity : AppCompatActivity() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.READ_EXTERNAL_STORAGE) needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
} }
// BLE permissions (needed for scale integration)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.BLUETOOTH_SCAN)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (needed.isEmpty()) { if (needed.isEmpty()) {
onPermissionsGranted() onPermissionsGranted()
} else { } else {
@@ -452,7 +470,7 @@ class SetupActivity : AppCompatActivity() {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE) { if (requestCode == PERMISSION_REQUEST_CODE || requestCode == BLE_PERMISSION_REQUEST) {
onPermissionsGranted() onPermissionsGranted()
} }
} }
@@ -699,381 +717,119 @@ class SetupActivity : AppCompatActivity() {
}.start() }.start()
} }
// ── Gateway ──────────────────────────────────────────────────────────── // ── BLE Scale ─────────────────────────────────────────────────────────
private fun isGatewayInstalled() = try { private fun startBleScan() {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0); true val mgr = bleManager ?: return
} catch (_: PackageManager.NameNotFoundException) { false } if (!mgr.hasRequiredPermissions()) {
val needed = mutableListOf<String>()
private fun checkGatewayStatus() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (isGatewayInstalled()) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED)
gatewayStatusIcon.text = "" needed.add(Manifest.permission.BLUETOOTH_SCAN)
gatewayStatusText.text = getString(R.string.wizard_gateway_installed) if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED)
gatewayStatusDetail.text = "⏳ Verifica connessione in corso..." needed.add(Manifest.permission.BLUETOOTH_CONNECT)
gatewayStatusDetail.setTextColor(0xFF94a3b8.toInt())
btnInstallGateway.visibility = View.GONE
btnConfigureGateway.visibility = View.VISIBLE
gatewayProgressBar.visibility = View.GONE
gatewayProgressText.visibility = View.GONE
// Probe WebSocket port to tell user if gateway is actually running
Thread {
val running = try {
java.net.Socket().use { s ->
s.connect(java.net.InetSocketAddress("127.0.0.1", 8765), 1200)
true
}
} catch (_: Exception) { false }
runOnUiThread {
if (running) {
gatewayStatusDetail.text = "✅ Gateway attivo su ws://127.0.0.1:8765"
gatewayStatusDetail.setTextColor(0xFF34d399.toInt())
btnConfigureGateway.text = "⚙️ Riapri Gateway per configurarlo"
} else {
gatewayStatusDetail.text =
"⚠️ Gateway installato ma non ancora avviato.\n" +
"Premi il pulsante qui sotto per aprirlo e configurarlo, poi torna a questa schermata."
gatewayStatusDetail.setTextColor(0xFFfbbf24.toInt())
btnConfigureGateway.text = "▶️ Apri e configura Gateway"
}
}
}.start()
} else {
gatewayStatusIcon.text = "📲"
gatewayStatusText.text = getString(R.string.wizard_gateway_not_installed)
gatewayStatusDetail.text = getString(R.string.wizard_gateway_not_installed_detail)
gatewayStatusDetail.setTextColor(0xFFfbbf24.toInt())
btnInstallGateway.visibility = View.VISIBLE
btnConfigureGateway.visibility = View.GONE
}
}
private fun setGatewayUI(icon: String, text: String, detail: String, color: Int,
btnEnabled: Boolean = true, progress: Int = -2) {
runOnUiThread {
gatewayStatusIcon.text = icon
gatewayStatusText.text = text
gatewayStatusDetail.text = detail
gatewayStatusDetail.setTextColor(color)
btnInstallGateway.isEnabled = btnEnabled
when {
progress == -2 -> {
gatewayProgressBar.visibility = View.GONE
gatewayProgressText.visibility = View.GONE
}
progress == -1 -> {
gatewayProgressBar.isIndeterminate = true
gatewayProgressBar.visibility = View.VISIBLE
gatewayProgressText.visibility = View.GONE
}
else -> {
gatewayProgressBar.isIndeterminate = false
gatewayProgressBar.progress = progress
gatewayProgressBar.visibility = View.VISIBLE
gatewayProgressText.visibility = View.VISIBLE
}
}
}
}
private fun startProgressPoll(downloadId: Long) {
activeDownloadId = downloadId
pollHandler.removeCallbacksAndMessages(null)
fun tick() {
if (activeDownloadId != downloadId) return
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val c = dm.query(DownloadManager.Query().setFilterById(downloadId))
if (!c.moveToFirst()) { c.close(); return }
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
val dl = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
val tot = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
c.close()
val pct = if (tot > 0) (dl * 100 / tot).toInt() else 0
val txt = if (tot > 0) "%.1f / %.1f MB".format(dl / 1_048_576f, tot / 1_048_576f) else ""
setGatewayUI(
"",
getString(R.string.install_downloading) + if (tot > 0) " ($pct%)" else "",
txt, 0xFF94a3b8.toInt(), btnEnabled = false, progress = pct
)
runOnUiThread { gatewayProgressText.text = txt }
pollHandler.postDelayed({ tick() }, 500)
} else { } else {
c.close() if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
needed.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (needed.isNotEmpty()) {
ActivityCompat.requestPermissions(this, needed.toTypedArray(), BLE_PERMISSION_REQUEST)
return
} }
} }
pollHandler.post { tick() } discoveredDevices.clear()
deviceAdapter?.notifyDataSetChanged()
tvScanStatus.text = "🔍 Scansione in corso…"
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
btnScanBle.isEnabled = false
mgr.startScan()
} }
private fun triggerApkDownload(apkUrl: String) { private fun onDeviceSelected(info: BleDeviceInfo) {
pendingApkDownloadUrl = apkUrl bleManager?.stopScan()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) { selectedDevice = info
@Suppress("DEPRECATION") bleManager?.saveDevice(info.device.address, info.name)
startActivityForResult( tvSelectedScale.text = "${info.name}"
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")), tvSelectedScale.visibility = View.VISIBLE
INSTALL_PERM_REQUEST tvScanStatus.text = "Bilancia selezionata. Premi Avanti per continuare."
) tvScanStatus.setTextColor(0xFF34d399.toInt())
return btnScanBle.isEnabled = true
} btnScanBle.text = "🔄 Scansiona di nuovo"
setGatewayUI("", getString(R.string.install_downloading), "", 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1) findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
val destDir = getExternalFilesDir(null) ?: filesDir
val destFile = java.io.File(destDir, "evershelf-gateway-setup.apk")
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf Scale Gateway")
setDescription(getString(R.string.install_downloading))
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationUri(Uri.fromFile(destFile))
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
startProgressPoll(downloadId)
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()) {
ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) ==
DownloadManager.STATUS_SUCCESSFUL
}
c.close()
pollHandler.removeCallbacksAndMessages(null)
activeDownloadId = -1
if (ok) {
setGatewayUI("", getString(R.string.install_installing), "", 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
installApk(destFile)
} else {
setGatewayUI("", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt())
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
} }
private fun installApk(file: java.io.File) { private fun makeBleListener() = object : BleScaleListener {
if (!file.exists() || file.length() == 0L) { override fun onDeviceFound(info: BleDeviceInfo) {
setGatewayUI("", getString(R.string.install_error_download), "File APK non trovato sul dispositivo.", 0xFFf87171.toInt()) val existing = discoveredDevices.indexOfFirst { it.device.address == info.device.address }
return if (existing >= 0) {
discoveredDevices[existing] = info
deviceAdapter?.notifyItemChanged(existing)
} else {
discoveredDevices.add(info)
deviceAdapter?.notifyItemInserted(discoveredDevices.size - 1)
}
} }
// Validate APK magic bytes (ZIP header) override fun onConnecting(device: BluetoothDevice) {}
val magic = try { file.inputStream().use { s -> val b = ByteArray(4); s.read(b); b } } catch (_: Exception) { null } override fun onConnected(deviceName: String) {}
if (magic == null || magic[0] != 0x50.toByte() || magic[1] != 0x4B.toByte()) { override fun onDisconnected() {}
setGatewayUI("", getString(R.string.install_error_download), "Il file scaricato non è un APK valido.", 0xFFf87171.toInt()) override fun onWeightReceived(reading: WeightReading) {}
file.delete() override fun onBatteryReceived(level: Int) {}
return override fun onError(message: String) {
tvScanStatus.text = "⚠️ $message"
tvScanStatus.setTextColor(0xFFf87171.toInt())
btnScanBle.isEnabled = true
} }
// Double-check install permission at runtime (may have been revoked or not granted yet) override fun onScanStopped() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) { btnScanBle.isEnabled = true
AlertDialog.Builder(this) if (discoveredDevices.isEmpty()) {
.setTitle("⚠️ Permesso mancante") tvScanStatus.text = "Nessuna bilancia trovata. Assicurati che sia accesa e vicina."
.setMessage("Per installare il Gateway è necessario abilitare \"Installa app sconosciute\" per questa app.\n\nTocca OK per aprire le impostazioni.") tvScanStatus.setTextColor(0xFFfbbf24.toInt())
.setPositiveButton("OK") { _, _ -> } else {
pendingInstallFile = file tvScanStatus.text = "Seleziona la tua bilancia dall'elenco."
@Suppress("DEPRECATION") tvScanStatus.setTextColor(0xFF94a3b8.toInt())
startActivityForResult( }
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")),
INSTALL_PERM_REQUEST
)
}
.setNegativeButton("Annulla", null)
.show()
return
} }
installWithPackageInstaller(file, GATEWAY_PACKAGE) override fun onDebugEvent(message: String) {}
} }
private fun installWithPackageInstaller(file: java.io.File, targetPkg: String) { // ── Device list adapter ────────────────────────────────────────────────
try {
val pi = packageManager.packageInstaller
val params = android.content.pm.PackageInstaller.SessionParams(
android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
// on some OEM/Android versions even when the package name is correct.
val sessionId = pi.createSession(params)
val session = pi.openSession(sessionId)
try {
file.inputStream().use { input ->
session.openWrite("package", 0, file.length()).use { out ->
input.copyTo(out)
session.fsync(out)
}
}
} catch (e: Exception) {
try { session.abandon() } catch (_: Exception) {}
throw e
}
// Do NOT close() the session after commit — it is now owned by the system.
val action = "it.dadaloop.evershelf.kiosk.SETUP_INSTALL_$sessionId" private inner class DeviceAdapter(
val resultReceiver = object : BroadcastReceiver() { private val onSelect: (BleDeviceInfo) -> Unit,
override fun onReceive(ctx: Context?, intent: Intent?) { ) : RecyclerView.Adapter<DeviceAdapter.VH>() {
val status = intent?.getIntExtra(
android.content.pm.PackageInstaller.EXTRA_STATUS,
android.content.pm.PackageInstaller.STATUS_FAILURE
) ?: android.content.pm.PackageInstaller.STATUS_FAILURE
when (status) { inner class VH(view: View) : RecyclerView.ViewHolder(view) {
android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -> { val tvName: TextView = view.findViewById(android.R.id.text1)
// Do NOT unregister here — on Android 11+ the final result val tvDetail: TextView = view.findViewById(android.R.id.text2)
// (STATUS_SUCCESS or STATUS_FAILURE) arrives as a second broadcast
// to this same receiver AFTER the user confirms the dialog.
@Suppress("DEPRECATION")
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirmIntent != null) {
pendingInstallFile = file
pendingInstallPkg = targetPkg
setGatewayUI("", getString(R.string.install_installing),
getString(R.string.install_confirm_detail), 0xFF94a3b8.toInt(),
btnEnabled = false, progress = -1)
@Suppress("DEPRECATION")
startActivityForResult(confirmIntent, INSTALL_CONFIRM_REQUEST)
} else {
// No confirmation intent — give up gracefully
unregisterReceiver(this)
setGatewayUI("", getString(R.string.install_error_install),
"No confirmation intent", 0xFFf87171.toInt())
}
}
android.content.pm.PackageInstaller.STATUS_SUCCESS -> {
unregisterReceiver(this)
setGatewayUI("", getString(R.string.install_success),
getString(R.string.install_success_detail), 0xFF34d399.toInt(),
btnEnabled = false)
Handler(Looper.getMainLooper()).postDelayed({ checkGatewayStatus() }, 1500)
}
android.content.pm.PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
android.content.pm.PackageInstaller.STATUS_FAILURE_CONFLICT -> {
unregisterReceiver(this)
runOnUiThread { offerUninstallAndRetry(file, targetPkg) }
}
-1 /* STATUS_FAILURE_ABORTED */ -> {
// User cancelled the install confirmation dialog — just reset UI
unregisterReceiver(this)
runOnUiThread { checkGatewayStatus() }
}
android.content.pm.PackageInstaller.STATUS_FAILURE -> {
// Generic failure (status=1): PackageInstaller can't install on this
// device/config. Fall back to system Intent.ACTION_VIEW installer UI.
unregisterReceiver(this)
ErrorReporter.reportMessage(
"install_failure",
"PackageInstaller STATUS_FAILURE=1, trying ACTION_VIEW fallback",
mapOf(
"pkg" to targetPkg,
"apk_kb" to (file.length() / 1024),
"android" to Build.VERSION.SDK_INT,
"device" to buildDeviceLabel()
),
forceReport = true
)
runOnUiThread { tryFallbackInstall(file, targetPkg) }
}
else -> {
unregisterReceiver(this)
val msg = intent?.getStringExtra(
android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE
) ?: ""
val deviceLabel = buildDeviceLabel()
val hint = when (status) {
2 -> "Bloccato da policy o da un'altra installazione in corso"
3 -> "Annullato"
4 -> "APK non valido o corrotto"
5 -> "Conflitto: versione precedente con firma diversa"
6 -> "Spazio insufficiente"
7 -> "Incompatibile con questa versione di Android"
else -> "Errore sconosciuto (status=$status)"
}
val diagInfo = buildString {
appendLine("❌ Status $status: $hint")
if (msg.isNotEmpty()) appendLine("Dettaglio: $msg")
appendLine("Pacchetto: $targetPkg")
appendLine("APK: ${file.length() / 1024} KB")
appendLine("Android: ${Build.VERSION.SDK_INT} (${Build.VERSION.RELEASE})")
appendLine("Dispositivo: $deviceLabel")
}
setGatewayUI("", getString(R.string.install_error_install),
diagInfo.trim(), 0xFFf87171.toInt())
ErrorReporter.reportMessage(
"install_failure",
"PackageInstaller status=$status pkg=$targetPkg android=${Build.VERSION.SDK_INT}",
mapOf(
"pkg" to targetPkg,
"status" to status,
"hint" to hint,
"msg" to msg,
"apk_kb" to (file.length() / 1024),
"android" to Build.VERSION.SDK_INT,
"device" to deviceLabel
),
forceReport = true
)
val pkgInstalled = try {
packageManager.getPackageInfo(targetPkg, 0); true
} catch (_: Exception) { false }
runOnUiThread {
if (pkgInstalled) {
offerUninstallAndRetry(file, targetPkg)
} else {
AlertDialog.Builder(this@SetupActivity)
.setTitle("❌ Installazione fallita (status=$status)")
.setMessage(diagInfo.trim())
.setPositiveButton("Riprova") { _, _ ->
installWithPackageInstaller(file, targetPkg)
}
.setNeutralButton("Salta") { _, _ ->
checkGatewayStatus()
}
.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)
setGatewayUI("", getString(R.string.install_installing), "", 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
} catch (e: Exception) {
setGatewayUI("", getString(R.string.install_error_install), e.message ?: "", 0xFFf87171.toInt())
ErrorReporter.reportMessage("install_packager_exception",
"installWithPackageInstaller exception for $targetPkg: ${e.message}",
mapOf("android" to Build.VERSION.SDK_INT, "apk_kb" to (file.length() / 1024)))
} }
}
private fun offerUninstallAndRetry(file: java.io.File, pkg: String) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
pendingInstallFile = file val v = LayoutInflater.from(parent.context)
pendingInstallPkg = pkg .inflate(android.R.layout.simple_list_item_2, parent, false)
AlertDialog.Builder(this) v.setBackgroundColor(0x1A7c3aed)
.setTitle("⚠️ Conflitto firma APK") val density = parent.context.resources.displayMetrics.density
.setMessage("L'app installata usa una firma diversa. Devi prima disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione riprenderà automaticamente.") val lp = v.layoutParams as? RecyclerView.LayoutParams
.setPositiveButton("Disinstalla") { _, _ -> lp?.bottomMargin = (6 * density).toInt()
@Suppress("DEPRECATION") v.layoutParams = lp
startActivityForResult( val pad = (12 * density).toInt()
Intent(Intent.ACTION_DELETE, Uri.parse("package:$pkg")), val padV = (10 * density).toInt()
UNINSTALL_REQUEST v.setPadding(pad, padV, pad, padV)
) return VH(v)
} }
.setNegativeButton("Annulla", null)
.show() override fun onBindViewHolder(holder: VH, position: Int) {
val info = discoveredDevices[position]
holder.tvName.text = info.name
holder.tvName.setTextColor(0xFFf1f5f9.toInt())
holder.tvName.textSize = 15f
val score = if (info.scaleScore >= 10) "⭐ probabile bilancia • " else ""
holder.tvDetail.text = "$score${info.proximity}${info.rssi} dBm"
holder.tvDetail.setTextColor(0xFF94a3b8.toInt())
holder.tvDetail.textSize = 12f
holder.itemView.setOnClickListener { onSelect(info) }
}
override fun getItemCount() = discoveredDevices.size
} }
// ── Summary / Finish ───────────────────────────────────────────────── // ── Summary / Finish ─────────────────────────────────────────────────
@@ -1082,15 +838,16 @@ class SetupActivity : AppCompatActivity() {
val url = prefs.getString(KEY_URL, "") ?: "" val url = prefs.getString(KEY_URL, "") ?: ""
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
val screensOn = setupSwitchScreensaver.isChecked val screensOn = setupSwitchScreensaver.isChecked
val gwOk = hasScale && isGatewayInstalled() val scaleName = bleManager?.getSavedDeviceName()
val scaleOk = hasScale && scaleName != null
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it" val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" } val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
val sb = StringBuilder() val sb = StringBuilder()
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel") sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url") if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
sb.appendLine(when { sb.appendLine(when {
gwOk -> "Scale Gateway: ${getString(R.string.wizard_gateway_installed)}" scaleOk -> "Bilancia: $scaleName"
hasScale -> "⚠️ Scale Gateway: ${getString(R.string.wizard_gateway_not_installed)}" hasScale -> "⚠️ Bilancia: da configurare"
else -> "${getString(R.string.summary_scale_skip)}" else -> "${getString(R.string.summary_scale_skip)}"
}) })
sb.appendLine(if (screensOn) "🌙 ${getString(R.string.summary_screensaver_on)}" else "💡 ${getString(R.string.summary_screensaver_off)}") sb.appendLine(if (screensOn) "🌙 ${getString(R.string.summary_screensaver_on)}" else "💡 ${getString(R.string.summary_screensaver_off)}")
@@ -1099,12 +856,9 @@ class SetupActivity : AppCompatActivity() {
private fun finishSetup() { private fun finishSetup() {
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply() prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
// ── Sync settings to webapp API ─────────────────────────────────────────
// Always push: screensaver_enabled (in-app clock overlay preference).
// Conditionally add: scale settings when gateway is installed.
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trimEnd('/') val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trimEnd('/')
if (baseUrl.isNotEmpty()) { if (baseUrl.isNotEmpty()) {
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && isGatewayInstalled() val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false) val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
Thread { Thread {
try { try {
@@ -1132,147 +886,4 @@ class SetupActivity : AppCompatActivity() {
setResult(RESULT_OK) setResult(RESULT_OK)
finish() finish()
} }
// ── Activity Results ─────────────────────────────────────────────────
@Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
INSTALL_PERM_REQUEST -> {
// Returned from "Install unknown apps" settings for this app.
// pendingInstallFile is set when coming from installApk() permission check,
// pendingApkDownloadUrl is set when coming from triggerApkDownload().
val pendingFile = pendingInstallFile
if (pendingFile != null && pendingFile.exists()) {
installApk(pendingFile)
} else if (pendingApkDownloadUrl.isNotEmpty()) {
triggerApkDownload(pendingApkDownloadUrl)
}
}
INSTALL_CONFIRM_REQUEST -> {
// On Android 11+ the final install result (STATUS_SUCCESS / STATUS_FAILURE)
// arrives via the BroadcastReceiver, not via onActivityResult.
// RESULT_OK = user tapped "Install" in the system dialog (not "install succeeded")
// RESULT_CANCELED = user pressed Back without confirming
if (resultCode != RESULT_OK) {
// User backed out of the confirmation — BroadcastReceiver will receive
// STATUS_FAILURE_ABORTED (-1) and reset the UI automatically.
// No action needed here.
}
}
UNINSTALL_REQUEST -> {
val f = pendingInstallFile
val pkg = pendingInstallPkg
if (f != null && f.exists() && pkg.isNotEmpty()) {
Handler(Looper.getMainLooper()).postDelayed({ installWithPackageInstaller(f, pkg) }, 600)
}
}
INSTALL_FALLBACK_REQUEST -> {
// System package installer returned — check if the package is now installed.
// Whether the user pressed "Done" or "Open", bring setup back to foreground.
Handler(Looper.getMainLooper()).postDelayed({
// Bring this activity back to front in case user pressed "Open"
val bringFront = Intent(this, SetupActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
}
startActivity(bringFront)
val installed = try { packageManager.getPackageInfo(pendingInstallPkg, 0); true } catch (_: Exception) { false }
if (installed) {
setGatewayUI("", getString(R.string.install_success),
getString(R.string.install_success_detail), 0xFF34d399.toInt(), btnEnabled = false)
Handler(Looper.getMainLooper()).postDelayed({ checkGatewayStatus() }, 1500)
} else {
// Install failed or user cancelled. Show an explicit retry button
// that re-launches the system installer directly (skipping PackageInstaller,
// which is known to give STATUS=1 on this device).
val retryFile = pendingInstallFile
val retryPkg = pendingInstallPkg
setGatewayUI(
"⚠️",
"Installazione non completata",
"L'app non risulta installata. Premi il pulsante sotto per riprovare.",
0xFFfbbf24.toInt()
)
btnInstallGateway.visibility = View.VISIBLE
btnInstallGateway.text = "🔄 Riprova installazione"
btnInstallGateway.setOnClickListener {
// Reset button back to default before retrying
btnInstallGateway.text = "📥 Installa Scale Gateway"
btnInstallGateway.setOnClickListener {
pendingApkDownloadUrl = GATEWAY_DOWNLOAD_URL
triggerApkDownload(GATEWAY_DOWNLOAD_URL)
}
if (retryFile != null && retryFile.exists()) {
tryFallbackInstall(retryFile, retryPkg)
} else {
pendingApkDownloadUrl = GATEWAY_DOWNLOAD_URL
triggerApkDownload(GATEWAY_DOWNLOAD_URL)
}
}
}
}, 800)
}
}
}
private fun tryFallbackInstall(file: java.io.File, targetPkg: String) {
try {
val uri = androidx.core.content.FileProvider.getUriForFile(
this, "$packageName.provider", file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
// Note: do NOT add FLAG_ACTIVITY_NEW_TASK — it breaks startActivityForResult:
// Android would return RESULT_CANCELED immediately without waiting for the user.
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
pendingInstallFile = file
pendingInstallPkg = targetPkg
// Warn user: after installation Android shows "Open" and "Done" buttons.
// Opening the gateway app directly would leave the kiosk in the background.
AlertDialog.Builder(this)
.setTitle("📦 Installazione in corso")
.setMessage(
"Quando Android mostra la schermata di installazione completata:\n\n" +
"✅ Premi \"Fine\" per tornare al setup\n" +
"⛔ NON premere \"Apri\" — l'app potrebbe non funzionare correttamente se aperta direttamente"
)
.setPositiveButton("Ho capito, procedi") { _, _ ->
setGatewayUI("", getString(R.string.install_installing),
"Conferma l'installazione nella finestra di sistema...",
0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
@Suppress("DEPRECATION")
startActivityForResult(intent, INSTALL_FALLBACK_REQUEST)
}
.setCancelable(false)
.show()
} catch (e: Exception) {
val deviceLabel = buildDeviceLabel()
val diagInfo = buildString {
appendLine("❌ PackageInstaller status=1 e fallback non riuscito")
appendLine("Errore: ${e.message}")
appendLine("Android: ${Build.VERSION.SDK_INT} (${Build.VERSION.RELEASE})")
appendLine("Dispositivo: $deviceLabel")
}
setGatewayUI("", getString(R.string.install_error_install),
diagInfo.trim(), 0xFFf87171.toInt())
ErrorReporter.reportMessage(
"install_fallback_exception",
"tryFallbackInstall failed: ${e.message}",
mapOf("android" to Build.VERSION.SDK_INT, "device" to deviceLabel),
forceReport = true
)
}
}
private fun buildDeviceLabel(): String {
val mfr = Build.MANUFACTURER.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.PRODUCT.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.BOARD
val model = Build.MODEL.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.HARDWARE
return "$mfr $model"
}
} }
@@ -0,0 +1,295 @@
package it.dadaloop.evershelf.kiosk.scale
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 = 20_000L
private const val PREFS_NAME = "evershelf_kiosk"
private const val PREF_SCALE_ADDRESS = "scale_device_address"
private const val PREF_SCALE_NAME = "scale_device_name"
data class BleDeviceInfo(
val device: BluetoothDevice,
val name: String,
val rssi: Int,
val proximity: String,
val scaleScore: Int,
)
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)
}
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
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
fun getSavedDeviceAddress(): String? =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_SCALE_ADDRESS, null)
fun getSavedDeviceName(): String? =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_SCALE_NAME, null)
fun saveDevice(address: String, name: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(PREF_SCALE_ADDRESS, address)
.putString(PREF_SCALE_NAME, name)
.apply()
}
fun clearSavedDevice() {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.remove(PREF_SCALE_ADDRESS)
.remove(PREF_SCALE_NAME)
.apply()
}
fun enableAutoConnect() {
autoConnectAddress = getSavedDeviceAddress()
}
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
}
}
fun startScan() {
val adapter = bluetoothAdapter ?: run { listener.onError("Bluetooth non disponibile"); return }
if (!adapter.isEnabled) { listener.onError("Bluetooth disabilitato"); return }
if (isScanning) stopScan()
leScanner = adapter.bluetoothLeScanner
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
isScanning = true
try { leScanner?.startScan(null, settings, scanCallback) }
catch (_: Exception) { leScanner?.startScan(scanCallback) }
mainHandler.postDelayed({ stopScan(); listener.onScanStopped() }, SCAN_PERIOD_MS)
}
fun stopScan() {
if (!isScanning) return
isScanning = false
try { leScanner?.stopScan(scanCallback) } catch (_: Exception) {}
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() }
?: try { device.name?.takeIf { it.isNotBlank() } } catch (_: SecurityException) { null }
?: return // skip unnamed devices
val score = scoreLikelyScale(name, result.scanRecord)
val info = BleDeviceInfo(device, name, result.rssi, rssiToProximity(result.rssi), score)
mainHandler.post { listener.onDeviceFound(info) }
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
autoConnectAddress = null
mainHandler.post { connect(device) }
}
}
override fun onScanFailed(errorCode: Int) {
isScanning = false
mainHandler.post { listener.onError("BLE scan failed (code $errorCode)") }
}
}
private fun rssiToProximity(rssi: Int) = when {
rssi >= -60 -> "📶 Vicino"
rssi >= -80 -> "📶 Medio"
else -> "📶 Lontano"
}
private fun scoreLikelyScale(name: String, scanRecord: ScanRecord?): Int {
var score = 0
val lower = name.lowercase()
val foodKeywords = listOf("scale","bilancia","kitchen","food","cucina","coffee","caffe",
"balance","weight","waage","arboleaf","ck10","ck20","ek-","acaia","felicita",
"timemore","brewista","hario","ozeri","etekcity","nutri","nicewell","koios","renpho")
if (foodKeywords.any { lower.contains(it) }) score += 10
val bodyKeywords = listOf("body","fat","bmi","composition","fitness","mi body","lepulse")
if (bodyKeywords.any { lower.contains(it) }) score -= 5
scanRecord?.serviceUuids?.let { uuids ->
val us = uuids.map { it.uuid.toString().lowercase() }
if (us.any { it.startsWith("0000181d") }) score += 15
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
if (us.any { it.startsWith("49535343") }) score += 20
if (us.any { it.startsWith("0000181b") }) score -= 10
}
return score
}
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("Permesso mancante: ${e.message}") }
}
}
fun disconnect() {
pendingSubscriptions.clear()
try { gatt?.disconnect(); gatt?.close() } catch (_: Exception) {}
gatt = null
connectedDeviceName = ""
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
}
BluetoothProfile.STATE_DISCONNECTED -> {
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") }
return
}
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)?.let { targetChars.add(it) }
gatt.getService(BleUuids.FFE0)?.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
gatt.getService(BleUuids.FFF0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
}
gatt.getService(BleUuids.ACAIA_SERVICE)?.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
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("Nessuna caratteristica peso trovata") }
return
}
gatt.getService(BleUuids.BATTERY_SERVICE)?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)?.let { targetChars.add(it) }
try { gatt.device?.address?.let { saveDevice(it, connectedDeviceName) } } catch (_: SecurityException) {}
pendingSubscriptions.clear()
pendingSubscriptions.addAll(targetChars)
val deviceName = try { gatt.device?.name ?: "Scale" } catch (_: SecurityException) { "Scale" }
connectedDeviceName = deviceName
mainHandler.post { listener.onConnected(deviceName) }
subscribeNext(gatt)
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
subscribeNext(gatt)
}
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
processCharacteristicData(characteristic, characteristic.value ?: return)
}
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) }
}
}
}
private fun subscribeNext(gatt: BluetoothGatt) {
val char = pendingSubscriptions.removeFirstOrNull() ?: return
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
try { gatt.readCharacteristic(char) } catch (_: SecurityException) {}
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 { 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 (_: SecurityException) {}
}
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
val level = data[0].toInt() and 0xFF
mainHandler.post { listener.onBatteryReceived(level) }
return
}
val reading = ScaleProtocol.parse(char, data) { msg -> mainHandler.post { listener.onDebugEvent(msg) } }
if (reading != null && reading.value > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
}
}
}
@@ -0,0 +1,246 @@
package it.dadaloop.evershelf.kiosk.scale
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import it.dadaloop.evershelf.kiosk.KioskActivity
import it.dadaloop.evershelf.kiosk.R
private const val TAG = "GatewayService"
private const val WS_PORT = 8765
private const val NOTIF_ID = 1001
private const val CHANNEL_ID = "evershelf_gateway"
private const val RECONNECT_DELAY_MS = 8_000L
/**
* Foreground service that keeps the BLE scale connection and WebSocket server alive
* independently of the KioskActivity lifecycle.
*
* The WebSocket server on port 8765 is protocol-compatible with the standalone
* evershelf-scale-gateway app, so the EverShelf webapp JS needs no changes.
*/
class GatewayService : Service(), BleScaleListener, ServerEventListener {
private lateinit var bleManager: BleScaleManager
private var wsServer: GatewayWebSocketServer? = null
private val handler = Handler(Looper.getMainLooper())
private var connectedDeviceName: String? = null
private var batteryLevel: Int? = null
private var reconnectPending = false
companion object {
const val ACTION_START = "evershelf.gateway.START"
const val ACTION_STOP = "evershelf.gateway.STOP"
/** Returns true if the service can try to connect (BLE permissions ok, device saved). */
fun canStart(context: Context): Boolean {
val prefs = context.getSharedPreferences("evershelf_kiosk", Context.MODE_PRIVATE)
val hasScale = prefs.getBoolean("has_scale", false)
val hasDevice = prefs.getString("scale_device_address", null) != null
return hasScale && hasDevice
}
fun start(context: Context) {
val intent = Intent(context, GatewayService::class.java).apply {
action = ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.startService(Intent(context, GatewayService::class.java).apply {
action = ACTION_STOP
})
}
}
override fun onCreate() {
super.onCreate()
bleManager = BleScaleManager(this, this)
createNotificationChannel()
startForeground(NOTIF_ID, buildNotification("Avvio bilancia…"))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopSelf()
return START_NOT_STICKY
}
else -> {
startWsServer()
connectToSavedScale()
}
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
bleManager.disconnect()
try { wsServer?.stop(1000) } catch (_: Exception) {}
wsServer = null
super.onDestroy()
}
// ── WebSocket server ──────────────────────────────────────────────────────
private fun startWsServer() {
if (wsServer != null) return
try {
wsServer = GatewayWebSocketServer(WS_PORT, this)
wsServer!!.isReuseAddr = true
wsServer!!.start()
Log.i(TAG, "WebSocket server started on :$WS_PORT")
} catch (e: Exception) {
Log.e(TAG, "Failed to start WebSocket server", e)
updateNotification("⚠️ WebSocket non avviato: ${e.message}")
}
}
// ── BLE connection ────────────────────────────────────────────────────────
private fun connectToSavedScale() {
if (!bleManager.hasRequiredPermissions()) {
updateNotification("⚠️ Permessi Bluetooth mancanti")
return
}
val addr = bleManager.getSavedDeviceAddress() ?: run {
updateNotification("Nessuna bilancia configurata")
return
}
val name = bleManager.getSavedDeviceName() ?: addr
updateNotification("🔍 Connessione a $name")
// Enable auto-connect: the scan callback will connect when the saved device is found
bleManager.enableAutoConnect()
bleManager.startScan()
}
private fun scheduleReconnect() {
if (reconnectPending) return
reconnectPending = true
handler.postDelayed({
reconnectPending = false
if (bleManager.getSavedDeviceAddress() != null) {
updateNotification("🔄 Riconnessione bilancia…")
bleManager.enableAutoConnect()
bleManager.startScan()
}
}, RECONNECT_DELAY_MS)
}
// ── BleScaleListener ─────────────────────────────────────────────────────
override fun onDeviceFound(info: BleDeviceInfo) { /* handled by autoConnect */ }
override fun onConnecting(device: BluetoothDevice) {
val name = try { device.name ?: device.address } catch (_: SecurityException) { device.address }
updateNotification("⏳ Connessione a $name")
}
override fun onConnected(deviceName: String) {
connectedDeviceName = deviceName
updateNotification("$deviceName connessa")
wsServer?.publishStatus("connected", deviceName, batteryLevel)
Log.i(TAG, "BLE scale connected: $deviceName")
}
override fun onDisconnected() {
val name = connectedDeviceName ?: "bilancia"
connectedDeviceName = null
updateNotification("⚠️ $name disconnessa — riconnessione…")
wsServer?.publishStatus("disconnected", null, null)
scheduleReconnect()
}
override fun onWeightReceived(reading: WeightReading) {
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
}
override fun onBatteryReceived(level: Int) {
batteryLevel = level
connectedDeviceName?.let { wsServer?.publishStatus("connected", it, level) }
}
override fun onError(message: String) {
Log.w(TAG, "BLE error: $message")
scheduleReconnect()
}
override fun onScanStopped() { /* auto-reconnect handles retries */ }
override fun onDebugEvent(message: String) {
Log.d(TAG, message)
}
// ── ServerEventListener ───────────────────────────────────────────────────
override fun onClientConnected(address: String) {
Log.d(TAG, "WS client connected: $address")
}
override fun onClientDisconnected(address: String) {
Log.d(TAG, "WS client disconnected: $address")
}
override fun onClientRequestedWeight() { /* weight is pushed via onWeightReceived */ }
// ── Notification ──────────────────────────────────────────────────────────
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"EverShelf Scale Gateway",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Bilancia smart integrata"
setShowBadge(false)
}
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
private fun buildNotification(text: String): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, KioskActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setContentTitle("EverShelf Scale")
.setContentText(text)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(text: String) {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIF_ID, buildNotification(text))
}
}
@@ -0,0 +1,98 @@
package it.dadaloop.evershelf.kiosk.scale
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"
interface ServerEventListener {
fun onClientConnected(address: String)
fun onClientDisconnected(address: String)
fun onClientRequestedWeight()
}
/**
* WebSocket server that exposes BLE scale data to EverShelf running in a browser.
* Protocol is identical to the standalone gateway app so the webapp JS needs no changes.
*/
class GatewayWebSocketServer(
port: Int,
private val eventListener: ServerEventListener?,
) : WebSocketServer(InetSocketAddress(port)) {
private val pendingWeightRequests: MutableSet<WebSocket> =
Collections.synchronizedSet(mutableSetOf())
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
@Volatile private var lastWeightJson: String? = null
override fun onStart() {
Log.i(TAG, "WebSocket server started on port ${address.port}")
connectionLostTimeout = 30
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
conn.send(lastStatusJson)
lastWeightJson?.let { conn.send(it) }
eventListener?.onClientConnected(conn.remoteSocketAddress?.toString() ?: "?")
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
pendingWeightRequests.remove(conn)
eventListener?.onClientDisconnected(conn.remoteSocketAddress?.toString() ?: "?")
}
override fun onMessage(conn: WebSocket, message: String) {
try {
when (JSONObject(message).optString("type")) {
"ping" -> conn.send("""{"type":"pong"}""")
"get_status" -> conn.send(lastStatusJson)
"get_weight" -> {
pendingWeightRequests.add(conn)
eventListener?.onClientRequestedWeight()
lastWeightJson?.let { conn.send(it) }
}
}
} catch (_: Exception) {}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket error", ex)
}
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
lastStatusJson = buildStatusJson(state, deviceName, battery)
broadcast(lastStatusJson)
}
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) { pendingWeightRequests.clear() }
}
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")
obj.put("value", Math.round(value * 10f) / 10.0)
obj.put("unit", unit)
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
}
}
@@ -0,0 +1,131 @@
package it.dadaloop.evershelf.kiosk.scale
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
// ── Data model ────────────────────────────────────────────────────────────────
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 {
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
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")
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
}
// ── Scale protocol parser ─────────────────────────────────────────────────────
object ScaleProtocol {
private const val MAX_GRAMS = 15000f
private const val MIN_GRAMS = 0.5f
fun resetState() { /* reserved */ }
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
}
when (char.uuid) {
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
}
if (data.size == 18
&& (data[0].toInt() and 0xFF) == 0x10
&& (data[1].toInt() and 0xFF) == 0x12) {
return parseQNFood(data, debug)
}
return parseGeneric(data, debug)
}
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
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
if (g < MIN_GRAMS || g > MAX_GRAMS) null
else WeightReading(g, "g", stable = true)
}
}
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
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")
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"; 0x04 -> "ml"; else -> "g"
}
val value = rawValue / 10f
debug?.invoke("QN-KS: ${value}${unit} stable=$stable")
if (rawValue == 0) return null
val valueG = if (unit == "oz") value * 28.3495f else value
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
return WeightReading(value, unit, stable)
}
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) return null
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
val candidates = listOf(
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"),
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"),
C(1, false, 2f, "pos1 LE 0.5g"), C(1, true, 2f, "pos1 BE 0.5g"),
C(1, false, 0.1f, "pos1 LE cg"), C(1, true, 0.1f, "pos1 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")
return WeightReading(g, "g", stable = false)
}
}
return null
}
private fun u16le(data: ByteArray, offset: Int) =
(data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
private fun u16be(data: ByteArray, offset: Int) =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 227 KiB

@@ -69,12 +69,14 @@
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:visibility="visible"> android:visibility="visible">
<TextView <ImageView
android:layout_width="wrap_content" android:layout_width="160dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🌐" android:src="@drawable/ic_logo"
android:textSize="64sp" android:adjustViewBounds="true"
android:layout_marginBottom="16dp" /> android:scaleType="fitCenter"
android:layout_marginBottom="24dp"
android:contentDescription="EverShelf" />
<!-- Title shown in all 3 languages so it's always readable --> <!-- Title shown in all 3 languages so it's always readable -->
<TextView <TextView
@@ -132,10 +134,12 @@
<!-- App logo --> <!-- App logo -->
<ImageView <ImageView
android:layout_width="96dp" android:layout_width="200dp"
android:layout_height="96dp" android:layout_height="wrap_content"
android:src="@drawable/ic_launcher_foreground" android:src="@drawable/ic_logo"
android:layout_marginBottom="16dp" android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:layout_marginBottom="20dp"
android:contentDescription="EverShelf" /> android:contentDescription="EverShelf" />
<TextView <TextView
@@ -196,7 +200,7 @@
<TextView <TextView
android:layout_width="22dp" android:layout_width="22dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🏠" android:text="💻"
android:textSize="14sp" /> android:textSize="14sp" />
<TextView <TextView
@@ -804,38 +808,9 @@
android:textColor="#64748b" /> android:textColor="#64748b" />
</LinearLayout> </LinearLayout>
<!-- Gateway info card (shown after YES) --> <!-- BLE scan card (shown after YES) -->
<LinearLayout <LinearLayout
android:id="@+id/gatewayInfoCard" android:id="@+id/bleSetupCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/tip_background"
android:padding="16dp"
android:layout_marginBottom="12dp"
android:visibility="gone">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📱 EverShelf Scale Gateway"
android:textColor="#f1f5f9"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Un'app separata che fa da ponte: legge i dati della bilancia Bluetooth e li trasmette al pannello kiosk via rete locale. Rimane in esecuzione in background e si avvia automaticamente insieme a EverShelf."
android:textColor="#94a3b8"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
</LinearLayout>
<!-- Gateway install status card (shown after YES) -->
<LinearLayout
android:id="@+id/gatewayInstallCard"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
@@ -845,82 +820,46 @@
android:visibility="gone"> android:visibility="gone">
<TextView <TextView
android:id="@+id/gatewayStatusIcon" android:id="@+id/tvScanStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📲"
android:textSize="32sp"
android:layout_gravity="center"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/gatewayStatusText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Scale Gateway non installato" android:text="Cerca la tua bilancia Bluetooth nelle vicinanze e selezionala dall'elenco."
android:textColor="#cbd5e1" android:textColor="#94a3b8"
android:textSize="16sp" android:textSize="14sp"
android:gravity="center" android:gravity="center"
android:layout_marginBottom="4dp" /> android:layout_marginBottom="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanBle"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="🔍 Cerca bilancia"
android:textSize="14sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed"
android:layout_marginBottom="12dp" />
<TextView <TextView
android:id="@+id/gatewayStatusDetail" android:id="@+id/tvSelectedScale"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="" android:text=""
android:textColor="#94a3b8"
android:textSize="13sp"
android:gravity="center"
android:layout_marginBottom="14dp" />
<ProgressBar
android:id="@+id/gatewayProgressBar"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginBottom="4dp"
android:progressTint="#7c3aed"
android:progressBackgroundTint="#334155"
android:max="100"
android:progress="0"
android:indeterminate="false"
android:visibility="gone" />
<TextView
android:id="@+id/gatewayProgressText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textColor="#94a3b8"
android:textSize="12sp"
android:gravity="center"
android:layout_marginBottom="10dp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnInstallGateway"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="📥 Installa Scale Gateway"
android:textSize="14sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConfigureGateway"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="⚙️ Apri Gateway per configurarlo"
android:textSize="14sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#34d399"
android:textColor="#34d399" android:textColor="#34d399"
android:layout_marginTop="8dp" android:textSize="15sp"
android:textStyle="bold"
android:gravity="center"
android:layout_marginBottom="8dp"
android:visibility="gone" /> android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvScaleDevices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
</LinearLayout> </LinearLayout>
<!-- Step 3 navigation (shown after YES) --> <!-- Step 4 navigation (shown after YES) -->
<LinearLayout <LinearLayout
android:id="@+id/step3NextButtons" android:id="@+id/step3NextButtons"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -1144,4 +1083,35 @@
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
<!-- ── Credits Footer ── -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:background="#0a1120"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Creato da Stimpfl Daniel • Open Source"
android:textColor="#475569"
android:textSize="11sp"
android:gravity="center" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="github.com/dadaloop82/EverShelf"
android:textColor="#334155"
android:textSize="10sp"
android:gravity="center"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.scalegate" applicationId = "it.dadaloop.evershelf.scalegate"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = 7 versionCode = 8
versionName = "2.1.0" versionName = "2.1.1"
} }
buildFeatures { buildFeatures {
@@ -478,8 +478,22 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
} }
// Only show banner if the release actually contains our APK // Only show banner if the release actually contains our APK
if (apkUrl.isEmpty()) return@Thread if (apkUrl.isEmpty()) return@Thread
// If semver tag matches current version → already up to date
if (isSemver && norm(latestTag) == norm(current)) 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 label = if (isSemver) "$current$latestTag" else latestTag
val msg = "⬆️ Scale Gateway $label" val msg = "⬆️ Scale Gateway $label"
+3 -3
View File
@@ -10,7 +10,7 @@
<meta name="description" content="Self-hosted pantry manager with barcode scanning, AI identification, and shopping list integration."> <meta name="description" content="Self-hosted pantry manager with barcode scanning, AI identification, and shopping list integration.">
<title>EverShelf</title> <title>EverShelf</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>"> <link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260421a"> <link rel="stylesheet" href="assets/css/style.css?v=20260421a">
<!-- QuaggaJS for barcode scanning --> <!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
@@ -1158,7 +1158,7 @@
<!-- Bottom Navigation --> <!-- Bottom Navigation -->
<nav class="bottom-nav"> <nav class="bottom-nav">
<button class="nav-btn" onclick="showPage('dashboard')" data-page="dashboard"> <button class="nav-btn" onclick="showPage('dashboard')" data-page="dashboard">
<span class="nav-icon">🏠</span> <span class="nav-icon"><img src="assets/img/logo/logo_icon.png" alt="" class="nav-logo-icon" /></span>
<span class="nav-label" data-i18n="nav.home">Home</span> <span class="nav-label" data-i18n="nav.home">Home</span>
</button> </button>
<button class="nav-btn" onclick="showPage('inventory', '')" data-page="inventory"> <button class="nav-btn" onclick="showPage('inventory', '')" data-page="inventory">
@@ -1246,7 +1246,7 @@
<div class="modal-overlay" id="setup-wizard" style="display:none"> <div class="modal-overlay" id="setup-wizard" style="display:none">
<div class="modal-content setup-wizard-content" onclick="event.stopPropagation()"> <div class="modal-content setup-wizard-content" onclick="event.stopPropagation()">
<div class="setup-header"> <div class="setup-header">
<h2>🏠 EverShelf</h2> <h2><img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" style="height:28px;vertical-align:middle;margin-right:6px" /> EverShelf</h2>
<div class="setup-progress" id="setup-progress"></div> <div class="setup-progress" id="setup-progress"></div>
</div> </div>
<div class="setup-body" id="setup-body"></div> <div class="setup-body" id="setup-body"></div>
+1 -1
View File
@@ -10,7 +10,7 @@
"orientation": "portrait", "orientation": "portrait",
"icons": [ "icons": [
{ {
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%232d5016'/><text y='.9em' font-size='80' x='10'>🏠</text></svg>", "src": "assets/img/logo/logo_icon.png",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml" "type": "image/svg+xml"
} }
+1 -1
View File
@@ -4,7 +4,7 @@
"loading": "Laden..." "loading": "Laden..."
}, },
"nav": { "nav": {
"title": "🏠 EverShelf", "title": "EverShelf",
"home": "Home", "home": "Home",
"inventory": "Vorrat", "inventory": "Vorrat",
"recipes": "Rezepte", "recipes": "Rezepte",
+1 -1
View File
@@ -4,7 +4,7 @@
"loading": "Loading..." "loading": "Loading..."
}, },
"nav": { "nav": {
"title": "🏠 EverShelf", "title": "EverShelf",
"home": "Home", "home": "Home",
"inventory": "Pantry", "inventory": "Pantry",
"recipes": "Recipes", "recipes": "Recipes",
+1 -1
View File
@@ -4,7 +4,7 @@
"loading": "Caricamento..." "loading": "Caricamento..."
}, },
"nav": { "nav": {
"title": "🏠 EverShelf", "title": "EverShelf",
"home": "Home", "home": "Home",
"inventory": "Dispensa", "inventory": "Dispensa",
"recipes": "Ricette", "recipes": "Ricette",