feat: centralized error reporting → GitHub Issues

- PHP (api/index.php): hardcode GH_ISSUE_TOKEN/GH_REPO constants at top of
  file (before exception handler runs); fix $fp_ variable interpolation bug;
  global set_exception_handler + register_shutdown_function; reportError()
  endpoint (POST ?action=report_error) with rate limiting, local log, dedup
  via fingerprint search on GitHub Issues API

- Kiosk (ErrorReporter.kt): add crash persistence – saves crash payload to
  SharedPreferences before network POST, clears on success, retries as
  'uncaught-exception-survived' on next launch via sendPendingCrash() in init()

- Scale Gateway: new ErrorReporter.kt – calls GitHub Issues API directly
  (no relay needed, token hardcoded, scoped Issues R+W only); crash
  persistence via SharedPreferences; MainActivity.kt hooked at onCreate,
  startGatewayServer catch, onError (BLE errors)

Tested end-to-end: issues #3-#6 created and closed during QA.
This commit is contained in:
dadaloop82
2026-05-03 17:11:11 +00:00
parent f2e151d89b
commit ea40c8e02b
5 changed files with 341 additions and 29 deletions
+2 -7
View File
@@ -21,10 +21,5 @@ TTS_CONTENT_TYPE=application/json
TTS_PAYLOAD_KEY=message TTS_PAYLOAD_KEY=message
TTS_ENABLED=false TTS_ENABLED=false
# GitHub Error Reporting (optional but recommended) # GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients).
# Creates GitHub Issues automatically on crashes/errors from app, kiosk and server. # No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate.
# Create a fine-grained PAT at https://github.com/settings/tokens?type=beta
# → Only selected repos: EverShelf
# → Permissions: Issues (Read+Write), Metadata (Read-only)
GITHUB_ISSUE_TOKEN=
GITHUB_REPO=dadaloop82/EverShelf
+18 -16
View File
@@ -8,6 +8,13 @@
* @license MIT * @license MIT
*/ */
// ── GitHub error-reporting credentials ───────────────────────────────────────
// Token is intentionally hardcoded: scoped only to Issues (R+W) on this repo.
// Defined here (at the very top) so they are available to the global exception
// handler registered below, before any other code runs.
define('GH_ISSUE_TOKEN', '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');
define('GH_REPO', 'dadaloop82/EverShelf');
// database.php must always be loaded (used both by HTTP router and cron) // database.php must always be loaded (used both by HTTP router and cron)
require_once __DIR__ . '/database.php'; require_once __DIR__ . '/database.php';
@@ -5571,6 +5578,9 @@ function migrateUnitsToBase(PDO $db): void {
// ===== CENTRALIZED ERROR REPORTING → GITHUB ISSUES ========================== // ===== CENTRALIZED ERROR REPORTING → GITHUB ISSUES ==========================
// ============================================================================= // =============================================================================
// GH_ISSUE_TOKEN and GH_REPO are defined at the very top of this file so they
// are available to the global exception handler even before this point.
/** /**
* POST /api/?action=report_error * POST /api/?action=report_error
* *
@@ -5610,11 +5620,7 @@ function reportError(): void {
_appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context); _appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context);
// ── Fire GitHub issue (non-blocking: we always return ok to client) ─── // ── Fire GitHub issue (non-blocking: we always return ok to client) ───
$token = env('GITHUB_ISSUE_TOKEN'); _createOrCommentGithubIssue(GH_ISSUE_TOKEN, GH_REPO, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context);
$repo = env('GITHUB_REPO', 'dadaloop82/EverShelf');
if (!empty($token) && !empty($repo)) {
_createOrCommentGithubIssue($token, $repo, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context);
}
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
} }
@@ -5683,7 +5689,7 @@ function _createOrCommentGithubIssue(
. "**Source:** `$source` | **Type:** `$type`\n" . "**Source:** `$source` | **Type:** `$type`\n"
. $urlMd . $uaMd . $verMd . "\n" . $urlMd . $uaMd . $verMd . "\n"
. $ctxMd . $stackMd . $ctxMd . $stackMd
. "\n---\n_fp:$fp_"; . "\n---\n_fp:{$fp}_";
_githubRequest($token, 'POST', _githubRequest($token, 'POST',
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments", "https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
['body' => $body] ['body' => $body]
@@ -5715,7 +5721,7 @@ function _createOrCommentGithubIssue(
. $ctxMd . $ctxMd
. "\n---\n" . "\n---\n"
. "<!-- auto-report fp:$fp -->\n" . "<!-- auto-report fp:$fp -->\n"
. "_This issue was created automatically by EverShelf's error reporter. fp:`$fp`_"; . "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_";
_githubRequest($token, 'POST', _githubRequest($token, 'POST',
"https://api.github.com/repos/$repo/issues", "https://api.github.com/repos/$repo/issues",
@@ -5779,15 +5785,11 @@ function _phpErrorReport(string $message, string $file, int $line, string $trace
_appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context); _appendErrorLog($source, $errType, "[$type] $message", $trace, '', '', $context);
$token = env('GITHUB_ISSUE_TOKEN'); _createOrCommentGithubIssue(
$repo = env('GITHUB_REPO', 'dadaloop82/EverShelf'); GH_ISSUE_TOKEN, GH_REPO, $source, $errType,
if (!empty($token) && !empty($repo)) { "[$type] $message", $trace,
_createOrCommentGithubIssue( '', '', PHP_VERSION, $context
$token, $repo, $source, $errType, );
"[$type] $message", $trace,
'', '', PHP_VERSION, $context
);
}
$running = false; $running = false;
} }
@@ -19,6 +19,11 @@ import java.util.concurrent.Executors
* (POST /api/?action=report_error) which in turn creates or * (POST /api/?action=report_error) which in turn creates or
* updates a GitHub Issue automatically. * updates a GitHub Issue automatically.
* *
* Crash persistence: if the app crashes and the network POST fails (or
* doesn't have time to complete), the crash details are saved to
* SharedPreferences. On the next launch (in init()), any pending crash
* is detected and re-sent before normal operation begins.
*
* Usage: * Usage:
* // In Application or Activity onCreate: * // In Application or Activity onCreate:
* ErrorReporter.init(this, prefs.getString("evershelf_url", "")!!) * ErrorReporter.init(this, prefs.getString("evershelf_url", "")!!)
@@ -32,6 +37,11 @@ import java.util.concurrent.Executors
object ErrorReporter { object ErrorReporter {
private const val TAG = "EverShelfErrorReporter" private const val TAG = "EverShelfErrorReporter"
// SharedPreferences for crash persistence
private const val PREFS_NAME = "evershelf_kiosk_errors"
private const val KEY_PENDING = "pending_crash_json"
private val executor = Executors.newSingleThreadExecutor() private val executor = Executors.newSingleThreadExecutor()
// Fingerprints already sent in this process to avoid flooding // Fingerprints already sent in this process to avoid flooding
@@ -40,6 +50,7 @@ object ErrorReporter {
private var serverBaseUrl: String = "" private var serverBaseUrl: String = ""
private var appVersion: String = "" private var appVersion: String = ""
private var deviceInfo: String = "" private var deviceInfo: String = ""
private lateinit var appContext: Context
/** /**
* Call once (e.g. in KioskActivity.onCreate) before reporting any errors. * Call once (e.g. in KioskActivity.onCreate) before reporting any errors.
@@ -47,6 +58,7 @@ object ErrorReporter {
* @param baseUrl The EverShelf server URL, e.g. "http://192.168.1.10:8080" * @param baseUrl The EverShelf server URL, e.g. "http://192.168.1.10:8080"
*/ */
fun init(context: Context, baseUrl: String) { fun init(context: Context, baseUrl: String) {
appContext = context.applicationContext
serverBaseUrl = baseUrl.trimEnd('/') serverBaseUrl = baseUrl.trimEnd('/')
try { try {
val pi = context.packageManager.getPackageInfo(context.packageName, 0) val pi = context.packageManager.getPackageInfo(context.packageName, 0)
@@ -54,16 +66,23 @@ object ErrorReporter {
} catch (_: Exception) {} } catch (_: Exception) {}
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})" deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
// Send any crash that was saved to prefs during a previous session
sendPendingCrash()
// Install a global UncaughtExceptionHandler so ANY unhandled crash is reported // Install a global UncaughtExceptionHandler so ANY unhandled crash is reported
val previousHandler = Thread.getDefaultUncaughtExceptionHandler() val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try { try {
reportSync( val type = "uncaught-exception"
type = "uncaught-exception", val message = throwable.message ?: throwable.javaClass.simpleName
message = throwable.message ?: throwable.javaClass.simpleName, val stack = throwable.stackTraceToString()
stack = throwable.stackTraceToString(), val ctx = mapOf("thread" to thread.name)
context = mapOf("thread" to thread.name) // Persist to SharedPreferences first so the data survives even if
) // the network POST doesn't complete before the process is killed.
savePendingCrash(type, message, stack, ctx)
reportSync(type, message, stack, ctx)
// If reportSync succeeded, the issue was sent — clear the pending entry
clearPendingCrash()
} catch (_: Exception) {} } catch (_: Exception) {}
// Re-throw to the previous handler so the system crash dialog/restart still works // Re-throw to the previous handler so the system crash dialog/restart still works
previousHandler?.uncaughtException(thread, throwable) previousHandler?.uncaughtException(thread, throwable)
@@ -124,6 +143,55 @@ object ErrorReporter {
doPost(type, message, stack, context) doPost(type, message, stack, context)
} }
// ── Crash persistence helpers ─────────────────────────────────────────────
private fun savePendingCrash(type: String, message: String, stack: String, context: Map<String, Any?>) {
try {
val ctxJson = JSONObject()
context.forEach { (k, v) -> ctxJson.put(k, v) }
val payload = JSONObject().apply {
put("type", type)
put("message", message)
put("stack", stack)
put("context", ctxJson)
put("version", appVersion)
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
}
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_PENDING, payload.toString()).apply()
} catch (_: Exception) {}
}
private fun clearPendingCrash() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().remove(KEY_PENDING).apply()
}
/**
* Called at the start of [init]: if there is an unsent crash from the
* previous session, send it now and then clear the entry.
*/
private fun sendPendingCrash() {
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_PENDING, null) ?: return
// Clear immediately so we don't re-send if THIS launch also crashes
clearPendingCrash()
executor.execute {
try {
val p = JSONObject(json)
val type = p.optString("type", "uncaught-exception")
val message = p.optString("message", "")
val stack = p.optString("stack", "")
val savedTs = p.optString("ts", "")
val ctxJson = p.optJSONObject("context") ?: JSONObject()
val ctx = mutableMapOf<String, Any?>("note" to "Sent on next launch after crash")
if (savedTs.isNotEmpty()) ctx["crash_ts"] = savedTs
ctxJson.keys().forEach { k -> ctx[k] = ctxJson.opt(k) }
doPost("$type-survived", message, stack, ctx)
} catch (_: Exception) {}
}
}
private fun doPost(type: String, message: String, stack: String, context: Map<String, Any?>) { private fun doPost(type: String, message: String, stack: String, context: Map<String, Any?>) {
val url = serverBaseUrl.ifEmpty { return } val url = serverBaseUrl.ifEmpty { return }
val endpoint = "$url/api/?action=report_error" val endpoint = "$url/api/?action=report_error"
@@ -0,0 +1,237 @@
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"
// ── Hardcoded credentials (scoped: Issues R+W on dadaloop82/EverShelf only) ──
private const val GH_TOKEN = "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"
private const val GH_REPO = "dadaloop82/EverShelf"
// 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 $GH_TOKEN")
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 $GH_TOKEN")
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 }
}
@@ -77,6 +77,10 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
bleManager = BleScaleManager(this, this) 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 -> deviceAdapter = DeviceAdapter(devices) { info ->
bleManager.connect(info.device) bleManager.connect(info.device)
} }
@@ -191,6 +195,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT" binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
} catch (e: Exception) { } catch (e: Exception) {
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}" 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 // Auto-scan if there's a saved device
@@ -287,6 +292,11 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
override fun onError(message: String) { override fun onError(message: String) {
binding.tvScaleStatus.text = "$message" binding.tvScaleStatus.text = "$message"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light)) 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() { override fun onScanStopped() {