refactor(kiosk): remove built-in scale, add SSL + gateway detection

- Remove BLE/scale code (BleScaleManager, ScaleProtocol, GatewayWebSocketServer, ScaleGatewayService)
- Kiosk is now a pure WebView wrapper — scale handled by standalone gateway app
- Fix SSL certificate error: accept self-signed certs for local servers (WebView + connection test)
- Add gateway APK detection: check if it.dadaloop.evershelf.scalegate is installed
- If gateway installed: show green status, auto-launch on finish
- If not installed: show download link to GitHub releases
- Remove BLE/foreground service permissions from manifest
- Remove java-websocket dependency
- Bump version to 1.1.0
This commit is contained in:
dadaloop82
2026-04-16 16:40:11 +00:00
parent f8c8dfb990
commit 9363bc147e
8 changed files with 155 additions and 903 deletions
+2 -6
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
versionCode = 2
versionName = "1.1.0"
}
buildTypes {
@@ -41,8 +41,4 @@ dependencies {
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.webkit:webkit:1.10.0")
// WebSocket server (for scale gateway)
implementation("org.java-websocket:Java-WebSocket:1.5.5")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
@@ -1,33 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- BLE permissions for Android < 12 -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE permissions for Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location (required for BLE scanning on Android 611) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Keep screen on / foreground service -->
<!-- Keep screen on -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application
android:allowBackup="true"
@@ -54,11 +34,6 @@
android:exported="false"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" />
<service
android:name=".ScaleGatewayService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
</application>
</manifest>
@@ -1,320 +0,0 @@
package it.dadaloop.evershelf.kiosk
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 = 15_000L
private const val PREFS_NAME = "evershelf_kiosk"
private const val PREF_LAST_DEVICE = "last_device_address"
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? {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_LAST_DEVICE, null)
}
private fun saveDeviceAddress(address: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(PREF_LAST_DEVICE, address).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 not available.")
return
}
if (!adapter.isEnabled) {
listener.onError("Bluetooth is off.")
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 (e: 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() }
?: getDeviceName(device)
val proximity = rssiToProximity(result.rssi)
val score = scoreLikelyScale(name, result.scanRecord)
val info = BleDeviceInfo(device, name, result.rssi, proximity, 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 getDeviceName(device: BluetoothDevice): String {
return try { device.name?.takeIf { it.isNotBlank() } ?: "Unnamed" } catch (_: SecurityException) { "Unnamed" }
}
private fun rssiToProximity(rssi: Int) = when {
rssi >= -60 -> "Near"; rssi >= -80 -> "Medium"; else -> "Far"
}
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.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",
"decent", "skale", "timemore", "brewista", "hario", "greater goods", "ozeri", "etekcity",
"nutri", "nicewell", "koios", "renpho", "eatsmart")
if (foodKeywords.any { lower.contains(it) }) score += 10
val bodyKeywords = listOf("body", "fat", "bmi", "composition", "fitness", "mi body", "lepulse", "qardio", "garmin", "withings")
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("Missing permission: ${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("GATT services not found (status=$status)") }
return
}
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)?.let { targetChars.add(it) }
gatt.getService(BleUuids.FFE0)?.let { svc ->
svc.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)?.let { svc ->
svc.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("No weight characteristic found.") }
return
}
gatt.getService(BleUuids.BATTERY_SERVICE)
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)?.let { targetChars.add(it) }
try { gatt.device?.address?.let { saveDeviceAddress(it) } } 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) {
val data = characteristic.value ?: return
processCharacteristicData(characteristic, data)
}
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 (e: SecurityException) {
Log.e(TAG, "SecurityException enabling notification", e)
}
}
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)
if (reading != null && reading.value > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
}
}
}
@@ -1,100 +0,0 @@
package it.dadaloop.evershelf.kiosk
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()
}
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) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
conn.send(lastStatusJson)
lastWeightJson?.let { conn.send(it) }
eventListener?.onClientConnected(addr)
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
pendingWeightRequests.remove(conn)
eventListener?.onClientDisconnected(addr)
}
override fun onMessage(conn: WebSocket, message: String) {
try {
val json = JSONObject(message)
when (json.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")
val rounded = Math.round(value * 10f) / 10.0
obj.put("value", rounded)
obj.put("unit", unit)
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
}
}
@@ -1,24 +1,22 @@
package it.dadaloop.evershelf.kiosk
import android.Manifest
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import android.webkit.ConsoleMessage
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
@@ -26,18 +24,17 @@ import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class KioskActivity : AppCompatActivity() {
@@ -58,34 +55,16 @@ class KioskActivity : AppCompatActivity() {
private lateinit var scaleStatusText: TextView
private lateinit var scaleStatusDetail: TextView
// Scale service
private var scaleService: ScaleGatewayService? = null
private var serviceBound = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val localBinder = binder as ScaleGatewayService.LocalBinder
scaleService = localBinder.getService()
serviceBound = true
scaleService?.statusCallback = { status, device, battery ->
runOnUiThread { updateScaleStatusUI(status, device, battery) }
}
}
override fun onServiceDisconnected(name: ComponentName?) {
scaleService = null
serviceBound = false
}
}
// File chooser
private var fileChooserCallback: ValueCallback<Array<Uri>>? = null
companion object {
private const val BLE_PERMISSION_REQUEST = 1001
private const val FILE_CHOOSER_REQUEST = 1002
private const val PREFS_NAME = "evershelf_kiosk"
private const val KEY_URL = "evershelf_url"
private const val KEY_SETUP_COMPLETE = "setup_complete"
private const val KEY_LAST_DEVICE = "last_device_address"
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"
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -136,7 +115,6 @@ class KioskActivity : AppCompatActivity() {
return@setOnClickListener
}
prefs.edit().putString(KEY_URL, url).apply()
requestBlePermissions()
goToStep(3)
}
@@ -145,6 +123,7 @@ class KioskActivity : AppCompatActivity() {
goToStep(2)
}
findViewById<MaterialButton>(R.id.btnFinish).setOnClickListener {
launchGatewayIfInstalled()
finishWizard()
}
findViewById<MaterialButton>(R.id.btnSkipScale).setOnClickListener {
@@ -180,7 +159,7 @@ class KioskActivity : AppCompatActivity() {
updateStepIndicator()
if (step == 3) {
startScaleGateway()
checkGatewayStatus()
}
}
@@ -223,6 +202,57 @@ class KioskActivity : AppCompatActivity() {
goToStep(1)
}
// ── Gateway Detection & Launch ────────────────────────────────────────
private fun isGatewayInstalled(): Boolean {
return try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
private fun launchGatewayIfInstalled() {
if (isGatewayInstalled()) {
val launchIntent = packageManager.getLaunchIntentForPackage(GATEWAY_PACKAGE)
if (launchIntent != null) {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(launchIntent)
}
}
}
private fun checkGatewayStatus() {
if (isGatewayInstalled()) {
scaleStatusIcon.text = ""
scaleStatusText.text = "Scale Gateway is installed"
scaleStatusDetail.text = "It will be launched automatically when you finish setup"
scaleStatusDetail.setTextColor(0xFF34d399.toInt())
// Hide skip, show finish prominently
findViewById<MaterialButton>(R.id.btnSkipScale).visibility = View.GONE
} else {
scaleStatusIcon.text = "📥"
scaleStatusText.text = "Scale Gateway not installed"
scaleStatusDetail.text = "You need the EverShelf Scale Gateway app to use a Bluetooth scale"
scaleStatusDetail.setTextColor(0xFFfbbf24.toInt())
// Show download button in the card
val downloadBtn = findViewById<MaterialButton>(R.id.btnFinish)
downloadBtn.text = "🚀 Launch EverShelf (without scale)"
findViewById<MaterialButton>(R.id.btnSkipScale).apply {
text = "📥 Download Scale Gateway"
setTextColor(0xFF7c3aed.toInt())
visibility = View.VISIBLE
setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(GATEWAY_DOWNLOAD_URL))
startActivity(intent)
}
}
}
}
// ── Connection Test ───────────────────────────────────────────────────
private fun testConnection() {
@@ -236,17 +266,33 @@ class KioskActivity : AppCompatActivity() {
Thread {
try {
val testUrl = if (url.endsWith("/")) "${url}api/" else "${url}/api/"
val conn = URL(testUrl).openConnection() as HttpURLConnection
val conn = URL(testUrl).openConnection()
// Trust all certs for local/self-signed servers
if (conn is HttpsURLConnection) {
val trustAll = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
conn.sslSocketFactory = sc.socketFactory
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
conn.connectTimeout = 5000
conn.readTimeout = 5000
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
runOnUiThread {
if (code in 200..299) {
showUrlStatus("✓ Connected successfully!", true)
} else {
showUrlStatus("⚠ Server responded with code $code", false)
if (conn is java.net.HttpURLConnection) {
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
runOnUiThread {
if (code in 200..299) {
showUrlStatus("✓ Connected successfully!", true)
} else {
showUrlStatus("⚠ Server responded with code $code", false)
}
}
}
} catch (e: Exception) {
@@ -269,75 +315,6 @@ class KioskActivity : AppCompatActivity() {
)
}
// ── Scale Gateway ─────────────────────────────────────────────────────
private fun startScaleGateway() {
val intent = Intent(this, ScaleGatewayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun updateScaleStatusUI(status: String, device: String?, battery: Int?) {
when {
status.contains("Connected", ignoreCase = true) -> {
scaleStatusIcon.text = ""
scaleStatusText.text = "Scale connected!"
val detail = buildString {
append(device ?: status)
if (battery != null) append(" • Battery: $battery%")
}
scaleStatusDetail.text = detail
scaleStatusDetail.setTextColor(0xFF34d399.toInt())
}
status.contains("Scanning", ignoreCase = true) ||
status.contains("search", ignoreCase = true) -> {
scaleStatusIcon.text = "🔍"
scaleStatusText.text = "Scanning for scales..."
scaleStatusDetail.text = "Turn on your scale and place it nearby"
scaleStatusDetail.setTextColor(0xFF64748b.toInt())
}
status.contains("Ready", ignoreCase = true) ||
status.contains("running", ignoreCase = true) -> {
scaleStatusIcon.text = "📡"
scaleStatusText.text = "Gateway is running"
scaleStatusDetail.text = status
scaleStatusDetail.setTextColor(0xFF94a3b8.toInt())
}
else -> {
scaleStatusIcon.text = "📡"
scaleStatusText.text = "Gateway active"
scaleStatusDetail.text = status
scaleStatusDetail.setTextColor(0xFF94a3b8.toInt())
}
}
}
// ── BLE Permissions ───────────────────────────────────────────────────
private fun requestBlePermissions() {
val perms = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED)
perms.add(Manifest.permission.BLUETOOTH_SCAN)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED)
perms.add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
perms.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
perms.add(Manifest.permission.POST_NOTIFICATIONS)
}
if (perms.isNotEmpty()) {
ActivityCompat.requestPermissions(this, perms.toTypedArray(), BLE_PERMISSION_REQUEST)
}
}
// ── WebView ───────────────────────────────────────────────────────────
@SuppressLint("SetJavaScriptEnabled")
@@ -352,6 +329,13 @@ class KioskActivity : AppCompatActivity() {
settings.allowFileAccess = true
webView.webViewClient = object : WebViewClient() {
override fun onReceivedSslError(
view: WebView?, handler: SslErrorHandler?, error: SslError?
) {
// Accept self-signed certs for local network servers
handler?.proceed()
}
override fun onReceivedError(
view: WebView?, request: WebResourceRequest?,
error: WebResourceError?
@@ -385,8 +369,8 @@ class KioskActivity : AppCompatActivity() {
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
webView.loadUrl(url)
// Start scale gateway
startScaleGateway()
// Launch gateway app if installed (handles scale in background)
launchGatewayIfInstalled()
// Keep screen on
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -443,17 +427,19 @@ class KioskActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
enterImmersiveMode()
// Reload WebView if setup is complete (in case URL changed in settings)
if (prefs.getBoolean(KEY_SETUP_COMPLETE, false) && webView.visibility == View.VISIBLE) {
val url = prefs.getString(KEY_URL, "") ?: ""
if (url.isNotEmpty() && webView.url != url) {
webView.loadUrl(url)
}
}
// Check if wizard reset was requested
if (!prefs.getBoolean(KEY_SETUP_COMPLETE, false) && wizardContainer.visibility != View.VISIBLE) {
showWizard()
}
// Re-check gateway status if on step 3
if (currentStep == 3 && wizardContainer.visibility == View.VISIBLE) {
checkGatewayStatus()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -467,14 +453,6 @@ class KioskActivity : AppCompatActivity() {
}
}
override fun onDestroy() {
super.onDestroy()
if (serviceBound) {
unbindService(serviceConnection)
serviceBound = false
}
}
override fun onBackPressed() {
if (webView.visibility == View.VISIBLE && webView.canGoBack()) {
webView.goBack()
@@ -1,194 +0,0 @@
package it.dadaloop.evershelf.kiosk
import android.app.*
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
private const val TAG = "ScaleGtwService"
private const val CHANNEL_ID = "scale_gateway"
private const val NOTIFICATION_ID = 1
private const val WS_PORT = 8765
private const val RECONNECT_DELAY_MS = 5000L
class ScaleGatewayService : Service(), BleScaleListener, ServerEventListener {
private var bleManager: BleScaleManager? = null
private var wsServer: GatewayWebSocketServer? = null
private var lastBattery: Int? = null
private var connectedDeviceName: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
// Binder so KioskActivity can get status updates
inner class LocalBinder : Binder() {
fun getService(): ScaleGatewayService = this@ScaleGatewayService
}
private val binder = LocalBinder()
// Callbacks for the activity
var statusCallback: ((String, String?, Int?) -> Unit)? = null // state, device, battery
var weightCallback: ((Float, String, Boolean) -> Unit)? = null // value, unit, stable
override fun onBind(intent: Intent?): IBinder = binder
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(NOTIFICATION_ID, buildNotification("Starting..."))
// Start WebSocket server
wsServer = GatewayWebSocketServer(WS_PORT, this).also {
try { it.start() } catch (e: Exception) {
Log.e(TAG, "Failed to start WS server", e)
}
}
// Start BLE manager
bleManager = BleScaleManager(this, this).also {
if (it.hasRequiredPermissions()) {
it.enableAutoConnect()
it.startScan()
}
}
}
override fun onDestroy() {
bleManager?.disconnect()
bleManager?.stopScan()
try { wsServer?.stop(1000) } catch (_: Exception) {}
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
fun startScaleScan() {
bleManager?.let {
if (it.hasRequiredPermissions()) {
it.enableAutoConnect()
it.startScan()
}
}
}
fun disconnectScale() {
bleManager?.disconnect()
connectedDeviceName = null
wsServer?.publishStatus("disconnected", null, null)
updateNotification("Gateway active — no scale")
statusCallback?.invoke("disconnected", null, null)
}
fun connectDevice(device: BluetoothDevice) {
bleManager?.connect(device)
}
val isScaleConnected: Boolean get() = bleManager?.isConnected == true
// ─── BleScaleListener ──────────────────────────────────────────────────
override fun onDeviceFound(info: BleDeviceInfo) {}
override fun onConnecting(device: BluetoothDevice) {
updateNotification("Connecting...")
statusCallback?.invoke("connecting", null, null)
}
override fun onConnected(deviceName: String) {
connectedDeviceName = deviceName
wsServer?.publishStatus("connected", deviceName, lastBattery)
updateNotification("Connected: $deviceName")
statusCallback?.invoke("connected", deviceName, lastBattery)
}
override fun onDisconnected() {
connectedDeviceName = null
wsServer?.publishStatus("disconnected", null, null)
updateNotification("Scale disconnected — reconnecting...")
statusCallback?.invoke("disconnected", null, null)
// Auto-reconnect
mainHandler.postDelayed({
bleManager?.let {
if (!it.isConnected && it.hasRequiredPermissions()) {
it.enableAutoConnect()
it.startScan()
}
}
}, RECONNECT_DELAY_MS)
}
override fun onWeightReceived(reading: WeightReading) {
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, lastBattery)
weightCallback?.invoke(reading.value, reading.unit, reading.stable)
}
override fun onBatteryReceived(level: Int) {
lastBattery = level
wsServer?.publishStatus("connected", connectedDeviceName, level)
}
override fun onError(message: String) {
Log.w(TAG, "BLE error: $message")
}
override fun onScanStopped() {}
override fun onDebugEvent(message: String) {}
// ─── 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() {}
// ─── Notification ──────────────────────────────────────────────────────
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Scale Gateway",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "EverShelf Scale Gateway running"
setShowBadge(false)
}
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
private fun buildNotification(text: String): Notification {
val intent = Intent(this, KioskActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("EverShelf Gateway")
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(text: String) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(text))
}
}
@@ -1,113 +0,0 @@
package it.dadaloop.evershelf.kiosk
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
data class WeightReading(
val value: Float,
val unit: String,
val stable: Boolean,
)
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")
}
object ScaleProtocol {
private const val MAX_GRAMS = 15000f
private const val MIN_GRAMS = 0.5f
fun resetState() {}
fun parse(
char: BluetoothGattCharacteristic,
data: ByteArray,
debug: ((String) -> Unit)? = null,
): WeightReading? {
if (data.size < 2) 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
if (lb < 0.01f || lb > 33f) null else WeightReading(lb, "lb", stable = true)
} else {
val g = raw * 5f
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)) 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
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, "p1LEg"), C(1, true, 1f, "p1BEg"),
C(2, false, 1f, "p2LEg"), C(2, true, 1f, "p2BEg"),
C(3, false, 1f, "p3LEg"), C(3, true, 1f, "p3BEg"),
C(1, false, 10f, "p1LE.1g"), C(1, true, 10f, "p1BE.1g"),
C(2, false, 10f, "p2LE.1g"), C(2, true, 10f, "p2BE.1g"),
C(3, false, 10f, "p3LE.1g"), C(3, true, 10f, "p3BE.1g"),
C(1, false, 2f, "p1LE.5g"), C(1, true, 2f, "p1BE.5g"),
C(1, false, 0.1f, "p1LEcg"), C(1, true, 0.1f, "p1BEcg"),
C(3, false, 0.1f, "p3LEcg"), C(3, true, 0.1f, "p3BEcg"),
)
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) return WeightReading(g, "g", stable = false)
}
return null
}
private fun u16le(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
private fun u16be(b: ByteArray, off: Int): Int =
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
}
@@ -1,15 +1,21 @@
package it.dadaloop.evershelf.kiosk
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.button.MaterialButton
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class SettingsActivity : AppCompatActivity() {
@@ -20,7 +26,7 @@ class SettingsActivity : AppCompatActivity() {
private const val PREFS_NAME = "evershelf_kiosk"
private const val KEY_URL = "evershelf_url"
private const val KEY_SETUP_COMPLETE = "setup_complete"
private const val KEY_LAST_DEVICE = "last_device_address"
private const val GATEWAY_PACKAGE = "it.dadaloop.evershelf.scalegate"
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -30,23 +36,32 @@ class SettingsActivity : AppCompatActivity() {
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
urlEdit = findViewById(R.id.urlEdit)
// Load saved URL
urlEdit.setText(prefs.getString(KEY_URL, "") ?: "")
// Scale status
val scaleDevice = prefs.getString(KEY_LAST_DEVICE, null)
findViewById<TextView>(R.id.scaleDeviceInfo).text =
if (scaleDevice != null) "Last connected: $scaleDevice" else "No scale connected yet"
// Back button
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener {
finish()
// Gateway status
val gatewayInstalled = try {
packageManager.getPackageInfo(GATEWAY_PACKAGE, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
val statusView = findViewById<TextView>(R.id.scaleGatewayStatus)
val deviceView = findViewById<TextView>(R.id.scaleDeviceInfo)
if (gatewayInstalled) {
statusView.text = "Installed"
statusView.setTextColor(0xFF34d399.toInt())
deviceView.text = "EverShelf Scale Gateway app is installed"
} else {
statusView.text = "Not installed"
statusView.setTextColor(0xFFfbbf24.toInt())
deviceView.text = "Install the Scale Gateway app to use a Bluetooth scale"
}
// Back
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
// Test connection
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener {
testConnection()
}
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
// Run wizard again
findViewById<MaterialButton>(R.id.btnRunWizard).setOnClickListener {
@@ -78,17 +93,32 @@ class SettingsActivity : AppCompatActivity() {
Thread {
try {
val testUrl = if (url.endsWith("/")) "${url}api/" else "${url}/api/"
val conn = URL(testUrl).openConnection() as HttpURLConnection
val conn = URL(testUrl).openConnection()
if (conn is HttpsURLConnection) {
val trustAll = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
conn.sslSocketFactory = sc.socketFactory
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
conn.connectTimeout = 5000
conn.readTimeout = 5000
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
runOnUiThread {
if (code in 200..299) {
Toast.makeText(this, "✓ Connection successful!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "⚠ Server responded: $code", Toast.LENGTH_SHORT).show()
if (conn is java.net.HttpURLConnection) {
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
runOnUiThread {
if (code in 200..299) {
Toast.makeText(this, "✓ Connection successful!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "⚠ Server responded: $code", Toast.LENGTH_SHORT).show()
}
}
}
} catch (e: Exception) {