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:
+2
-7
@@ -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
|
|
||||||
|
|||||||
+14
-12
@@ -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');
|
|
||||||
$repo = env('GITHUB_REPO', 'dadaloop82/EverShelf');
|
|
||||||
if (!empty($token) && !empty($repo)) {
|
|
||||||
_createOrCommentGithubIssue(
|
_createOrCommentGithubIssue(
|
||||||
$token, $repo, $source, $errType,
|
GH_ISSUE_TOKEN, GH_REPO, $source, $errType,
|
||||||
"[$type] $message", $trace,
|
"[$type] $message", $trace,
|
||||||
'', '', PHP_VERSION, $context
|
'', '', 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"
|
||||||
|
|||||||
+237
@@ -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 }
|
||||||
|
}
|
||||||
+10
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user