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)
@@ -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)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 227 KiB |
|
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>
|
||||
|
||||