diff --git a/assets/css/style.css b/assets/css/style.css index 3452835..f1faa45 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -2575,6 +2575,16 @@ body { .nav-icon { 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 { diff --git a/assets/img/logo/logo.png b/assets/img/logo/logo.png index ad19c75..916a108 100644 Binary files a/assets/img/logo/logo.png and b/assets/img/logo/logo.png differ diff --git a/assets/img/logo/logo_icon.png b/assets/img/logo/logo_icon.png index 5417102..7ab2f28 100644 Binary files a/assets/img/logo/logo_icon.png and b/assets/img/logo/logo_icon.png differ diff --git a/evershelf-kiosk/app/build.gradle.kts b/evershelf-kiosk/app/build.gradle.kts index 0f0f747..4ca0eb6 100644 --- a/evershelf-kiosk/app/build.gradle.kts +++ b/evershelf-kiosk/app/build.gradle.kts @@ -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") } diff --git a/evershelf-kiosk/app/src/main/AndroidManifest.xml b/evershelf-kiosk/app/src/main/AndroidManifest.xml index 3ec694a..1c09103 100644 --- a/evershelf-kiosk/app/src/main/AndroidManifest.xml +++ b/evershelf-kiosk/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -20,16 +21,28 @@ - + - + - - - - + + + + + + + + + + + + + + + + + = 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() - 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) } diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt index 8e0825a..3417cc8 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SetupActivity.kt @@ -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() + 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(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(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(R.id.btnScaleNext).isEnabled = true + } else { + findViewById(R.id.btnScaleNext).isEnabled = false + startBleScan() + } } findViewById(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(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(R.id.btnScaleBack).setOnClickListener { showStep(3) } findViewById(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(R.id.btnScaleNext).isEnabled = true + } else { + tvSelectedScale.visibility = View.GONE + findViewById(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, 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() + 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(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() { - 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" - } } diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/BleScaleManager.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/BleScaleManager.kt new file mode 100644 index 0000000..520189c --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/BleScaleManager.kt @@ -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() + + 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() + 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) } + } + } +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/GatewayService.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/GatewayService.kt new file mode 100644 index 0000000..69ceacd --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/GatewayService.kt @@ -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)) + } +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/GatewayWebSocketServer.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/GatewayWebSocketServer.kt new file mode 100644 index 0000000..9efdf8c --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/GatewayWebSocketServer.kt @@ -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 = + 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() + } +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/ScaleProtocol.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/ScaleProtocol.kt new file mode 100644 index 0000000..249e4cf --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/scale/ScaleProtocol.kt @@ -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) +} diff --git a/evershelf-kiosk/app/src/main/res/drawable-hdpi/ic_logo.png b/evershelf-kiosk/app/src/main/res/drawable-hdpi/ic_logo.png index 0b65f5d..57af7dd 100644 Binary files a/evershelf-kiosk/app/src/main/res/drawable-hdpi/ic_logo.png and b/evershelf-kiosk/app/src/main/res/drawable-hdpi/ic_logo.png differ diff --git a/evershelf-kiosk/app/src/main/res/drawable-mdpi/ic_logo.png b/evershelf-kiosk/app/src/main/res/drawable-mdpi/ic_logo.png index 8fcc2d7..cf2870c 100644 Binary files a/evershelf-kiosk/app/src/main/res/drawable-mdpi/ic_logo.png and b/evershelf-kiosk/app/src/main/res/drawable-mdpi/ic_logo.png differ diff --git a/evershelf-kiosk/app/src/main/res/drawable-xhdpi/ic_logo.png b/evershelf-kiosk/app/src/main/res/drawable-xhdpi/ic_logo.png index b593ef5..27b16f8 100644 Binary files a/evershelf-kiosk/app/src/main/res/drawable-xhdpi/ic_logo.png and b/evershelf-kiosk/app/src/main/res/drawable-xhdpi/ic_logo.png differ diff --git a/evershelf-kiosk/app/src/main/res/drawable-xxhdpi/ic_logo.png b/evershelf-kiosk/app/src/main/res/drawable-xxhdpi/ic_logo.png index 9a72f58..31ff60b 100644 Binary files a/evershelf-kiosk/app/src/main/res/drawable-xxhdpi/ic_logo.png and b/evershelf-kiosk/app/src/main/res/drawable-xxhdpi/ic_logo.png differ diff --git a/evershelf-kiosk/app/src/main/res/drawable-xxxhdpi/ic_logo.png b/evershelf-kiosk/app/src/main/res/drawable-xxxhdpi/ic_logo.png index 9a72f58..31ff60b 100644 Binary files a/evershelf-kiosk/app/src/main/res/drawable-xxxhdpi/ic_logo.png and b/evershelf-kiosk/app/src/main/res/drawable-xxxhdpi/ic_logo.png differ diff --git a/evershelf-kiosk/app/src/main/res/layout/activity_setup.xml b/evershelf-kiosk/app/src/main/res/layout/activity_setup.xml index d95d274..eb7e9bf 100644 --- a/evershelf-kiosk/app/src/main/res/layout/activity_setup.xml +++ b/evershelf-kiosk/app/src/main/res/layout/activity_setup.xml @@ -69,12 +69,14 @@ android:gravity="center_horizontal" android:visibility="visible"> - + android:src="@drawable/ic_logo" + android:adjustViewBounds="true" + android:scaleType="fitCenter" + android:layout_marginBottom="24dp" + android:contentDescription="EverShelf" /> - + - - - - - - - - - - + android:layout_marginBottom="16dp" /> + + - - - - - - - - + + + - + + + + + + + + + + diff --git a/evershelf-scale-gateway/app/build.gradle.kts b/evershelf-scale-gateway/app/build.gradle.kts index 5f2835a..7d7fa33 100644 --- a/evershelf-scale-gateway/app/build.gradle.kts +++ b/evershelf-scale-gateway/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "it.dadaloop.evershelf.scalegate" minSdk = 24 targetSdk = 34 - versionCode = 7 - versionName = "2.1.0" + versionCode = 8 + versionName = "2.1.1" } buildFeatures { diff --git a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt index b51f944..34b22fb 100644 --- a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt +++ b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt @@ -478,8 +478,22 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener } // Only show banner if the release actually contains our APK if (apkUrl.isEmpty()) return@Thread - // If semver tag matches current version → already up to date - if (isSemver && norm(latestTag) == norm(current)) return@Thread + + // Proper semver comparison: only update if remote is strictly newer + fun semverNewer(remote: String, local: String): Boolean { + val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 } + val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 } + val len = maxOf(r.size, l.size) + for (i in 0 until len) { + val rv = r.getOrElse(i) { 0 } + val lv = l.getOrElse(i) { 0 } + if (rv != lv) return rv > lv + } + return false + } + + if (current.isEmpty()) return@Thread + if (isSemver && !semverNewer(norm(latestTag), norm(current))) return@Thread val label = if (isSemver) "$current → $latestTag" else latestTag val msg = "⬆️ Scale Gateway $label" diff --git a/index.html b/index.html index bc7c1f8..2bc80df 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ EverShelf - + @@ -1158,7 +1158,7 @@