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:
@@ -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 6–11) -->
|
||||
<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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
-100
@@ -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,9 +266,24 @@ 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
|
||||
if (conn is java.net.HttpURLConnection) {
|
||||
conn.requestMethod = "GET"
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
@@ -249,6 +294,7 @@ class KioskActivity : AppCompatActivity() {
|
||||
showUrlStatus("⚠ Server responded with code $code", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
showUrlStatus("✗ Cannot reach server: ${e.message}", false)
|
||||
@@ -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()
|
||||
|
||||
-194
@@ -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)
|
||||
}
|
||||
+45
-15
@@ -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,9 +93,23 @@ 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
|
||||
if (conn is java.net.HttpURLConnection) {
|
||||
conn.requestMethod = "GET"
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
@@ -91,6 +120,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
Toast.makeText(this, "⚠ Server responded: $code", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(this, "✗ Cannot reach server", Toast.LENGTH_SHORT).show()
|
||||
|
||||
Reference in New Issue
Block a user