Send Android client errors to Mac log service

ober

2e8b0af15c1130b59c54ce0bab669ef3726be78f

diff --git a/templates/original-tactics.ss b/templates/original-tactics.ss
index edada84..973a39f 100644
--- a/templates/original-tactics.ss
+++ b/templates/original-tactics.ss
@@ -5808,7 +5808,7 @@
        "    }"
        ""
        "    private fun clientLogEndpoint(): String ="
-       "        if (::rulesClient.isInitialized) rulesClient.clientLogEndpoint() else DEFAULT_ENDPOINT.removeSuffix(\"/api/service\") + \"/api/client-log\""
+       "        CLIENT_LOG_ENDPOINT"
        ""
        "    private fun clientLogPayload(event: String, fields: JSONObject): JSONObject ="
        "        JSONObject()"
@@ -6112,6 +6112,7 @@
        "        private val UI_DIM = Color.rgb(102, 120, 148)"
        "        private val UI_ACCENT = Color.rgb(88, 156, 226)"
        "        private const val DEFAULT_ENDPOINT = \"http://127.0.0.1:8788/api/service\""
+       "        private const val CLIENT_LOG_ENDPOINT = \"http://10.66.60.2:8799/api/client-log\""
        "        private const val PREF_ENDPOINT = \"endpoint\""
        "        private const val PREF_STATE_SCHEMA_VERSION = \"state_schema_version\""
        "        private const val STATE_SCHEMA_VERSION = 2"
@@ -6197,7 +6198,7 @@
        "        if (embeddedAvailable) EMBEDDED_HEALTH_ENDPOINT else fallbackEndpoint.removeSuffix(\"/api/service\") + \"/health\""
        ""
        "    fun clientLogEndpoint(): String ="
-       "        if (embeddedAvailable) EMBEDDED_CLIENT_LOG_ENDPOINT else fallbackEndpoint.removeSuffix(\"/api/service\") + \"/api/client-log\""
+       "        EXTERNAL_CLIENT_LOG_ENDPOINT"
        ""
        "    fun modeLabel(): String ="
        "        if (embeddedAvailable) \"embedded\" else \"http-fallback\""
@@ -6210,7 +6211,7 @@
        "                .put(\"engine\", \"jerboa\")"
        "                .put(\"transport\", \"embedded\")"
        "                .put(\"endpoint\", EMBEDDED_SERVICE_ENDPOINT)"
-       "                .put(\"client-log-endpoint\", EMBEDDED_CLIENT_LOG_ENDPOINT)"
+       "                .put(\"client-log-endpoint\", EXTERNAL_CLIENT_LOG_ENDPOINT)"
        "                .toString()"
        "        }"
        "        return httpGet(url)"
@@ -6272,6 +6273,7 @@
        "        private const val EMBEDDED_SERVICE_ENDPOINT = \"embedded://sfb-rules/api/service\""
        "        private const val EMBEDDED_HEALTH_ENDPOINT = \"embedded://sfb-rules/health\""
        "        private const val EMBEDDED_CLIENT_LOG_ENDPOINT = \"embedded://sfb-rules/api/client-log\""
+       "        private const val EXTERNAL_CLIENT_LOG_ENDPOINT = \"http://10.66.60.2:8799/api/client-log\""
        "    }"
        "}"
        ""
diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index e31924e..c20322e 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -267,6 +267,7 @@
        "import android.os.Bundle"
        "import android.os.Environment"
        "import android.os.ParcelFileDescriptor"
+       "import android.os.SystemClock"
        "import android.provider.DocumentsContract"
        "import android.provider.MediaStore"
        "import android.provider.OpenableColumns"
@@ -286,10 +287,15 @@
        "import android.widget.TextView"
 	       "import android.widget.Toast"
 	       "import androidx.documentfile.provider.DocumentFile"
