chore: remove deprecated scale-gateway app
The BLE scale gateway is fully integrated into the EverShelf Kiosk app since v1.6.0. This standalone Android app is no longer needed or maintained. Removal also resolves GitHub secret scanning alert #1 (legacy plain-text GitHub PAT in ErrorReporter.kt — already revoked by GitHub automatically).
@@ -1,7 +0,0 @@
|
|||||||
.gradle/
|
|
||||||
build/
|
|
||||||
local.properties
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
*.class
|
|
||||||
*.dex
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# ~~EverShelf Scale Gateway~~ — DEPRECATED
|
|
||||||
|
|
||||||
> ⚠️ **This app is deprecated and no longer maintained.**
|
|
||||||
>
|
|
||||||
> As of **EverShelf Kiosk v1.6.0**, BLE scale support is fully integrated into the kiosk app itself. You no longer need to install or configure this separate gateway app.
|
|
||||||
>
|
|
||||||
> **If you are using the EverShelf Kiosk app** → the scale gateway runs automatically as a background service. Configure your Bluetooth scale in **step 4 of the setup wizard**.
|
|
||||||
>
|
|
||||||
> **If you are NOT using the kiosk app** (standalone Android tablet) → you may still use this APK, but no new releases will be published.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# EverShelf Scale Gateway (legacy)
|
|
||||||
|
|
||||||
> Android gateway app that bridges Bluetooth LE smart scales with EverShelf via WebSocket.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
```
|
|
||||||
Smart Scale ──(BLE)──► Android Gateway App ──(WebSocket/LAN)──► EverShelf Server ──(SSE)──► Browser
|
|
||||||
```
|
|
||||||
|
|
||||||
The app runs a local WebSocket server (port **8765**) on your Android device. The EverShelf server connects to it via a server-side relay (`api/scale_relay.php` SSE + `api/scale_ping.php` WebSocket client), avoiding mixed-content (HTTPS→WS) issues. Weight readings are streamed to the browser in real time.
|
|
||||||
|
|
||||||
> **Kiosk integration (v1.6.0+):** The gateway is now **built into the EverShelf Kiosk app** as a foreground service. This separate app is not needed when using the kiosk.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Supported scale protocols
|
|
||||||
|
|
||||||
| Protocol | Service UUID | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible; works with most smart scales |
|
|
||||||
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Reports weight + body fat %, BMI |
|
|
||||||
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
|
|
||||||
|
|
||||||
### Verified compatible scales (community list)
|
|
||||||
- Xiaomi Mi Body Composition Scale 2
|
|
||||||
- Renpho Smart Body Fat Scale
|
|
||||||
- INEVIFIT Smart Body Fat Scale
|
|
||||||
- Any OpenScale-compatible scale (see [openScale supported devices](https://github.com/oliexdev/openScale/wiki/Supported-scales))
|
|
||||||
|
|
||||||
> **Your scale (B09MRXVBV6):** If it implements the standard BLE Weight Scale or Body Composition profile (very likely for modern Amazon smart scales), the gateway will connect automatically. If not, check the [openScale wiki](https://github.com/oliexdev/openScale/wiki/Supported-scales) and open an issue.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Download
|
|
||||||
|
|
||||||
Download the latest APK directly: **[evershelf-scale-gateway.apk](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Android **7.0** (API 24) or later
|
|
||||||
- Bluetooth LE (BLE) support
|
|
||||||
- Both the Android device and the device running EverShelf must be on the **same Wi-Fi network**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup (step by step)
|
|
||||||
|
|
||||||
### 1. Install the APK
|
|
||||||
Download and install the APK from the Releases page. You may need to allow "Install from unknown sources" in Android settings.
|
|
||||||
|
|
||||||
### 2. Launch the app
|
|
||||||
The app starts the WebSocket gateway server immediately. You will see the **gateway URL** (e.g. `ws://192.168.1.100:8765`) at the top.
|
|
||||||
|
|
||||||
### 3. Connect your scale
|
|
||||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is turned on. Tap it in the list to connect.
|
|
||||||
|
|
||||||
### 4. Configure EverShelf
|
|
||||||
In EverShelf → ⚙️ Settings → **⚖️ Bilancia Smart**:
|
|
||||||
1. Enable the toggle
|
|
||||||
2. Paste the gateway URL shown in the Android app
|
|
||||||
3. Tap **"Testa connessione"** — you should see ✅
|
|
||||||
|
|
||||||
### 5. Use it
|
|
||||||
When adding or consuming a product with unit **g** or **ml**, a **"⚖️ Leggi dalla bilancia"** button appears. Tap it, place the product on the scale, and the weight is filled in automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## WebSocket protocol reference
|
|
||||||
|
|
||||||
All messages are JSON. The server sends these to connected clients:
|
|
||||||
|
|
||||||
```json
|
|
||||||
// Scale status update
|
|
||||||
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
|
|
||||||
{"type":"status","state":"disconnected"}
|
|
||||||
|
|
||||||
// Weight reading (broadcast continuously while scale is active)
|
|
||||||
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
|
|
||||||
|
|
||||||
// Response to ping
|
|
||||||
{"type":"pong"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Clients can send:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"type":"get_status"} // Request current status
|
|
||||||
{"type":"get_weight"} // Request next stable weight reading
|
|
||||||
{"type":"ping"} // Keep-alive
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build from source
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- Android Studio Hedgehog (2023.1) or later
|
|
||||||
- Java 8+
|
|
||||||
|
|
||||||
### Steps
|
|
||||||
```bash
|
|
||||||
# 1. Clone the repo
|
|
||||||
git clone https://github.com/dadaloop82/EverShelf.git
|
|
||||||
cd EverShelf/evershelf-scale-gateway
|
|
||||||
|
|
||||||
# 2. Download the Gradle wrapper (if not included)
|
|
||||||
gradle wrapper --gradle-version 8.4
|
|
||||||
|
|
||||||
# 3. Build debug APK
|
|
||||||
./gradlew assembleDebug
|
|
||||||
|
|
||||||
# APK is at: app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```
|
|
||||||
evershelf-scale-gateway/
|
|
||||||
├── app/src/main/
|
|
||||||
│ ├── kotlin/it/dadaloop/evershelf/scalegate/
|
|
||||||
│ │ ├── MainActivity.kt — UI, orchestration
|
|
||||||
│ │ ├── BleScaleManager.kt — BLE scanning & GATT connection
|
|
||||||
│ │ ├── ScaleProtocol.kt — Parsing for all supported protocols
|
|
||||||
│ │ └── GatewayWebSocketServer.kt — WebSocket server (Java-WebSocket)
|
|
||||||
│ ├── res/layout/
|
|
||||||
│ │ ├── activity_main.xml
|
|
||||||
│ │ └── item_device.xml
|
|
||||||
│ └── AndroidManifest.xml
|
|
||||||
├── build.gradle.kts
|
|
||||||
└── settings.gradle.kts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT — see [LICENSE](../LICENSE)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "it.dadaloop.evershelf.scalegate"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "it.dadaloop.evershelf.scalegate"
|
|
||||||
minSdk = 24
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 8
|
|
||||||
versionName = "2.1.1"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding = true
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
|
||||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
|
||||||
implementation("com.google.android.material:material:1.11.0")
|
|
||||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
|
||||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
|
||||||
// WebSocket server
|
|
||||||
implementation("org.java-websocket:Java-WebSocket:1.5.5")
|
|
||||||
// Coroutines
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<?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 (for WebSocket server) -->
|
|
||||||
<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 while gateway is active -->
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
|
|
||||||
<!-- Self-update: install APK downloaded at runtime -->
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- FileProvider for serving the downloaded APK to the installer -->
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.provider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
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_gateway"
|
|
||||||
private const val PREF_LAST_DEVICE = "last_device_address"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a discovered BLE device during scan.
|
|
||||||
*/
|
|
||||||
data class BleDeviceInfo(
|
|
||||||
val device: BluetoothDevice,
|
|
||||||
val name: String,
|
|
||||||
val rssi: Int,
|
|
||||||
val proximity: String,
|
|
||||||
val scaleScore: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback interface for BLE events dispatched back to the UI.
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages BLE scanning and connection to a smart scale.
|
|
||||||
* All listener callbacks are dispatched on the main thread.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
|
|
||||||
// The characteristics we will subscribe to (multiple may exist).
|
|
||||||
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
|
|
||||||
|
|
||||||
// ─── Public state ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
|
|
||||||
|
|
||||||
// ─── Saved device (auto-reconnect) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Permissions helper ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Scanning ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun startScan() {
|
|
||||||
val adapter = bluetoothAdapter ?: run {
|
|
||||||
listener.onError("Bluetooth not available on this device.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!adapter.isEnabled) {
|
|
||||||
listener.onError("Bluetooth is off. Enable it and try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isScanning) stopScan()
|
|
||||||
|
|
||||||
leScanner = adapter.bluetoothLeScanner
|
|
||||||
val settings = ScanSettings.Builder()
|
|
||||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// No service UUID filters — many consumer scales use proprietary UUIDs
|
|
||||||
// and would be invisible with strict filtering. We show all named BLE devices.
|
|
||||||
isScanning = true
|
|
||||||
try {
|
|
||||||
leScanner?.startScan(null, settings, scanCallback)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
leScanner?.startScan(scanCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-stop after SCAN_PERIOD_MS
|
|
||||||
mainHandler.postDelayed({
|
|
||||||
stopScan()
|
|
||||||
listener.onScanStopped()
|
|
||||||
}, SCAN_PERIOD_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopScan() {
|
|
||||||
if (!isScanning) return
|
|
||||||
isScanning = false
|
|
||||||
try {
|
|
||||||
leScanner?.stopScan(scanCallback)
|
|
||||||
} catch (e: Exception) { /* ignore */ }
|
|
||||||
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) }
|
|
||||||
|
|
||||||
// Auto-connect to saved device
|
|
||||||
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
|
|
||||||
autoConnectAddress = null // prevent re-trigger
|
|
||||||
mainHandler.post {
|
|
||||||
listener.onDebugEvent("\uD83D\uDD04 Auto-connecting to $name (${device.address})")
|
|
||||||
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 (e: 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()
|
|
||||||
// Kitchen / food scale brand and model keywords
|
|
||||||
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
|
|
||||||
|
|
||||||
// Negative: body/fitness scale keywords (demote but don't hide)
|
|
||||||
val bodyKeywords = listOf(
|
|
||||||
"body", "fat", "bmi", "composition", "fitness",
|
|
||||||
"mi body", "lepulse", "qardio", "garmin", "withings",
|
|
||||||
)
|
|
||||||
if (bodyKeywords.any { lower.contains(it) }) score -= 5
|
|
||||||
|
|
||||||
// Service UUID scoring
|
|
||||||
scanRecord?.serviceUuids?.let { uuids ->
|
|
||||||
val us = uuids.map { it.uuid.toString().lowercase() }
|
|
||||||
// SIG Weight Scale service
|
|
||||||
if (us.any { it.startsWith("0000181d") }) score += 15
|
|
||||||
// Common vendor services on kitchen scales
|
|
||||||
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
|
|
||||||
// Acaia coffee scale
|
|
||||||
if (us.any { it.startsWith("49535343") }) score += 20
|
|
||||||
// Body Composition service = body scale, demote
|
|
||||||
if (us.any { it.startsWith("0000181b") }) score -= 10
|
|
||||||
}
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Connection ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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 (e: Exception) { /* ignore */ }
|
|
||||||
gatt = null
|
|
||||||
connectedDeviceName = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GATT callbacks ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val gattCallback = object : BluetoothGattCallback() {
|
|
||||||
|
|
||||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
|
||||||
when (newState) {
|
|
||||||
BluetoothProfile.STATE_CONNECTED -> {
|
|
||||||
Log.d(TAG, "Connected — discovering services…")
|
|
||||||
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
|
|
||||||
}
|
|
||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
|
||||||
Log.d(TAG, "Disconnected (status=$status)")
|
|
||||||
this@BleScaleManager.gatt?.close()
|
|
||||||
this@BleScaleManager.gatt = null
|
|
||||||
connectedDeviceName = ""
|
|
||||||
mainHandler.post { listener.onDisconnected() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
||||||
mainHandler.post { listener.onError("Servizi GATT non trovati (status=$status)") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
|
|
||||||
|
|
||||||
// Priority 1: BLE SIG Weight Scale Service
|
|
||||||
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
|
|
||||||
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
|
|
||||||
?.let { targetChars.add(it) }
|
|
||||||
|
|
||||||
// Priority 2: Common vendor service FFE0 (arboleaf, generic kitchen scales)
|
|
||||||
gatt.getService(BleUuids.FFE0)?.let { svc ->
|
|
||||||
svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: Common vendor service FFF0
|
|
||||||
gatt.getService(BleUuids.FFF0)?.let { svc ->
|
|
||||||
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
|
|
||||||
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 4: Acaia coffee scale
|
|
||||||
gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc ->
|
|
||||||
svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: any notifiable characteristic from remaining services
|
|
||||||
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. Make sure it's a BLE kitchen scale.") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Battery (optional)
|
|
||||||
gatt.getService(BleUuids.BATTERY_SERVICE)
|
|
||||||
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
|
|
||||||
?.let { targetChars.add(it) }
|
|
||||||
|
|
||||||
// Debug: log all discovered services and characteristics
|
|
||||||
val dbg = buildString {
|
|
||||||
append("GATT services (${gatt.services.size}):\n")
|
|
||||||
for (svc in gatt.services) {
|
|
||||||
append(" SVC: ${svc.uuid}\n")
|
|
||||||
for (ch in svc.characteristics) {
|
|
||||||
val p = ch.properties
|
|
||||||
val flags = buildString {
|
|
||||||
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) append("N")
|
|
||||||
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) append("I")
|
|
||||||
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) append("R")
|
|
||||||
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) append("W")
|
|
||||||
}
|
|
||||||
append(" CHAR: ${ch.uuid} [$flags]\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
append("Subscribed to ${targetChars.size} characteristics")
|
|
||||||
}
|
|
||||||
mainHandler.post { listener.onDebugEvent(dbg) }
|
|
||||||
|
|
||||||
// Save device for auto-reconnect
|
|
||||||
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
|
|
||||||
|
|
||||||
pendingSubscriptions.clear()
|
|
||||||
pendingSubscriptions.addAll(targetChars)
|
|
||||||
|
|
||||||
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
|
|
||||||
connectedDeviceName = deviceName
|
|
||||||
mainHandler.post { listener.onConnected(deviceName) }
|
|
||||||
|
|
||||||
// Subscribe one at a time (Android BLE requires sequential descriptor writes)
|
|
||||||
subscribeNext(gatt)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
|
||||||
// Subscribe to the next characteristic
|
|
||||||
subscribeNext(gatt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
override fun onCharacteristicChanged(
|
|
||||||
gatt: BluetoothGatt,
|
|
||||||
characteristic: BluetoothGattCharacteristic,
|
|
||||||
) {
|
|
||||||
val data = characteristic.value ?: return
|
|
||||||
processCharacteristicData(characteristic, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android 13+ override
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun subscribeNext(gatt: BluetoothGatt) {
|
|
||||||
val char = pendingSubscriptions.removeFirstOrNull() ?: return
|
|
||||||
|
|
||||||
// Battery characteristic — read once instead of notify
|
|
||||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
|
|
||||||
try { gatt.readCharacteristic(char) } catch (e: SecurityException) { /* ignore */ }
|
|
||||||
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 {
|
|
||||||
// No CCCD — skip and try next
|
|
||||||
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) {
|
|
||||||
// Battery level
|
|
||||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
|
|
||||||
val level = data[0].toInt() and 0xFF
|
|
||||||
mainHandler.post { listener.onBatteryReceived(level) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: log raw bytes received
|
|
||||||
val hex = data.joinToString(" ") { "%02X".format(it) }
|
|
||||||
mainHandler.post { listener.onDebugEvent("📡 ${char.uuid}\n HEX [${data.size}B]: $hex") }
|
|
||||||
|
|
||||||
// Parse weight data
|
|
||||||
val reading = ScaleProtocol.parse(char, data) { msg ->
|
|
||||||
mainHandler.post { listener.onDebugEvent(msg) }
|
|
||||||
}
|
|
||||||
if (reading != null && reading.value > 0f) {
|
|
||||||
mainHandler.post { listener.onWeightReceived(reading) }
|
|
||||||
} else {
|
|
||||||
val rawDump = data.mapIndexed { i, b ->
|
|
||||||
val v = b.toInt() and 0xFF
|
|
||||||
val h = "%02X".format(v)
|
|
||||||
"[$i]=$v(0x$h)"
|
|
||||||
}.joinToString(" ")
|
|
||||||
mainHandler.post { listener.onDebugEvent("\u26a0\ufe0f Weight not decoded\n RAW: $rawDump") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.io.OutputStreamWriter
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Centralized error reporter for EverShelf Scale Gateway.
|
|
||||||
*
|
|
||||||
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
|
|
||||||
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
|
|
||||||
* calls the GitHub Issues REST API directly.
|
|
||||||
*
|
|
||||||
* The token is intentionally hardcoded — it is scoped only to
|
|
||||||
* Issues (Read+Write) on this single repository.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ErrorReporter.init(applicationContext)
|
|
||||||
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
|
|
||||||
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
|
|
||||||
*/
|
|
||||||
object ErrorReporter {
|
|
||||||
|
|
||||||
private const val TAG = "ScaleGWErrorReporter"
|
|
||||||
|
|
||||||
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
|
|
||||||
// Stored encoded so the literal token string never appears in source or git history.
|
|
||||||
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
|
|
||||||
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
|
|
||||||
private const val GH_REPO = "dadaloop82/EverShelf"
|
|
||||||
|
|
||||||
private var _ghTokenCache: String? = null
|
|
||||||
private fun ghToken(): String {
|
|
||||||
_ghTokenCache?.let { return it }
|
|
||||||
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
||||||
val key = GH_TOKEN_KEY
|
|
||||||
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
|
|
||||||
_ghTokenCache = out
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// SharedPreferences key for pending (unsent) crash reports
|
|
||||||
private const val PREFS_NAME = "evershelf_scalegw_errors"
|
|
||||||
private const val KEY_PENDING = "pending_crash_json"
|
|
||||||
|
|
||||||
private val executor = Executors.newSingleThreadExecutor()
|
|
||||||
private val sentFingerprints = mutableSetOf<String>()
|
|
||||||
|
|
||||||
private var appVersion: String = "unknown"
|
|
||||||
private var deviceInfo: String = ""
|
|
||||||
private lateinit var appContext: Context
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call once in MainActivity.onCreate() or Application.onCreate().
|
|
||||||
*/
|
|
||||||
fun init(context: Context) {
|
|
||||||
appContext = context.applicationContext
|
|
||||||
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
|
|
||||||
try {
|
|
||||||
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
||||||
appVersion = pi.versionName ?: "unknown"
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
|
|
||||||
// Send any crash report that was saved from the previous session
|
|
||||||
sendPendingCrash()
|
|
||||||
|
|
||||||
// Install global UncaughtExceptionHandler
|
|
||||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
||||||
try {
|
|
||||||
val crash = buildPayload(
|
|
||||||
type = "uncaught-exception",
|
|
||||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
|
||||||
stack = throwable.stackTraceToString(),
|
|
||||||
context = mapOf("thread" to thread.name)
|
|
||||||
)
|
|
||||||
// Save to prefs first (in case network POST fails before process dies)
|
|
||||||
savePendingCrash(crash)
|
|
||||||
// Try immediate send (synchronous — we're already off main thread in the handler)
|
|
||||||
postToGitHub(crash)
|
|
||||||
clearPendingCrash()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
previous?.uncaughtException(thread, throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Report a caught [Throwable] asynchronously. */
|
|
||||||
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
|
|
||||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
|
||||||
if (location.isNotEmpty()) ctx["location"] = location
|
|
||||||
ctx.putAll(extra)
|
|
||||||
enqueue(
|
|
||||||
type = "scale-exception",
|
|
||||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
|
||||||
stack = throwable.stackTraceToString(),
|
|
||||||
context = ctx
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
|
|
||||||
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
|
|
||||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
|
||||||
ctx.putAll(extra)
|
|
||||||
enqueue(type = type, message = message, stack = "", context = ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun fingerprint(type: String, message: String) =
|
|
||||||
"${type}:${message.take(120)}".hashCode().toString(16)
|
|
||||||
|
|
||||||
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
|
||||||
val fp = fingerprint(type, message)
|
|
||||||
synchronized(sentFingerprints) {
|
|
||||||
if (!sentFingerprints.add(fp)) return
|
|
||||||
}
|
|
||||||
val payload = buildPayload(type, message, stack, context)
|
|
||||||
executor.execute { postToGitHub(payload) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
|
|
||||||
val ctxJson = JSONObject()
|
|
||||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
|
||||||
return JSONObject().apply {
|
|
||||||
put("source", "scale")
|
|
||||||
put("type", type)
|
|
||||||
put("message", message)
|
|
||||||
put("stack", stack)
|
|
||||||
put("context", ctxJson)
|
|
||||||
put("version", appVersion)
|
|
||||||
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
|
|
||||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist crash payload to SharedPreferences so it survives a process kill. */
|
|
||||||
private fun savePendingCrash(payload: JSONObject) {
|
|
||||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.edit().putString(KEY_PENDING, payload.toString()).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearPendingCrash() {
|
|
||||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.edit().remove(KEY_PENDING).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** On startup, check if there's an unsent crash report from the previous session. */
|
|
||||||
private fun sendPendingCrash() {
|
|
||||||
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.getString(KEY_PENDING, null) ?: return
|
|
||||||
clearPendingCrash() // remove before sending to prevent re-sending on next crash
|
|
||||||
executor.execute {
|
|
||||||
try {
|
|
||||||
val payload = JSONObject(json)
|
|
||||||
// Tag it as a "survived-crash" so we know it was saved and retried
|
|
||||||
payload.put("type", "uncaught-exception-survived")
|
|
||||||
payload.put("note", "Sent on next launch after crash")
|
|
||||||
postToGitHub(payload)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
|
|
||||||
* Uses the GitHub Issues Search API to deduplicate.
|
|
||||||
*/
|
|
||||||
private fun postToGitHub(payload: JSONObject) {
|
|
||||||
val source = payload.optString("source", "scale")
|
|
||||||
val type = payload.optString("type", "error")
|
|
||||||
val message = payload.optString("message", "")
|
|
||||||
val stack = payload.optString("stack", "")
|
|
||||||
val version = payload.optString("version", "")
|
|
||||||
val ua = payload.optString("user_agent", "")
|
|
||||||
val ts = payload.optString("ts", "")
|
|
||||||
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
|
|
||||||
|
|
||||||
val fp = fingerprint(type, message)
|
|
||||||
|
|
||||||
// ── 1. Search for existing open issue ──────────────────────────────
|
|
||||||
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
|
|
||||||
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
|
|
||||||
val searchResult = ghGet(searchUrl) ?: JSONObject()
|
|
||||||
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
|
|
||||||
|
|
||||||
// ── 2. Build body ─────────────────────────────────────────────────
|
|
||||||
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
|
|
||||||
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
|
|
||||||
|
|
||||||
if (existingNumber != null) {
|
|
||||||
// Comment on existing issue
|
|
||||||
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:${fp}_"
|
|
||||||
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
|
|
||||||
} else {
|
|
||||||
// Create new issue
|
|
||||||
val shortMsg = if (message.length > 70) "${message.take(70)}…" else message
|
|
||||||
val title = "[SCALE] $shortMsg"
|
|
||||||
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`${fp}`_"
|
|
||||||
ghPost(
|
|
||||||
"https://api.github.com/repos/$GH_REPO/issues",
|
|
||||||
JSONObject()
|
|
||||||
.put("title", title)
|
|
||||||
.put("body", body)
|
|
||||||
.put("labels", JSONArray().put("auto-report").put("scale-error"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ghGet(url: String): JSONObject? = try {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
|
||||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
|
||||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
|
||||||
conn.connectTimeout = 8000
|
|
||||||
conn.readTimeout = 8000
|
|
||||||
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
|
|
||||||
conn.disconnect()
|
|
||||||
JSONObject(raw)
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
|
|
||||||
|
|
||||||
private fun ghPost(url: String, payload: JSONObject): Int = try {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.requestMethod = "POST"
|
|
||||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
|
||||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
|
||||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
|
||||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
|
||||||
conn.doOutput = true
|
|
||||||
conn.connectTimeout = 8000
|
|
||||||
conn.readTimeout = 8000
|
|
||||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
|
||||||
val code = conn.responseCode
|
|
||||||
conn.disconnect()
|
|
||||||
Log.d(TAG, "ghPost $url → HTTP $code")
|
|
||||||
code
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callbacks for the WebSocket server, dispatched on the server's internal thread.
|
|
||||||
* The caller (MainActivity) is responsible for switching to the main thread if needed.
|
|
||||||
*/
|
|
||||||
interface ServerEventListener {
|
|
||||||
fun onClientConnected(address: String)
|
|
||||||
fun onClientDisconnected(address: String)
|
|
||||||
fun onClientRequestedWeight()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket server that exposes smart-scale data to EverShelf running in a browser.
|
|
||||||
*
|
|
||||||
* Message protocol (JSON):
|
|
||||||
*
|
|
||||||
* Server -> Client:
|
|
||||||
* {"type":"status","state":"connected"|"disconnected","device":"QN-KS","battery":80}
|
|
||||||
* {"type":"weight","value":17.0,"unit":"g","stable":true,"timestamp":1712345678000}
|
|
||||||
* {"type":"pong"}
|
|
||||||
*
|
|
||||||
* Client → Server:
|
|
||||||
* {"type":"get_status"} → server responds with current status message
|
|
||||||
* {"type":"get_weight"} → server will push the next stable weight reading
|
|
||||||
* {"type":"ping"} → server responds with {"type":"pong"}
|
|
||||||
*/
|
|
||||||
class GatewayWebSocketServer(
|
|
||||||
port: Int,
|
|
||||||
private val eventListener: ServerEventListener?,
|
|
||||||
) : WebSocketServer(InetSocketAddress(port)) {
|
|
||||||
|
|
||||||
// Thread-safe set of clients waiting for the next stable weight reading
|
|
||||||
private val pendingWeightRequests: MutableSet<WebSocket> =
|
|
||||||
Collections.synchronizedSet(mutableSetOf())
|
|
||||||
|
|
||||||
// Last known scale state (to send to new clients immediately)
|
|
||||||
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
|
|
||||||
@Volatile private var lastWeightJson: String? = null
|
|
||||||
|
|
||||||
// ─── Server lifecycle ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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() ?: "?"
|
|
||||||
Log.d(TAG, "Client connected: $addr")
|
|
||||||
|
|
||||||
// Immediately send current status so the web app knows the scale state
|
|
||||||
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() ?: "?"
|
|
||||||
Log.d(TAG, "Client disconnected: $addr (code=$code)")
|
|
||||||
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" -> {
|
|
||||||
// Add to pending set; next stable weight will be sent to this client
|
|
||||||
pendingWeightRequests.add(conn)
|
|
||||||
eventListener?.onClientRequestedWeight()
|
|
||||||
// If we already have a recent weight, send it immediately
|
|
||||||
lastWeightJson?.let { conn.send(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Malformed message: $message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(conn: WebSocket?, ex: Exception) {
|
|
||||||
Log.e(TAG, "WebSocket error on ${conn?.remoteSocketAddress}", ex)
|
|
||||||
ErrorReporter.report(ex, "GatewayWebSocketServer.onError",
|
|
||||||
mapOf("remote_addr" to (conn?.remoteSocketAddress?.toString() ?: "null")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Publishing API ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast scale connection status to all connected WebSocket clients.
|
|
||||||
*/
|
|
||||||
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
|
|
||||||
lastStatusJson = buildStatusJson(state, deviceName, battery)
|
|
||||||
broadcast(lastStatusJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast a weight reading to all clients.
|
|
||||||
* If [stable] is true, also fulfil pending on-demand weight requests.
|
|
||||||
*/
|
|
||||||
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) {
|
|
||||||
// Clients that requested on-demand readings are already served by broadcast;
|
|
||||||
// just clear the pending set.
|
|
||||||
pendingWeightRequests.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── JSON builders ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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")
|
|
||||||
// Round to 1 decimal to avoid floating point noise (e.g. 17.000001)
|
|
||||||
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,674 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.bluetooth.BluetoothAdapter
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.pm.PackageInstaller
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
|
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.NetworkInterface
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
private const val WS_PORT = 8765
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener {
|
|
||||||
|
|
||||||
private lateinit var binding: ActivityMainBinding
|
|
||||||
private lateinit var bleManager: BleScaleManager
|
|
||||||
private var wsServer: GatewayWebSocketServer? = null
|
|
||||||
|
|
||||||
private val devices = mutableListOf<BleDeviceInfo>()
|
|
||||||
private lateinit var deviceAdapter: DeviceAdapter
|
|
||||||
|
|
||||||
private var batteryLevel: Int? = null
|
|
||||||
private val debugLines = mutableListOf<String>()
|
|
||||||
private var debugVisible = false
|
|
||||||
private var lastDebugUpdate = 0L
|
|
||||||
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
|
||||||
private var isAutoReconnecting = false
|
|
||||||
// Update banner
|
|
||||||
private var pendingApkDownloadUrl = ""
|
|
||||||
private var pendingInstallFile: java.io.File? = null
|
|
||||||
private companion object {
|
|
||||||
const val MAX_DEBUG_LINES = 150
|
|
||||||
const val DEBUG_THROTTLE_MS = 200L
|
|
||||||
const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
|
|
||||||
const val APK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Permission launcher ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val permissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
|
||||||
) { granted ->
|
|
||||||
if (granted.values.all { it }) {
|
|
||||||
startGatewayServer()
|
|
||||||
} else {
|
|
||||||
showDialog("Missing permissions",
|
|
||||||
"The app requires Bluetooth and Location permissions to function.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val enableBtLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
|
|
||||||
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download. */
|
|
||||||
private val installPermLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { _ ->
|
|
||||||
val url = pendingApkDownloadUrl
|
|
||||||
if (url.isNotEmpty()) triggerApkDownload(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns from system installer dialog — if not OK the install failed (signature conflict?). */
|
|
||||||
private val installConfirmLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode != RESULT_OK) {
|
|
||||||
val f = pendingInstallFile
|
|
||||||
if (f != null && f.exists()) {
|
|
||||||
runOnUiThread {
|
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle("⚠️ Installazione non riuscita")
|
|
||||||
.setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
|
|
||||||
.setPositiveButton("Disinstalla") { _, _ ->
|
|
||||||
uninstallLauncher.launch(
|
|
||||||
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.setNegativeButton("Annulla", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns from uninstall screen — auto-retry the install with the saved APK file. */
|
|
||||||
private val uninstallLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { _ ->
|
|
||||||
val f = pendingInstallFile
|
|
||||||
if (f != null && f.exists()) installApk(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
bleManager = BleScaleManager(this, this)
|
|
||||||
|
|
||||||
// Initialise error reporter early so the UncaughtExceptionHandler is installed
|
|
||||||
// and any pending crash from a previous session is sent
|
|
||||||
ErrorReporter.init(this)
|
|
||||||
|
|
||||||
deviceAdapter = DeviceAdapter(devices) { info ->
|
|
||||||
bleManager.connect(info.device)
|
|
||||||
}
|
|
||||||
binding.rvDevices.apply {
|
|
||||||
layoutManager = LinearLayoutManager(this@MainActivity)
|
|
||||||
adapter = deviceAdapter
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.btnScan.setOnClickListener { startScanIfPermitted() }
|
|
||||||
binding.btnDisconnect.setOnClickListener {
|
|
||||||
bleManager.disconnect()
|
|
||||||
updateUiDisconnected()
|
|
||||||
}
|
|
||||||
binding.btnDebug.setOnClickListener {
|
|
||||||
debugVisible = !debugVisible
|
|
||||||
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
|
||||||
binding.btnCopyLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
|
||||||
binding.btnShareLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
|
||||||
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Hide Debug" else "\uD83D\uDC1B Debug"
|
|
||||||
}
|
|
||||||
binding.btnCopyLog.setOnClickListener {
|
|
||||||
val log = debugLines.joinToString("\n")
|
|
||||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
|
||||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Scale Log", log))
|
|
||||||
Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
binding.btnShareLog.setOnClickListener {
|
|
||||||
val log = debugLines.joinToString("\n")
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "text/plain"
|
|
||||||
putExtra(Intent.EXTRA_SUBJECT, "EverShelf Scale Gateway - Debug Log")
|
|
||||||
putExtra(Intent.EXTRA_TEXT, log)
|
|
||||||
}
|
|
||||||
startActivity(Intent.createChooser(intent, "Share log"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show app version
|
|
||||||
try {
|
|
||||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
|
||||||
binding.tvVersion.text = "v${pInfo.versionName} (${pInfo.longVersionCode})"
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
|
|
||||||
updateGatewayUrl()
|
|
||||||
checkPermissionsAndStart()
|
|
||||||
|
|
||||||
// Wire update banner buttons
|
|
||||||
binding.btnDismissUpdate.setOnClickListener { binding.updateBanner.visibility = View.GONE }
|
|
||||||
binding.btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
|
|
||||||
|
|
||||||
// Check for a newer release (background thread, at most once every 6 h)
|
|
||||||
checkForUpdates()
|
|
||||||
|
|
||||||
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled
|
|
||||||
if (bleManager.getSavedDeviceAddress() != null) {
|
|
||||||
binding.tvScanHint.visibility = View.VISIBLE
|
|
||||||
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale\u2026"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
bleManager.disconnect()
|
|
||||||
wsServer?.stop(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Permissions & startup ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun checkPermissionsAndStart() {
|
|
||||||
val required = buildList {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
add(Manifest.permission.BLUETOOTH_SCAN)
|
|
||||||
add(Manifest.permission.BLUETOOTH_CONNECT)
|
|
||||||
} else {
|
|
||||||
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val missing = required.filter {
|
|
||||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
missing.isNotEmpty() -> permissionLauncher.launch(missing.toTypedArray())
|
|
||||||
!isBluetoothEnabled() -> enableBtLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
|
|
||||||
else -> startGatewayServer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isBluetoothEnabled(): Boolean {
|
|
||||||
val adapter = android.bluetooth.BluetoothManager::class.java.let {
|
|
||||||
getSystemService(it)
|
|
||||||
} as? android.bluetooth.BluetoothManager
|
|
||||||
return adapter?.adapter?.isEnabled == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startScanIfPermitted() {
|
|
||||||
if (!bleManager.hasRequiredPermissions()) {
|
|
||||||
checkPermissionsAndStart()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
devices.clear()
|
|
||||||
deviceAdapter.notifyDataSetChanged()
|
|
||||||
debugLines.clear()
|
|
||||||
binding.tvDebugLog.text = ""
|
|
||||||
binding.tvScanHint.visibility = View.VISIBLE
|
|
||||||
binding.tvScanHint.text = "Scanning for BLE scales\u2026"
|
|
||||||
binding.btnScan.isEnabled = false
|
|
||||||
bleManager.enableAutoConnect()
|
|
||||||
isAutoReconnecting = false // manual scan — stop any pending auto-reconnect cycle
|
|
||||||
bleManager.startScan()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── WebSocket gateway ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun startGatewayServer() {
|
|
||||||
if (wsServer != null) return
|
|
||||||
try {
|
|
||||||
wsServer = GatewayWebSocketServer(WS_PORT, this)
|
|
||||||
wsServer!!.start()
|
|
||||||
updateGatewayUrl()
|
|
||||||
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
|
|
||||||
ErrorReporter.report(e, "startGatewayServer", mapOf("port" to WS_PORT))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-scan if there's a saved device
|
|
||||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
|
||||||
bleManager.enableAutoConnect()
|
|
||||||
bleManager.startScan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateGatewayUrl() {
|
|
||||||
val ip = getLocalIpAddress() ?: "—"
|
|
||||||
val url = "ws://$ip:$WS_PORT"
|
|
||||||
binding.tvGatewayUrl.text = url
|
|
||||||
binding.tvGatewayUrlHint.text = "Paste this URL in EverShelf \u2192 Settings \u2192 Smart Scale"
|
|
||||||
binding.btnCopyUrl.setOnClickListener {
|
|
||||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
|
||||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
|
|
||||||
binding.btnCopyUrl.text = "\u2705 Copied!"
|
|
||||||
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── BleScaleListener ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override fun onDeviceFound(info: BleDeviceInfo) {
|
|
||||||
if (devices.none { it.device.address == info.device.address }) {
|
|
||||||
// Insert keeping descending scaleScore order (scale-likely devices first)
|
|
||||||
val insertAt = devices.indexOfFirst { it.scaleScore < info.scaleScore }
|
|
||||||
.let { if (it < 0) devices.size else it }
|
|
||||||
devices.add(insertAt, info)
|
|
||||||
deviceAdapter.notifyItemInserted(insertAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConnecting(device: BluetoothDevice) {
|
|
||||||
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
|
|
||||||
binding.tvScaleStatus.text = "\u23f3 Connecting to $name\u2026"
|
|
||||||
binding.tvWeight.text = "— — —"
|
|
||||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConnected(deviceName: String) {
|
|
||||||
isAutoReconnecting = false
|
|
||||||
binding.tvScaleStatus.text = "\u2705 Connected: $deviceName"
|
|
||||||
binding.tvWeight.text = "Waiting for weight\u2026"
|
|
||||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_green_light))
|
|
||||||
binding.btnDisconnect.visibility = View.VISIBLE
|
|
||||||
binding.rvDevices.visibility = View.GONE
|
|
||||||
binding.btnScan.visibility = View.GONE
|
|
||||||
binding.tvScanHint.visibility = View.GONE
|
|
||||||
wsServer?.publishStatus("connected", deviceName, batteryLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDisconnected() {
|
|
||||||
wsServer?.publishStatus("disconnected", null, null)
|
|
||||||
updateUiDisconnected()
|
|
||||||
// Auto-reconnect: if a saved device exists, restart scan after a short delay.
|
|
||||||
// This handles the scale turning off by itself (auto-off) — when it powers
|
|
||||||
// back on it will start advertising again and we will pick it up.
|
|
||||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
|
||||||
isAutoReconnecting = true
|
|
||||||
binding.tvScanHint.visibility = View.VISIBLE
|
|
||||||
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale in 5 s\u2026"
|
|
||||||
binding.root.postDelayed({
|
|
||||||
if (!bleManager.isConnected && isAutoReconnecting) {
|
|
||||||
bleManager.enableAutoConnect()
|
|
||||||
bleManager.startScan()
|
|
||||||
}
|
|
||||||
}, 5_000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWeightReceived(reading: WeightReading) {
|
|
||||||
val displayValue = if (reading.value % 1f == 0f) reading.value.toInt().toString()
|
|
||||||
else "%.1f".format(reading.value)
|
|
||||||
binding.tvWeight.text = "$displayValue ${reading.unit}"
|
|
||||||
|
|
||||||
if (reading.stable) {
|
|
||||||
binding.tvWeightHint.text = "\u2713 Stable reading"
|
|
||||||
} else {
|
|
||||||
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
|
|
||||||
}
|
|
||||||
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBatteryReceived(level: Int) {
|
|
||||||
batteryLevel = level
|
|
||||||
binding.tvBattery.text = "🔋 $level%"
|
|
||||||
binding.tvBattery.visibility = View.VISIBLE
|
|
||||||
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
|
|
||||||
.removePrefix("\u2705 Connected: "), level)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(message: String) {
|
|
||||||
binding.tvScaleStatus.text = "❌ $message"
|
|
||||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
|
|
||||||
ErrorReporter.reportMessage(
|
|
||||||
type = "ble-error",
|
|
||||||
message = message,
|
|
||||||
extra = mapOf("connected_device" to (bleManager.getSavedDeviceAddress() ?: "none"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onScanStopped() {
|
|
||||||
binding.btnScan.isEnabled = true
|
|
||||||
if (isAutoReconnecting && !bleManager.isConnected && bleManager.getSavedDeviceAddress() != null) {
|
|
||||||
// Scale not found yet — retry scan after 10 s indefinitely until reconnected
|
|
||||||
binding.tvScanHint.visibility = View.VISIBLE
|
|
||||||
binding.tvScanHint.text = "\uD83D\uDD04 Bilancia non trovata, riprovo tra 10 s\u2026"
|
|
||||||
binding.root.postDelayed({
|
|
||||||
if (!bleManager.isConnected && isAutoReconnecting) {
|
|
||||||
binding.tvScanHint.text = "\uD83D\uDD04 Cerco la bilancia\u2026"
|
|
||||||
bleManager.enableAutoConnect()
|
|
||||||
bleManager.startScan()
|
|
||||||
}
|
|
||||||
}, 10_000L)
|
|
||||||
} else if (devices.isEmpty()) {
|
|
||||||
binding.tvScanHint.text = "No scale found. Make sure it's on, then scan again."
|
|
||||||
} else {
|
|
||||||
binding.tvScanHint.text = "Tap a scale to connect."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDebugEvent(message: String) {
|
|
||||||
runOnUiThread {
|
|
||||||
val ts = debugTimeFmt.format(Date())
|
|
||||||
debugLines.add("[$ts] $message")
|
|
||||||
// Keep only last MAX_DEBUG_LINES
|
|
||||||
while (debugLines.size > MAX_DEBUG_LINES) debugLines.removeAt(0)
|
|
||||||
// Throttle UI updates to avoid freezing
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (now - lastDebugUpdate >= DEBUG_THROTTLE_MS) {
|
|
||||||
lastDebugUpdate = now
|
|
||||||
binding.tvDebugLog.text = debugLines.joinToString("\n")
|
|
||||||
if (debugVisible) {
|
|
||||||
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ServerEventListener ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override fun onClientConnected(address: String) {
|
|
||||||
runOnUiThread {
|
|
||||||
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
|
|
||||||
binding.tvClientCount.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClientDisconnected(address: String) {
|
|
||||||
runOnUiThread {
|
|
||||||
binding.tvClientCount.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClientRequestedWeight() { /* Nothing extra needed */ }
|
|
||||||
|
|
||||||
// ─── UI helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun updateUiDisconnected() {
|
|
||||||
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
|
|
||||||
binding.tvWeight.text = "— — —"
|
|
||||||
binding.tvWeightHint.text = ""
|
|
||||||
binding.tvBattery.visibility = View.GONE
|
|
||||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.darker_gray))
|
|
||||||
binding.btnDisconnect.visibility = View.GONE
|
|
||||||
binding.rvDevices.visibility = View.VISIBLE
|
|
||||||
binding.btnScan.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLocalIpAddress(): String? {
|
|
||||||
return try {
|
|
||||||
NetworkInterface.getNetworkInterfaces().toList()
|
|
||||||
.flatMap { it.inetAddresses.toList() }
|
|
||||||
.filterIsInstance<Inet4Address>()
|
|
||||||
.firstOrNull { !it.isLoopbackAddress }
|
|
||||||
?.hostAddress
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showDialog(title: String, message: String) {
|
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle(title)
|
|
||||||
.setMessage(message)
|
|
||||||
.setPositiveButton("OK", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Update check ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun checkForUpdates() {
|
|
||||||
Thread {
|
|
||||||
try {
|
|
||||||
val conn = java.net.URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
|
||||||
conn.connectTimeout = 5000
|
|
||||||
conn.readTimeout = 5000
|
|
||||||
val body = conn.inputStream.bufferedReader().readText()
|
|
||||||
conn.disconnect()
|
|
||||||
val json = JSONObject(body)
|
|
||||||
val latestTag = json.optString("tag_name", "").ifEmpty { return@Thread }
|
|
||||||
val current = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
|
|
||||||
val norm = { v: String -> v.trimStart('v') }
|
|
||||||
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
|
|
||||||
|
|
||||||
// Find scale-gateway APK in release assets
|
|
||||||
var apkUrl = ""
|
|
||||||
val assets = json.optJSONArray("assets")
|
|
||||||
if (assets != null) {
|
|
||||||
for (i in 0 until assets.length()) {
|
|
||||||
val a = assets.getJSONObject(i)
|
|
||||||
val name = a.optString("name", "").lowercase()
|
|
||||||
val url = a.optString("browser_download_url", "")
|
|
||||||
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) {
|
|
||||||
apkUrl = url; break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Only show banner if the release actually contains our APK
|
|
||||||
if (apkUrl.isEmpty()) return@Thread
|
|
||||||
|
|
||||||
// Proper semver comparison: only update if remote is strictly newer
|
|
||||||
fun semverNewer(remote: String, local: String): Boolean {
|
|
||||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
|
||||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
|
||||||
val len = maxOf(r.size, l.size)
|
|
||||||
for (i in 0 until len) {
|
|
||||||
val rv = r.getOrElse(i) { 0 }
|
|
||||||
val lv = l.getOrElse(i) { 0 }
|
|
||||||
if (rv != lv) return rv > lv
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.isEmpty()) return@Thread
|
|
||||||
if (isSemver && !semverNewer(norm(latestTag), norm(current))) return@Thread
|
|
||||||
|
|
||||||
val label = if (isSemver) "$current → $latestTag" else latestTag
|
|
||||||
val msg = "⬆️ Scale Gateway $label"
|
|
||||||
runOnUiThread { showNativeUpdateBanner(msg, apkUrl) }
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showNativeUpdateBanner(message: String, apkUrl: String) {
|
|
||||||
pendingApkDownloadUrl = apkUrl
|
|
||||||
binding.tvUpdateMessage.text = message
|
|
||||||
binding.updateBanner.visibility = View.VISIBLE
|
|
||||||
binding.updateBanner.postDelayed({ binding.updateBanner.visibility = View.GONE }, 30_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun triggerApkDownload(apkUrl: String) {
|
|
||||||
if (apkUrl.isEmpty()) return
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
|
||||||
!packageManager.canRequestPackageInstalls()) {
|
|
||||||
pendingApkDownloadUrl = apkUrl // remember for retry
|
|
||||||
installPermLauncher.launch(
|
|
||||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
|
|
||||||
)
|
|
||||||
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Download to app-private external dir — no storage permission needed
|
|
||||||
val destDir = getExternalFilesDir(null) ?: filesDir
|
|
||||||
val destFile = java.io.File(destDir, "evershelf-scale-update.apk")
|
|
||||||
pendingInstallFile = destFile
|
|
||||||
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
|
|
||||||
setTitle("EverShelf Scale Gateway — Aggiornamento")
|
|
||||||
setDescription("Scaricamento aggiornamento…")
|
|
||||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
|
||||||
setDestinationUri(Uri.fromFile(destFile))
|
|
||||||
setMimeType("application/vnd.android.package-archive")
|
|
||||||
}
|
|
||||||
val downloadId = dm.enqueue(req)
|
|
||||||
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
|
||||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
|
||||||
if (id != downloadId) return
|
|
||||||
unregisterReceiver(this)
|
|
||||||
val q = DownloadManager.Query().setFilterById(downloadId)
|
|
||||||
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
|
||||||
var ok = false
|
|
||||||
if (c.moveToFirst()) {
|
|
||||||
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
|
||||||
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
|
|
||||||
}
|
|
||||||
c.close()
|
|
||||||
if (ok) installApk(destFile)
|
|
||||||
else runOnUiThread {
|
|
||||||
Toast.makeText(this@MainActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
// RECEIVER_EXPORTED required: ACTION_DOWNLOAD_COMPLETE is sent by the system DownloadManager
|
|
||||||
// (an external process), so NOT_EXPORTED would silently block the broadcast on API 33+.
|
|
||||||
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
|
||||||
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun installApk(file: java.io.File) {
|
|
||||||
if (!file.exists() || file.length() == 0L) {
|
|
||||||
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val pi = packageManager.packageInstaller
|
|
||||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
|
||||||
params.setAppPackageName(packageName)
|
|
||||||
val sessionId = pi.createSession(params)
|
|
||||||
pi.openSession(sessionId).use { session ->
|
|
||||||
file.inputStream().use { input ->
|
|
||||||
session.openWrite("package", 0, file.length()).use { out ->
|
|
||||||
input.copyTo(out)
|
|
||||||
session.fsync(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val action = "it.dadaloop.evershelf.scalegate.INSTALL_RESULT_$sessionId"
|
|
||||||
val resultReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
|
||||||
unregisterReceiver(this)
|
|
||||||
val status = intent?.getIntExtra(
|
|
||||||
PackageInstaller.EXTRA_STATUS,
|
|
||||||
PackageInstaller.STATUS_FAILURE
|
|
||||||
) ?: PackageInstaller.STATUS_FAILURE
|
|
||||||
when (status) {
|
|
||||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
|
||||||
// Use launcher so we get notified if system installer fails
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
|
||||||
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
|
||||||
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
|
|
||||||
if (confirmIntent != null) installConfirmLauncher.launch(confirmIntent)
|
|
||||||
}
|
|
||||||
PackageInstaller.STATUS_SUCCESS ->
|
|
||||||
runOnUiThread { Toast.makeText(this@MainActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
|
|
||||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
|
|
||||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
|
|
||||||
runOnUiThread {
|
|
||||||
AlertDialog.Builder(this@MainActivity)
|
|
||||||
.setTitle("⚠️ Conflitto firma APK")
|
|
||||||
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
|
|
||||||
.setPositiveButton("Disinstalla") { _, _ ->
|
|
||||||
uninstallLauncher.launch(
|
|
||||||
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.setNegativeButton("Annulla", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
|
||||||
?: "status=$status"
|
|
||||||
runOnUiThread { Toast.makeText(this@MainActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
|
||||||
RECEIVER_NOT_EXPORTED else 0
|
|
||||||
registerReceiver(resultReceiver, IntentFilter(action), flags)
|
|
||||||
val pi2 = PendingIntent.getBroadcast(
|
|
||||||
this, sessionId,
|
|
||||||
Intent(action).setPackage(packageName),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
session.commit(pi2.intentSender)
|
|
||||||
}
|
|
||||||
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── RecyclerView adapter ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
inner class DeviceAdapter(
|
|
||||||
private val items: List<BleDeviceInfo>,
|
|
||||||
private val onClick: (BleDeviceInfo) -> Unit,
|
|
||||||
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
|
|
||||||
|
|
||||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
|
||||||
val tvName: TextView = view.findViewById(R.id.tv_device_name)
|
|
||||||
val tvAddr: TextView = view.findViewById(R.id.tv_device_addr)
|
|
||||||
val tvRssi: TextView = view.findViewById(R.id.tv_device_rssi)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
|
||||||
val view = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.item_device, parent, false)
|
|
||||||
return VH(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
|
||||||
val info = items[position]
|
|
||||||
holder.tvName.text = info.name
|
|
||||||
holder.tvAddr.text = info.device.address
|
|
||||||
holder.tvRssi.text = info.proximity
|
|
||||||
holder.itemView.setOnClickListener { onClick(info) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() = items.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
import android.bluetooth.BluetoothGattCharacteristic
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
// --- Data model ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single weight reading from a BLE scale.
|
|
||||||
* [value] is in the scale's current display unit (grams, oz, ml, lb).
|
|
||||||
* [unit] is "g", "oz", "ml", or "lb".
|
|
||||||
*/
|
|
||||||
data class WeightReading(
|
|
||||||
val value: Float,
|
|
||||||
val unit: String,
|
|
||||||
val stable: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- UUIDs ---
|
|
||||||
|
|
||||||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
object BleUuids {
|
|
||||||
// BLE SIG Weight Scale (some kitchen scales use this)
|
|
||||||
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
|
|
||||||
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
// Battery
|
|
||||||
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
|
||||||
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
// Common vendor services used by kitchen scales
|
|
||||||
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")
|
|
||||||
|
|
||||||
// Acaia / Brewista coffee scales
|
|
||||||
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
|
|
||||||
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
|
|
||||||
|
|
||||||
// QN/Yolanda food scale secondary service (QN-KS, etc.)
|
|
||||||
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
|
|
||||||
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Food scale protocol parser ---
|
|
||||||
|
|
||||||
object ScaleProtocol {
|
|
||||||
|
|
||||||
// Plausible kitchen scale range
|
|
||||||
private const val MAX_GRAMS = 15000f
|
|
||||||
private const val MIN_GRAMS = 0.5f // allow tare/small values
|
|
||||||
|
|
||||||
fun resetState() { /* reserved for future use */ }
|
|
||||||
|
|
||||||
fun parse(
|
|
||||||
char: BluetoothGattCharacteristic,
|
|
||||||
data: ByteArray,
|
|
||||||
debug: ((String) -> Unit)? = null,
|
|
||||||
): WeightReading? {
|
|
||||||
if (data.size < 2) {
|
|
||||||
debug?.invoke("skip: packet too short (" + data.size + "B)")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// UUID-specific parsers
|
|
||||||
when (char.uuid) {
|
|
||||||
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QN/Yolanda food scale (QN-KS, BC-KS, etc.):
|
|
||||||
// 18-byte frame starting with 0x10 0x12 on FFF1
|
|
||||||
if (data.size == 18
|
|
||||||
&& (data[0].toInt() and 0xFF) == 0x10
|
|
||||||
&& (data[1].toInt() and 0xFF) == 0x12) {
|
|
||||||
return parseQNFood(data, debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseGeneric(data, debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// BLE SIG 0x2A9D Weight Measurement
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
|
||||||
if (data.size < 3) return null
|
|
||||||
val flags = data[0].toInt() and 0xFF
|
|
||||||
val isImperial = (flags and 0x01) != 0
|
|
||||||
val raw = u16le(data, 1)
|
|
||||||
|
|
||||||
return if (isImperial) {
|
|
||||||
val lb = raw * 0.01f
|
|
||||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${lb}lb")
|
|
||||||
if (lb < 0.01f || lb > 33f) null
|
|
||||||
else WeightReading(lb, "lb", stable = true)
|
|
||||||
} else {
|
|
||||||
val g = raw * 5f // 0.005 kg resolution = 5 g/unit
|
|
||||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
|
|
||||||
if (g < MIN_GRAMS || g > MAX_GRAMS) null
|
|
||||||
else WeightReading(g, "g", stable = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// QN / Yolanda food scale (QN-KS, BC-KS, YolandaKS, ...)
|
|
||||||
//
|
|
||||||
// 18-byte notification on service 0xFFF0, char 0xFFF1:
|
|
||||||
// [0x10][0x12][00][??][unit][02][05][01][flags][w_hi][w_lo][7E][1F][02][58][02][01][crc]
|
|
||||||
// index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
|
||||||
//
|
|
||||||
// weight = u16BE(data, 9) / 10.0 (0.1-unit resolution)
|
|
||||||
// unit = byte[4]: 0x01=g, 0x02=oz, 0x03=ml(water), 0x04=ml(milk)
|
|
||||||
// stable = bit3 of byte[8] != 0 (0xF8=stable, 0xF0=settling)
|
|
||||||
// crc = sum(bytes[0..16]) mod 256
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
|
||||||
// Verify checksum
|
|
||||||
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
|
|
||||||
if (calc != (data[17].toInt() and 0xFF)) {
|
|
||||||
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(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" // water mode
|
|
||||||
0x04 -> "ml" // milk mode
|
|
||||||
else -> "g"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolution is 0.1 unit (e.g. 170 raw = 17.0 g, 195 raw = 19.5 g)
|
|
||||||
val value = rawValue / 10f
|
|
||||||
|
|
||||||
debug?.invoke("QN-KS: ${value}${unit} stable=$stable (raw=$rawValue unit_byte=0x%02X)".format(data[4].toInt() and 0xFF))
|
|
||||||
|
|
||||||
if (rawValue == 0) return null
|
|
||||||
// Convert to grams for range check
|
|
||||||
val valueG = if (unit == "oz") value * 28.3495f else value
|
|
||||||
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
|
|
||||||
|
|
||||||
return WeightReading(value, unit, stable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Generic fallback parser
|
|
||||||
// Tries common frame layouts used by many BLE kitchen scales.
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
|
||||||
if (data.size < 3) {
|
|
||||||
debug?.invoke("generic: skip short packet (" + data.size + "B)")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
|
|
||||||
|
|
||||||
val candidates = listOf(
|
|
||||||
// Direct grams (1g resolution)
|
|
||||||
C(1, false, 1f, "pos1 LE g"),
|
|
||||||
C(1, true, 1f, "pos1 BE g"),
|
|
||||||
C(2, false, 1f, "pos2 LE g"),
|
|
||||||
C(2, true, 1f, "pos2 BE g"),
|
|
||||||
C(3, false, 1f, "pos3 LE g"),
|
|
||||||
C(3, true, 1f, "pos3 BE g"),
|
|
||||||
// 0.1g resolution (high-precision scales)
|
|
||||||
C(1, false, 10f, "pos1 LE 0.1g"),
|
|
||||||
C(1, true, 10f, "pos1 BE 0.1g"),
|
|
||||||
C(2, false, 10f, "pos2 LE 0.1g"),
|
|
||||||
C(2, true, 10f, "pos2 BE 0.1g"),
|
|
||||||
C(3, false, 10f, "pos3 LE 0.1g"),
|
|
||||||
C(3, true, 10f, "pos3 BE 0.1g"),
|
|
||||||
// 0.5g resolution
|
|
||||||
C(1, false, 2f, "pos1 LE 0.5g"),
|
|
||||||
C(1, true, 2f, "pos1 BE 0.5g"),
|
|
||||||
// Raw = centgrams (raw*10 = g)
|
|
||||||
C(1, false, 0.1f, "pos1 LE cg"),
|
|
||||||
C(1, true, 0.1f, "pos1 BE cg"),
|
|
||||||
C(3, false, 0.1f, "pos3 LE cg"),
|
|
||||||
C(3, true, 0.1f, "pos3 BE cg"),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (c in candidates) {
|
|
||||||
if (c.pos + 1 >= data.size) continue
|
|
||||||
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
|
|
||||||
if (raw == 0) continue
|
|
||||||
val g = raw / c.div
|
|
||||||
if (g in MIN_GRAMS..MAX_GRAMS) {
|
|
||||||
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
|
|
||||||
return WeightReading(g, "g", stable = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
private fun u16le(b: ByteArray, off: Int): Int =
|
|
||||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
|
||||||
|
|
||||||
private fun u16be(b: ByteArray, off: Int): Int =
|
|
||||||
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#6C63FF"
|
|
||||||
android:pathData="M0,0h108v108H0z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M54,30L54,70 M40,38L68,38 M36,54L44,38 M64,54L72,38 M36,54C36,56 44,56 44,54 M64,54C64,56 72,56 72,54" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="#F3F4F6">
|
|
||||||
|
|
||||||
<!-- ── Update banner (shown at the TOP when a new version is available) ─ -->
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/updateBanner"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:background="#1e293b"
|
|
||||||
android:paddingStart="16dp"
|
|
||||||
android:paddingEnd="8dp"
|
|
||||||
android:paddingTop="10dp"
|
|
||||||
android:paddingBottom="10dp"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvUpdateMessage"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:textColor="#fbbf24"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:text="" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnInstallUpdate"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:text="⬇ Scarica"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#1e293b"
|
|
||||||
android:backgroundTint="#fbbf24"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnDismissUpdate"
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:layout_marginStart="4dp"
|
|
||||||
android:text="✕"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textColor="#94a3b8"
|
|
||||||
android:backgroundTint="@android:color/transparent"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────────────────── -->
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="⚖️ EverShelf Scale Gateway"
|
|
||||||
android:textSize="22sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#1E293B"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:paddingBottom="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="Connect your smart scale to EverShelf via Bluetooth"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textColor="#64748B" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_version"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="v?.?.?"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="#94A3B8"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:gravity="end" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- ── Gateway URL card ───────────────────────────────────────────── -->
|
|
||||||
<androidx.cardview.widget.CardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="12dp"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="2dp"
|
|
||||||
app:cardBackgroundColor="#EFF6FF">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="🌐 Gateway URL (paste into EverShelf)"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#64748B"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_gateway_url"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="ws://…:8765"
|
|
||||||
android:textSize="18sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#1D4ED8"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_gateway_url_hint"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Settings → Smart Scale"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="#94A3B8"
|
|
||||||
android:paddingBottom="8dp" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_copy_url"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="📋 Copy URL"
|
|
||||||
android:backgroundTint="#1D4ED8"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</androidx.cardview.widget.CardView>
|
|
||||||
|
|
||||||
<!-- ── Gateway status ────────────────────────────────────────────── -->
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_gateway_status"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="⏳ Starting gateway…"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textColor="#64748B"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_client_count"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text=""
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#059669"
|
|
||||||
android:paddingBottom="12dp"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<!-- ── Scale connection card ──────────────────────────────────────── -->
|
|
||||||
<androidx.cardview.widget.CardView
|
|
||||||
android:id="@+id/card_connection"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="12dp"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="2dp"
|
|
||||||
app:cardBackgroundColor="@android:color/darker_gray">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_scale_status"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="⚡ Ready — scan for a scale"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:paddingBottom="8dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_weight"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="— — —"
|
|
||||||
android:textSize="46sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:gravity="center"
|
|
||||||
android:paddingTop="8dp"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_weight_hint"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text=""
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#E2E8F0"
|
|
||||||
android:gravity="center"
|
|
||||||
android:paddingBottom="8dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_battery"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text=""
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#E2E8F0"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_disconnect"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="🔌 Disconnect scale"
|
|
||||||
android:backgroundTint="#EF4444"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</androidx.cardview.widget.CardView>
|
|
||||||
|
|
||||||
<!-- ── Scan controls ──────────────────────────────────────────────── -->
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_scan"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="🔍 Scan for Bluetooth Scales"
|
|
||||||
android:backgroundTint="#7C3AED"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:layout_marginBottom="8dp"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:layout_marginBottom="8dp">
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_debug"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="\uD83D\uDC1B Debug"
|
|
||||||
android:backgroundTint="#374151"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_copy_log"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="\uD83D\uDCCB"
|
|
||||||
android:backgroundTint="#374151"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:minWidth="48dp"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_share_log"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="\uD83D\uDCE4"
|
|
||||||
android:backgroundTint="#374151"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:minWidth="48dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:id="@+id/sv_debug_log"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="220dp"
|
|
||||||
android:layout_marginBottom="12dp"
|
|
||||||
android:background="#111827"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_debug_log"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text=""
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:textColor="#4ADE80"
|
|
||||||
android:padding="8dp" />
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_scan_hint"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Press to scan for nearby BLE scales.\nMake sure the scale is turned on."
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#64748B"
|
|
||||||
android:paddingBottom="12dp"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<!-- ── Device list ─────────────────────────────────────────────────── -->
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/rv_devices"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:nestedScrollingEnabled="false" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</ScrollView>
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="8dp"
|
|
||||||
app:cardCornerRadius="10dp"
|
|
||||||
app:cardElevation="1dp"
|
|
||||||
app:cardBackgroundColor="#FFFFFF">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:padding="14dp"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="32dp"
|
|
||||||
android:layout_height="32dp"
|
|
||||||
android:text="⚖️"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:layout_marginEnd="12dp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_device_name"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#1E293B" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_device_addr"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:textColor="#94A3B8" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_device_rssi"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="#64748B" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</androidx.cardview.widget.CardView>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 122 B |
|
Before Width: | Height: | Size: 122 B |
|
Before Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 546 B |
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purple_500">#FF6200EE</color>
|
|
||||||
<color name="purple_700">#FF3700B3</color>
|
|
||||||
<color name="teal_200">#FF03DAC5</color>
|
|
||||||
<color name="white">#FFFFFFFF</color>
|
|
||||||
<color name="black">#FF000000</color>
|
|
||||||
<color name="accent">#7C3AED</color>
|
|
||||||
<color name="green">#059669</color>
|
|
||||||
<color name="red">#EF4444</color>
|
|
||||||
<color name="blue">#1D4ED8</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">EverShelf Scale Gateway</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths>
|
|
||||||
<!-- App-private external dir: no storage permission needed -->
|
|
||||||
<external-files-path name="apk_downloads" path="." />
|
|
||||||
</paths>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Top-level build file
|
|
||||||
plugins {
|
|
||||||
id("com.android.application") version "8.2.2" apply false
|
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
android.useAndroidX=true
|
|
||||||
android.enableJetifier=true
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = "EverShelf Scale Gateway"
|
|
||||||
include(":app")
|
|
||||||