diff --git a/.env.example b/.env.example index cd3455b..dfb5881 100644 --- a/.env.example +++ b/.env.example @@ -21,10 +21,5 @@ TTS_CONTENT_TYPE=application/json TTS_PAYLOAD_KEY=message TTS_ENABLED=false -# GitHub Error Reporting (optional but recommended) -# Creates GitHub Issues automatically on crashes/errors from app, kiosk and server. -# 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 +# GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients). +# No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate. diff --git a/api/index.php b/api/index.php index f89512b..adc4ef8 100644 --- a/api/index.php +++ b/api/index.php @@ -8,6 +8,13 @@ * @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) require_once __DIR__ . '/database.php'; @@ -5571,6 +5578,9 @@ function migrateUnitsToBase(PDO $db): void { // ===== 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 * @@ -5610,11 +5620,7 @@ function reportError(): void { _appendErrorLog($source, $type, $message, $stack, $pageUrl, $ua, $context); // ── Fire GitHub issue (non-blocking: we always return ok to client) ─── - $token = env('GITHUB_ISSUE_TOKEN'); - $repo = env('GITHUB_REPO', 'dadaloop82/EverShelf'); - if (!empty($token) && !empty($repo)) { - _createOrCommentGithubIssue($token, $repo, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context); - } + _createOrCommentGithubIssue(GH_ISSUE_TOKEN, GH_REPO, $source, $type, $message, $stack, $pageUrl, $ua, $version, $context); echo json_encode(['ok' => true]); } @@ -5683,7 +5689,7 @@ function _createOrCommentGithubIssue( . "**Source:** `$source` | **Type:** `$type`\n" . $urlMd . $uaMd . $verMd . "\n" . $ctxMd . $stackMd - . "\n---\n_fp:$fp_"; + . "\n---\n_fp:{$fp}_"; _githubRequest($token, 'POST', "https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments", ['body' => $body] @@ -5715,7 +5721,7 @@ function _createOrCommentGithubIssue( . $ctxMd . "\n---\n" . "\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', "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); - $token = env('GITHUB_ISSUE_TOKEN'); - $repo = env('GITHUB_REPO', 'dadaloop82/EverShelf'); - if (!empty($token) && !empty($repo)) { - _createOrCommentGithubIssue( - $token, $repo, $source, $errType, - "[$type] $message", $trace, - '', '', PHP_VERSION, $context - ); - } + _createOrCommentGithubIssue( + GH_ISSUE_TOKEN, GH_REPO, $source, $errType, + "[$type] $message", $trace, + '', '', PHP_VERSION, $context + ); $running = false; } diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ErrorReporter.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ErrorReporter.kt index faacfb1..29ae258 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ErrorReporter.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ErrorReporter.kt @@ -19,6 +19,11 @@ import java.util.concurrent.Executors * (POST /api/?action=report_error) which in turn creates or * 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: * // In Application or Activity onCreate: * ErrorReporter.init(this, prefs.getString("evershelf_url", "")!!) @@ -32,6 +37,11 @@ import java.util.concurrent.Executors object ErrorReporter { 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() // Fingerprints already sent in this process to avoid flooding @@ -40,6 +50,7 @@ object ErrorReporter { private var serverBaseUrl: String = "" private var appVersion: String = "" private var deviceInfo: String = "" + private lateinit var appContext: Context /** * 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" */ fun init(context: Context, baseUrl: String) { + appContext = context.applicationContext serverBaseUrl = baseUrl.trimEnd('/') try { val pi = context.packageManager.getPackageInfo(context.packageName, 0) @@ -54,16 +66,23 @@ object ErrorReporter { } catch (_: Exception) {} 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 val previousHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> try { - reportSync( - type = "uncaught-exception", - message = throwable.message ?: throwable.javaClass.simpleName, - stack = throwable.stackTraceToString(), - context = mapOf("thread" to thread.name) - ) + val type = "uncaught-exception" + val message = throwable.message ?: throwable.javaClass.simpleName + val stack = throwable.stackTraceToString() + val ctx = 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) {} // Re-throw to the previous handler so the system crash dialog/restart still works previousHandler?.uncaughtException(thread, throwable) @@ -124,6 +143,55 @@ object ErrorReporter { doPost(type, message, stack, context) } + // ── Crash persistence helpers ───────────────────────────────────────────── + + private fun savePendingCrash(type: String, message: String, stack: String, context: Map) { + 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("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) { val url = serverBaseUrl.ifEmpty { return } val endpoint = "$url/api/?action=report_error" diff --git a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/ErrorReporter.kt b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/ErrorReporter.kt new file mode 100644 index 0000000..365d151 --- /dev/null +++ b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/ErrorReporter.kt @@ -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() + + 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 = emptyMap()) { + val ctx = mutableMapOf("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 = emptyMap()) { + val ctx = mutableMapOf("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) { + 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): 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\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 } +} diff --git a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt index 2aee2cf..f66f20e 100644 --- a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt +++ b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt @@ -77,6 +77,10 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener 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) } @@ -191,6 +195,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener 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 @@ -287,6 +292,11 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener 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() {