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
+4 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 9
versionName = "1.5.3"
versionCode = 10
versionName = "1.6.0"
}
signingConfigs {
@@ -59,4 +59,6 @@ dependencies {
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
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"?>
<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 -->
<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.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" />
<!-- Self-update: install APK downloaded at runtime -->
<!-- Self-update: install own APK at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Query gateway app visibility (required Android 11+) -->
<queries>
<package android:name="it.dadaloop.evershelf.scalegate" />
</queries>
<!-- ── BLE Scale Gateway (integrated) ───────────────────────────── -->
<!-- Legacy BLE permissions (Android ≤ 11) -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<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
android:allowBackup="true"
@@ -64,6 +77,12 @@
android:exported="false"
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 -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -2,7 +2,6 @@ package it.dadaloop.evershelf.kiosk
import android.annotation.SuppressLint
import android.Manifest
import android.app.ActivityManager
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
@@ -97,8 +96,6 @@ class KioskActivity : AppCompatActivity() {
private const val KEY_SETUP_COMPLETE = "setup_complete"
private const val KEY_HAS_SCALE = "has_scale"
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 SPLASH_DURATION = 1500L
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 {
return try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0)
true
} catch (e: PackageManager.NameNotFoundException) { false }
}
private fun launchGatewayInBackground() {
private fun startGatewayService() {
if (!prefs.getBoolean(KEY_HAS_SCALE, false)) return
if (!isGatewayInstalled()) return
val launchIntent = packageManager.getLaunchIntentForPackage(GATEWAY_PACKAGE) ?: return
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(launchIntent)
Handler(Looper.getMainLooper()).postDelayed({
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
am.moveTaskToFront(taskId, ActivityManager.MOVE_TASK_WITH_HOME)
}, 1500)
val intent = Intent(this, it.dadaloop.evershelf.kiosk.scale.GatewayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent)
else startService(intent)
}
// ── Install UI ────────────────────────────────────────────────────────
@@ -312,9 +297,9 @@ class KioskActivity : AppCompatActivity() {
@SuppressLint("SetJavaScriptEnabled")
private fun launchWebView() {
// Start gateway BEFORE entering kiosk lock — in lock task mode Android blocks
// startActivity() for other packages, so the gateway would never launch.
launchGatewayInBackground()
// Start BLE gateway service BEFORE entering kiosk lock — in lock task mode Android blocks
// startForegroundService() for foreground services, so we must start before lockTask.
startGatewayService()
// Ensure kiosk lock and permissions are active
enableKioskLock()
@@ -510,9 +495,6 @@ class KioskActivity : AppCompatActivity() {
val currentKiosk = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" }
val currentGateway = try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0).versionName ?: ""
} catch (_: Exception) { null }
val norm = { v: String -> v.trimStart('v') }
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
@@ -531,39 +513,24 @@ class KioskActivity : AppCompatActivity() {
}
val assets = json.optJSONArray("assets")
var kioskApkUrl = ""
var gatewayApkUrl = ""
var kioskApkUrl = ""
if (assets != null) {
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name", "").lowercase()
val url = a.optString("browser_download_url", "")
if (name.contains("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)))
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>()
var primaryApkUrl = ""
if (kioskNeedsUpdate) {
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) }
val label = if (isSemver) "$currentKiosk$latestTag" else latestTag
val message = "🔄 Kiosk $label"
runOnUiThread { showNativeUpdateBanner(message, kioskApkUrl) }
} catch (_: Exception) { }
}.start()
}
@@ -650,11 +617,8 @@ class KioskActivity : AppCompatActivity() {
file.delete()
return
}
val targetPkg = when {
pendingApkDownloadUrl.contains("gateway", ignoreCase = true) ||
pendingApkDownloadUrl.contains("scale", ignoreCase = true) -> GATEWAY_PACKAGE
else -> packageName
}
// Only kiosk self-update is handled; gateway is now integrated
val targetPkg = packageName
installWithPackageInstaller(file, targetPkg)
}
@@ -2,33 +2,35 @@ package it.dadaloop.evershelf.kiosk
import android.Manifest
import android.app.AlertDialog
import android.app.DownloadManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
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.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.InetSocketAddress
import java.net.NetworkInterface
@@ -83,18 +85,19 @@ class SetupActivity : AppCompatActivity() {
private lateinit var btnDiscover: MaterialButton
private lateinit var discoverStatus: TextView
// Scale step
private lateinit var scaleQuestionCard: LinearLayout
private lateinit var gatewayInfoCard: LinearLayout
private lateinit var gatewayInstallCard: LinearLayout
private lateinit var gatewayStatusIcon: TextView
private lateinit var gatewayStatusText: TextView
private lateinit var gatewayStatusDetail: TextView
private lateinit var btnInstallGateway: MaterialButton
private lateinit var btnConfigureGateway: MaterialButton
private lateinit var gatewayProgressBar: ProgressBar
private lateinit var gatewayProgressText: TextView
private lateinit var step3NextButtons: LinearLayout
// Scale step (BLE)
private lateinit var scaleQuestionCard: LinearLayout
private lateinit var bleSetupCard: LinearLayout
private lateinit var tvScanStatus: TextView
private lateinit var btnScanBle: MaterialButton
private lateinit var tvSelectedScale: TextView
private lateinit var rvScaleDevices: RecyclerView
private lateinit var step3NextButtons: LinearLayout
private var bleManager: BleScaleManager? = null
private val discoveredDevices = mutableListOf<BleDeviceInfo>()
private var selectedDevice: BleDeviceInfo? = null
private var deviceAdapter: DeviceAdapter? = null
// Screensaver step
private lateinit var setupSwitchScreensaver: SwitchMaterial
@@ -106,13 +109,6 @@ class SetupActivity : AppCompatActivity() {
private lateinit var permsGrantedCard: LinearLayout
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
private val discoverCancelled = AtomicBoolean(false)
@@ -123,14 +119,8 @@ class SetupActivity : AppCompatActivity() {
private const val KEY_HAS_SCALE = "has_scale"
private const val KEY_LANGUAGE = "kiosk_language"
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 INSTALL_FALLBACK_REQUEST = 2005
private const val BLE_PERMISSION_REQUEST = 2006
fun applyLocale(base: Context, lang: String): Context {
val locale = Locale(lang)
@@ -184,16 +174,22 @@ class SetupActivity : AppCompatActivity() {
}
override fun onDestroy() {
pollHandler.removeCallbacksAndMessages(null)
bleManager?.stopScan()
bleManager?.disconnect()
discoverCancelled.set(true)
super.onDestroy()
}
override fun onResume() {
super.onResume()
// When returning from the gateway app (after pressing "Configura"), refresh status
if (currentStep == 4 && gatewayInstallCard.visibility == View.VISIBLE) {
checkGatewayStatus()
// If we're on step 4 with a saved device, reflect it in the UI
if (currentStep == 4) {
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)
// Scale step
scaleQuestionCard = findViewById(R.id.scaleQuestionCard)
gatewayInfoCard = findViewById(R.id.gatewayInfoCard)
gatewayInstallCard = findViewById(R.id.gatewayInstallCard)
gatewayStatusIcon = findViewById(R.id.gatewayStatusIcon)
gatewayStatusText = findViewById(R.id.gatewayStatusText)
gatewayStatusDetail = findViewById(R.id.gatewayStatusDetail)
btnInstallGateway = findViewById(R.id.btnInstallGateway)
btnConfigureGateway = findViewById(R.id.btnConfigureGateway)
gatewayProgressBar = findViewById(R.id.gatewayProgressBar)
gatewayProgressText = findViewById(R.id.gatewayProgressText)
step3NextButtons = findViewById(R.id.step3NextButtons)
scaleQuestionCard = findViewById(R.id.scaleQuestionCard)
bleSetupCard = findViewById(R.id.bleSetupCard)
tvScanStatus = findViewById(R.id.tvScanStatus)
btnScanBle = findViewById(R.id.btnScanBle)
tvSelectedScale = findViewById(R.id.tvSelectedScale)
rvScaleDevices = findViewById(R.id.rvScaleDevices)
step3NextButtons = findViewById(R.id.step3NextButtons)
// Screensaver step
setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver)
@@ -277,32 +269,41 @@ class SetupActivity : AppCompatActivity() {
}
// ── 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 {
scaleQuestionCard.visibility = View.GONE
gatewayInfoCard.visibility = View.VISIBLE
gatewayInstallCard.visibility = View.VISIBLE
step3NextButtons.visibility = View.VISIBLE
checkGatewayStatus()
prefs.edit().putBoolean(KEY_HAS_SCALE, true).apply()
scaleQuestionCard.visibility = View.GONE
bleSetupCard.visibility = View.VISIBLE
step3NextButtons.visibility = View.VISIBLE
// 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 {
prefs.edit().putBoolean(KEY_HAS_SCALE, false).apply()
bleManager?.stopScan()
showStep(5)
}
btnInstallGateway.setOnClickListener {
pendingApkDownloadUrl = GATEWAY_DOWNLOAD_URL
triggerApkDownload(GATEWAY_DOWNLOAD_URL)
btnScanBle.setOnClickListener { startBleScan() }
findViewById<MaterialButton>(R.id.btnScaleBack).setOnClickListener {
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 {
prefs.edit().putBoolean(KEY_HAS_SCALE, true).apply()
bleManager?.stopScan()
showStep(5)
}
@@ -355,19 +356,26 @@ class SetupActivity : AppCompatActivity() {
// Reset scale step when entering it
if (step == 4) {
val scaleAlreadyConfiguredYes = prefs.contains(KEY_HAS_SCALE) && prefs.getBoolean(KEY_HAS_SCALE, false)
if (scaleAlreadyConfiguredYes) {
// User already confirmed they have a scale — skip the question
scaleQuestionCard.visibility = View.GONE
gatewayInfoCard.visibility = View.VISIBLE
gatewayInstallCard.visibility = View.VISIBLE
step3NextButtons.visibility = View.VISIBLE
checkGatewayStatus()
val hasScaleYes = prefs.contains(KEY_HAS_SCALE) && prefs.getBoolean(KEY_HAS_SCALE, false)
if (hasScaleYes) {
// Already said YES — go straight to BLE scan card
scaleQuestionCard.visibility = View.GONE
bleSetupCard.visibility = View.VISIBLE
step3NextButtons.visibility = View.VISIBLE
val savedName = bleManager?.getSavedDeviceName()
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 {
scaleQuestionCard.visibility = View.VISIBLE
gatewayInfoCard.visibility = View.GONE
gatewayInstallCard.visibility = View.GONE
step3NextButtons.visibility = View.GONE
scaleQuestionCard.visibility = View.VISIBLE
bleSetupCard.visibility = View.GONE
step3NextButtons.visibility = View.GONE
}
}
@@ -414,7 +422,7 @@ class SetupActivity : AppCompatActivity() {
.setTitle(getString(R.string.setup_exit_title))
.setMessage(getString(R.string.setup_exit_message))
.setPositiveButton(getString(R.string.setup_exit_confirm)) { _, _ ->
pollHandler.removeCallbacksAndMessages(null)
bleManager?.stopScan()
discoverCancelled.set(true)
finishAffinity()
}
@@ -443,6 +451,16 @@ class SetupActivity : AppCompatActivity() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
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()) {
onPermissionsGranted()
} else {
@@ -452,7 +470,7 @@ class SetupActivity : AppCompatActivity() {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE) {
if (requestCode == PERMISSION_REQUEST_CODE || requestCode == BLE_PERMISSION_REQUEST) {
onPermissionsGranted()
}
}
@@ -699,381 +717,119 @@ class SetupActivity : AppCompatActivity() {
}.start()
}
// ── Gateway ────────────────────────────────────────────────────────────
// ── BLE Scale ─────────────────────────────────────────────────────────
private fun isGatewayInstalled() = try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0); true
} catch (_: PackageManager.NameNotFoundException) { false }
private fun checkGatewayStatus() {
if (isGatewayInstalled()) {
gatewayStatusIcon.text = ""
gatewayStatusText.text = getString(R.string.wizard_gateway_installed)
gatewayStatusDetail.text = "⏳ Verifica connessione in corso..."
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)
private fun startBleScan() {
val mgr = bleManager ?: return
if (!mgr.hasRequiredPermissions()) {
val needed = mutableListOf<String>()
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 {
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) {
pendingApkDownloadUrl = apkUrl
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
@Suppress("DEPRECATION")
startActivityForResult(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")),
INSTALL_PERM_REQUEST
)
return
}
setGatewayUI("", getString(R.string.install_downloading), "", 0xFF94a3b8.toInt(), btnEnabled = false, progress = -1)
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 onDeviceSelected(info: BleDeviceInfo) {
bleManager?.stopScan()
selectedDevice = info
bleManager?.saveDevice(info.device.address, info.name)
tvSelectedScale.text = "${info.name}"
tvSelectedScale.visibility = View.VISIBLE
tvScanStatus.text = "Bilancia selezionata. Premi Avanti per continuare."
tvScanStatus.setTextColor(0xFF34d399.toInt())
btnScanBle.isEnabled = true
btnScanBle.text = "🔄 Scansiona di nuovo"
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
}
private fun installApk(file: java.io.File) {
if (!file.exists() || file.length() == 0L) {
setGatewayUI("", getString(R.string.install_error_download), "File APK non trovato sul dispositivo.", 0xFFf87171.toInt())
return
private fun makeBleListener() = object : BleScaleListener {
override fun onDeviceFound(info: BleDeviceInfo) {
val existing = discoveredDevices.indexOfFirst { it.device.address == info.device.address }
if (existing >= 0) {
discoveredDevices[existing] = info
deviceAdapter?.notifyItemChanged(existing)
} else {
discoveredDevices.add(info)
deviceAdapter?.notifyItemInserted(discoveredDevices.size - 1)
}
}
// Validate APK magic bytes (ZIP header)
val magic = try { file.inputStream().use { s -> val b = ByteArray(4); s.read(b); b } } catch (_: Exception) { null }
if (magic == null || magic[0] != 0x50.toByte() || magic[1] != 0x4B.toByte()) {
setGatewayUI("", getString(R.string.install_error_download), "Il file scaricato non è un APK valido.", 0xFFf87171.toInt())
file.delete()
return
override fun onConnecting(device: BluetoothDevice) {}
override fun onConnected(deviceName: String) {}
override fun onDisconnected() {}
override fun onWeightReceived(reading: WeightReading) {}
override fun onBatteryReceived(level: Int) {}
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)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
AlertDialog.Builder(this)
.setTitle("⚠️ Permesso mancante")
.setMessage("Per installare il Gateway è necessario abilitare \"Installa app sconosciute\" per questa app.\n\nTocca OK per aprire le impostazioni.")
.setPositiveButton("OK") { _, _ ->
pendingInstallFile = file
@Suppress("DEPRECATION")
startActivityForResult(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")),
INSTALL_PERM_REQUEST
)
}
.setNegativeButton("Annulla", null)
.show()
return
override fun onScanStopped() {
btnScanBle.isEnabled = true
if (discoveredDevices.isEmpty()) {
tvScanStatus.text = "Nessuna bilancia trovata. Assicurati che sia accesa e vicina."
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
} else {
tvScanStatus.text = "Seleziona la tua bilancia dall'elenco."
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
}
}
installWithPackageInstaller(file, GATEWAY_PACKAGE)
override fun onDebugEvent(message: String) {}
}
private fun installWithPackageInstaller(file: java.io.File, targetPkg: String) {
try {
val pi = packageManager.packageInstaller
val params = android.content.pm.PackageInstaller.SessionParams(
android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
// 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.
// ── Device list adapter ────────────────────────────────────────────────
val action = "it.dadaloop.evershelf.kiosk.SETUP_INSTALL_$sessionId"
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val status = intent?.getIntExtra(
android.content.pm.PackageInstaller.EXTRA_STATUS,
android.content.pm.PackageInstaller.STATUS_FAILURE
) ?: android.content.pm.PackageInstaller.STATUS_FAILURE
private inner class DeviceAdapter(
private val onSelect: (BleDeviceInfo) -> Unit,
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
when (status) {
android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Do NOT unregister here — on Android 11+ the final result
// (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)))
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val tvName: TextView = view.findViewById(android.R.id.text1)
val tvDetail: TextView = view.findViewById(android.R.id.text2)
}
}
private fun offerUninstallAndRetry(file: java.io.File, pkg: String) {
pendingInstallFile = file
pendingInstallPkg = pkg
AlertDialog.Builder(this)
.setTitle("⚠️ Conflitto firma APK")
.setMessage("L'app installata usa una firma diversa. Devi prima disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione riprenderà automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
@Suppress("DEPRECATION")
startActivityForResult(
Intent(Intent.ACTION_DELETE, Uri.parse("package:$pkg")),
UNINSTALL_REQUEST
)
}
.setNegativeButton("Annulla", null)
.show()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val v = LayoutInflater.from(parent.context)
.inflate(android.R.layout.simple_list_item_2, parent, false)
v.setBackgroundColor(0x1A7c3aed)
val density = parent.context.resources.displayMetrics.density
val lp = v.layoutParams as? RecyclerView.LayoutParams
lp?.bottomMargin = (6 * density).toInt()
v.layoutParams = lp
val pad = (12 * density).toInt()
val padV = (10 * density).toInt()
v.setPadding(pad, padV, pad, padV)
return VH(v)
}
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 ─────────────────────────────────────────────────
@@ -1082,15 +838,16 @@ class SetupActivity : AppCompatActivity() {
val url = prefs.getString(KEY_URL, "") ?: ""
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
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 langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
val sb = StringBuilder()
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
sb.appendLine(when {
gwOk -> "Scale Gateway: ${getString(R.string.wizard_gateway_installed)}"
hasScale -> "⚠️ Scale Gateway: ${getString(R.string.wizard_gateway_not_installed)}"
scaleOk -> "Bilancia: $scaleName"
hasScale -> "⚠️ Bilancia: da configurare"
else -> "${getString(R.string.summary_scale_skip)}"
})
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() {
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('/')
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)
Thread {
try {
@@ -1132,147 +886,4 @@ class SetupActivity : AppCompatActivity() {
setResult(RESULT_OK)
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:visibility="visible">
<TextView
android:layout_width="wrap_content"
<ImageView
android:layout_width="160dp"
android:layout_height="wrap_content"
android:text="🌐"
android:textSize="64sp"
android:layout_marginBottom="16dp" />
android:src="@drawable/ic_logo"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:layout_marginBottom="24dp"
android:contentDescription="EverShelf" />
<!-- Title shown in all 3 languages so it's always readable -->
<TextView
@@ -132,10 +134,12 @@
<!-- App logo -->
<ImageView
android:layout_width="96dp"
android:layout_height="96dp"
android:src="@drawable/ic_launcher_foreground"
android:layout_marginBottom="16dp"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:src="@drawable/ic_logo"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:layout_marginBottom="20dp"
android:contentDescription="EverShelf" />
<TextView
@@ -196,7 +200,7 @@
<TextView
android:layout_width="22dp"
android:layout_height="wrap_content"
android:text="🏠"
android:text="💻"
android:textSize="14sp" />
<TextView
@@ -804,38 +808,9 @@
android:textColor="#64748b" />
</LinearLayout>
<!-- Gateway info card (shown after YES) -->
<!-- BLE scan card (shown after YES) -->
<LinearLayout
android:id="@+id/gatewayInfoCard"
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:id="@+id/bleSetupCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
@@ -845,82 +820,46 @@
android:visibility="gone">
<TextView
android:id="@+id/gatewayStatusIcon"
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:id="@+id/tvScanStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Scale Gateway non installato"
android:textColor="#cbd5e1"
android:textSize="16sp"
android:text="Cerca la tua bilancia Bluetooth nelle vicinanze e selezionala dall'elenco."
android:textColor="#94a3b8"
android:textSize="14sp"
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
android:id="@+id/gatewayStatusDetail"
android:id="@+id/tvSelectedScale"
android:layout_width="match_parent"
android:layout_height="wrap_content"
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:layout_marginTop="8dp"
android:textSize="15sp"
android:textStyle="bold"
android:gravity="center"
android:layout_marginBottom="8dp"
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>
<!-- Step 3 navigation (shown after YES) -->
<!-- Step 4 navigation (shown after YES) -->
<LinearLayout
android:id="@+id/step3NextButtons"
android:layout_width="match_parent"
@@ -1144,4 +1083,35 @@
</LinearLayout>
</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>