-	       "import org.json.JSONObject"
-	       "import java.io.File"
+       "import org.json.JSONObject"
+       "import java.io.File"
+       "import java.io.PrintWriter"
+       "import java.io.StringWriter"
+       "import java.net.HttpURLConnection"
+       "import java.net.URL"
        "import java.util.UUID"
        "import kotlin.concurrent.thread"
+       "import kotlin.system.exitProcess"
        "import kotlin.math.ceil"
        "import kotlin.math.max"
        "import kotlin.math.sqrt"
@@ -333,6 +339,8 @@
        ""
        "    override fun onCreate(savedInstanceState: Bundle?) {"
        "        super.onCreate(savedInstanceState)"
+       "        installClientCrashLogger()"
+       "        logClientEvent(\"ssd-app-start\")"
        "        truthStore = TruthStore(this)"
        "        setContentView(buildUi())"
        "        setStatus(\"Open an SSD PDF. ${truthStore.storageSummary()}\")"
@@ -1668,8 +1676,108 @@
        "        return uri.lastPathSegment ?: \"ssd.pdf\""
        "    }"
        ""
+       "    private fun installClientCrashLogger() {"
+       "        if (clientCrashLoggerInstalled) return"
+       "        clientCrashLoggerInstalled = true"
+       "        val previous = Thread.getDefaultUncaughtExceptionHandler()"
+       "        Thread.setDefaultUncaughtExceptionHandler { crashingThread, throwable ->"
+       "            val payload = try {"
+       "                clientLogPayload("
+       "                    \"ssd-uncaught-exception\","
+       "                    JSONObject()"
+       "                        .put(\"crashing-thread\", crashingThread.name ?: \"\")"
+       "                        .put(\"error\", throwableLog(throwable))"
+       "                )"
+       "            } catch (_: Exception) { JSONObject().put(\"event\", \"ssd-uncaught-exception\") }"
+       "            try {"
+       "                val sender = thread(start = true) { sendClientLog(payload, CLIENT_LOG_CRASH_TIMEOUT_MS) }"
+       "                sender.join((CLIENT_LOG_CRASH_TIMEOUT_MS + 500).toLong())"
+       "            } catch (_: Exception) {"
+       "                // Diagnostics must not change Android crash handling."
+       "            }"
+       "            if (previous != null) previous.uncaughtException(crashingThread, throwable) else exitProcess(2)"
+       "        }"
+       "    }"
+       ""
+       "    private fun logClientEvent(event: String, fields: JSONObject = JSONObject()) {"
+       "        val payload = try { clientLogPayload(event, fields) } catch (_: Exception) { return }"
+       "        thread {"
+       "            try { sendClientLog(payload, CLIENT_LOG_READ_TIMEOUT_MS) } catch (_: Exception) { }"
+       "        }"
+       "    }"
+       ""
+       "    private fun sendClientLog(payload: JSONObject, readTimeoutMillis: Int) {"
+       "        val bytes = payload.toString().toByteArray(Charsets.UTF_8)"
+       "        val connection = (URL(CLIENT_LOG_ENDPOINT).openConnection() as HttpURLConnection).apply {"
+       "            requestMethod = \"POST\""
+       "            connectTimeout = CLIENT_LOG_CONNECT_TIMEOUT_MS"
+       "            readTimeout = readTimeoutMillis"
+       "            doOutput = true"
+       "            setRequestProperty(\"Content-Type\", \"application/json; charset=utf-8\")"
+       "            setFixedLengthStreamingMode(bytes.size)"
+       "        }"
+       "        try {"
+       "            connection.outputStream.use { it.write(bytes) }"
+       "            val code = connection.responseCode"
+       "            if (code !in 200..299) throw IllegalStateException(\"client log HTTP $code\")"
+       "            connection.inputStream.close()"
+       "        } finally {"
+       "            connection.disconnect()"
+       "        }"
+       "    }"
+       ""
+       "    private fun clientLogPayload(event: String, fields: JSONObject): JSONObject ="
+       "        JSONObject()"
+       "            .put(\"schema\", 1)"
+       "            .put(\"event\", event)"
+       "            .put(\"client-time-ms\", System.currentTimeMillis())"
+       "            .put(\"uptime-ms\", SystemClock.uptimeMillis())"
+       "            .put(\"thread\", Thread.currentThread().name ?: \"\")"
+       "            .put(\"app\", appLogInfo())"
+       "            .put(\"device\", deviceLogInfo())"
+       "            .put(\"pdf\", JSONObject().put(\"name\", pdfName).put(\"page\", pageIndex + 1).put(\"pages\", pageCount))"
+       "            .put(\"selected-group\", selectedGroupId ?: \"\")"
+       "            .put(\"fields\", fields)"
+       ""
+       "    private fun appLogInfo(): JSONObject {"
+       "        val out = JSONObject().put(\"package\", packageName)"
+       "        return try {"
+       "            val info = packageManager.getPackageInfo(packageName, 0)"
+       "            val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else {"
+       "                @Suppress(\"DEPRECATION\")"
+       "                info.versionCode.toLong()"
+       "            }"
+       "            out.put(\"version-name\", info.versionName ?: \"\").put(\"version-code\", versionCode)"
+       "        } catch (_: Exception) { out }"
+       "    }"
+       ""
+       "    private fun deviceLogInfo(): JSONObject ="
+       "        JSONObject()"
+       "            .put(\"sdk\", Build.VERSION.SDK_INT)"
+       "            .put(\"release\", Build.VERSION.RELEASE ?: \"\")"
+       "            .put(\"manufacturer\", Build.MANUFACTURER ?: \"\")"
+       "            .put(\"model\", Build.MODEL ?: \"\")"
+       ""
+       "    private fun throwableLog(error: Throwable): JSONObject {"
+       "        val writer = StringWriter()"
+       "        error.printStackTrace(PrintWriter(writer))"
+       "        val stack = writer.toString()"
+       "        return JSONObject()"
+       "            .put(\"class\", error.javaClass.name)"
+       "            .put(\"message\", error.message ?: \"\")"
+       "            .put(\"stack\", if (stack.length <= CLIENT_LOG_STACK_CHARS) stack else stack.take(CLIENT_LOG_STACK_CHARS) + \"...[truncated]\")"
+       "    }"
+       ""
+       "    private fun isErrorStatus(text: String): Boolean {"
+       "        val lower = text.lowercase()"
+       "        return lower.contains(\"failed\") || lower.contains(\"error\") || lower.contains(\"exception\") || lower.contains(\"rejected\")"
+       "    }"
+       ""
        "    private fun setStatus(text: String) {"
        "        status.text = text"
+       "        if (isErrorStatus(text)) {"
+       "            logClientEvent(\"ssd-status-error\", JSONObject().put(\"status\", text))"
+       "        }"
        "    }"
        ""
        "    companion object {"
@@ -1685,6 +1793,12 @@
        "        private const val MAX_SSD_PDF_SCAN_DEPTH = 8"
        "        private const val MAX_SSD_PDF_CHOICES = 1000"
        "        private const val FAST_TRUTH_GROUP_THRESHOLD = 5"
+       "        @Volatile private var clientCrashLoggerInstalled = false"
+       "        private const val CLIENT_LOG_ENDPOINT = \"http://10.66.60.2:8799/api/client-log\""
+       "        private const val CLIENT_LOG_CONNECT_TIMEOUT_MS = 2500"
+       "        private const val CLIENT_LOG_READ_TIMEOUT_MS = 1500"
+       "        private const val CLIENT_LOG_CRASH_TIMEOUT_MS = 1500"
+       "        private const val CLIENT_LOG_STACK_CHARS = 12000"
        "    }"
        ""
        "    private data class SsdPdfChoice(val label: String, val uri: Uri)"