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