diff --git a/evershelf-kiosk/app/build.gradle.kts b/evershelf-kiosk/app/build.gradle.kts index 10d007f..e771eb0 100644 --- a/evershelf-kiosk/app/build.gradle.kts +++ b/evershelf-kiosk/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "it.dadaloop.evershelf.kiosk" minSdk = 24 targetSdk = 34 - versionCode = 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") } diff --git a/evershelf-kiosk/app/src/main/AndroidManifest.xml b/evershelf-kiosk/app/src/main/AndroidManifest.xml index 85e6f44..ab89e1d 100644 --- a/evershelf-kiosk/app/src/main/AndroidManifest.xml +++ b/evershelf-kiosk/app/src/main/AndroidManifest.xml @@ -1,33 +1,13 @@ - - - - - - - - - - - - - + - - - - - - - diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/BleScaleManager.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/BleScaleManager.kt deleted file mode 100644 index 6bc4967..0000000 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/BleScaleManager.kt +++ /dev/null @@ -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() - - 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() - - 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) } - } - } -} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/GatewayWebSocketServer.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/GatewayWebSocketServer.kt deleted file mode 100644 index e158809..0000000 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/GatewayWebSocketServer.kt +++ /dev/null @@ -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 = - 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() - } -} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt index 61b4aeb..31f95f8 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -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>? = 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(R.id.btnFinish).setOnClickListener { + launchGatewayIfInstalled() finishWizard() } findViewById(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(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(R.id.btnFinish) + downloadBtn.text = "🚀 Launch EverShelf (without scale)" + + findViewById(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(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = 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() - 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() diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleGatewayService.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleGatewayService.kt deleted file mode 100644 index 9721337..0000000 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleGatewayService.kt +++ /dev/null @@ -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)) - } -} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleProtocol.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleProtocol.kt deleted file mode 100644 index 3052247..0000000 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleProtocol.kt +++ /dev/null @@ -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) -} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SettingsActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SettingsActivity.kt index 9ca0f4f..7d793ab 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SettingsActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SettingsActivity.kt @@ -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(R.id.scaleDeviceInfo).text = - if (scaleDevice != null) "Last connected: $scaleDevice" else "No scale connected yet" - - // Back button - findViewById(R.id.btnBack).setOnClickListener { - finish() + // Gateway status + val gatewayInstalled = try { + packageManager.getPackageInfo(GATEWAY_PACKAGE, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false } + val statusView = findViewById(R.id.scaleGatewayStatus) + val deviceView = findViewById(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(R.id.btnBack).setOnClickListener { finish() } // Test connection - findViewById(R.id.btnTestConnection).setOnClickListener { - testConnection() - } + findViewById(R.id.btnTestConnection).setOnClickListener { testConnection() } // Run wizard again findViewById(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(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = 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) {