Fix SSD review local truth sync and rotate status

ober

3ffae5450a6ad1e34af1932f9e14c9d5107823f0

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 0c10446..931be4f 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -719,8 +719,11 @@
 	       "    }"
        ""
        "    private fun rotatePage() {"
+       "        loadGeneration += 1"
        "        reviewView.rotateClockwise()"
-       "        setStatus(\"Rotated page\")"
+       "        val session = currentSession"
+       "        val suffix = if (session != null) \"; ${session.cells.size} cells, ${session.groups.size} groups\" else \"\""
+       "        setStatus(\"Rotated page$suffix\")"
        "    }"
        ""
        "    private fun showPageDialog() {"
@@ -3182,6 +3185,7 @@
        "import java.io.FilterOutputStream"
        "import java.io.InputStream"
        "import java.io.OutputStream"
+       "import java.net.HttpURLConnection"
        "import java.net.URL"
        "import java.net.URLEncoder"
        "import java.nio.channels.FileChannel"
@@ -3211,6 +3215,7 @@
        ""
        "class TruthStore(private val context: Context) {"
        "    companion object {"
+       "        private const val DEFAULT_TRUTH_API_URL = \"http://10.66.60.2:8797/api\""
        "        private const val MAX_HTTP_TEXT_BYTES = 32L * 1024L * 1024L"
        "        private const val MAX_HTTP_COMPRESSED_BYTES = 8L * 1024L * 1024L"
        "        private const val MAX_REMOTE_ZIP_BYTES = 96L * 1024L * 1024L"
@@ -3231,7 +3236,7 @@
        ""
        "    private data class RemoteConfig("
        "        val apiUrl: URL,"
-       "        val spkiSha256: ByteArray,"
+       "        val spkiSha256: ByteArray?,"
        "        val bearerToken: String"
        "    )"
        ""
@@ -3488,32 +3493,56 @@
        ""
        "    private fun loadRemoteConfig(): RemoteConfig? {"
        "        val preferences = context.getSharedPreferences(\"ssd_review_remote\", Context.MODE_PRIVATE)"
-       "        val endpoint = preferences.getString(\"base_url\", null)?.trim().orEmpty()"
+       "        val savedEndpoint = preferences.getString(\"base_url\", null)?.trim().orEmpty()"
+       "        val endpoint = normalizeRemoteEndpoint(savedEndpoint.ifBlank { DEFAULT_TRUTH_API_URL })"
        "        val pinText = preferences.getString(\"spki_sha256\", null)?.trim().orEmpty()"
        "        val token = preferences.getString(\"bearer_token\", null)?.trim().orEmpty()"
-       "        if (endpoint.isBlank() && pinText.isBlank() && token.isBlank()) return null"
-       "        require(endpoint.isNotBlank() && pinText.isNotBlank() && token.isNotBlank()) {"
-       "            \"Remote SSD service configuration is incomplete\""
-       "        }"
        "        val url = URL(endpoint.trimEnd('/'))"
-       "        require(url.protocol == \"https\" && url.host.isNotBlank()) { \"HTTPS endpoint required\" }"
+       "        require((url.protocol == \"http\" || url.protocol == \"https\") && url.host.isNotBlank()) { \"HTTP(S) endpoint required\" }"
+       "        if (url.protocol == \"http\") {"
+       "            require(localTruthHttpAllowed(url.host)) { \"HTTP truth sync is only allowed for local/private hosts\" }"
+       "        }"
        "        require(url.userInfo == null && url.query == null && url.ref == null) { \"Unsafe endpoint URL\" }"
-       "        require(token.length in 32..MAX_AUTH_TOKEN_CHARS && !token.contains('\\n') && !token.contains('\\r')) {"
-       "            \"Invalid remote authentication token\""
+       "        if (token.isNotBlank()) {"
+       "            require(token.length <= MAX_AUTH_TOKEN_CHARS && !token.contains('\\n') && !token.contains('\\r')) {"
+       "                \"Invalid remote authentication token\""
+       "            }"
+       "        }"
+       "        val pin = if (pinText.isNotBlank()) {"
+       "            val encodedPin = pinText.removePrefix(\"sha256/\")"
+       "            Base64.decode(encodedPin, Base64.NO_WRAP).also {"
+       "                require(it.size == 32) { \"SPKI SHA-256 pin must decode to 32 bytes\" }"
+       "            }"
+       "        } else {"
+       "            null"
        "        }"
-       "        val encodedPin = pinText.removePrefix(\"sha256/\")"
-       "        val pin = Base64.decode(encodedPin, Base64.NO_WRAP)"
-       "        require(pin.size == 32) { \"SPKI SHA-256 pin must decode to 32 bytes\" }"
+       "        require(url.protocol == \"https\" || pin == null) { \"SPKI pin requires HTTPS\" }"
        "        return RemoteConfig(url, pin, token)"
        "    }"
        ""
+       "    private fun normalizeRemoteEndpoint(value: String): String {"
+       "        var endpoint = value.trim().ifBlank { DEFAULT_TRUTH_API_URL }"
+       "        if (!endpoint.startsWith(\"http://\") && !endpoint.startsWith(\"https://\")) {"
+       "            endpoint = \"http://$endpoint\""
+       "        }"
+       "        endpoint = endpoint.replace(\"10.66.69.2\", \"10.66.60.2\")"
+       "        return if (endpoint.trimEnd('/').endsWith(\"/api\")) endpoint else endpoint.trimEnd('/') + \"/api\""
+       "    }"
+       ""
+       "    private fun localTruthHttpAllowed(host: String): Boolean {"
+       "        val lower = host.lowercase()"
+       "        if (lower == \"localhost\" || lower == \"127.0.0.1\" || lower == \"10.66.60.2\") return true"
+       "        if (lower.startsWith(\"10.\") || lower.startsWith(\"192.168.\")) return true"
+       "        return Regex(\"^172\\\\.(1[6-9]|2[0-9]|3[0-1])\\\\.\").containsMatchIn(lower)"
+       "    }"
+       ""
        "    private fun remoteUrl(relative: String): URL {"
        "        require(relative.isNotBlank() && !relative.startsWith('/') && !relative.contains(\"..\"))"
        "        val config = remoteConfig ?: throw IllegalStateException(\"Remote SSD service is not locally configured\")"
        "        val candidate = URL(config.apiUrl.toString().trimEnd('/') + \"/\" + relative)"
        "        val basePort = if (config.apiUrl.port >= 0) config.apiUrl.port else config.apiUrl.defaultPort"
        "        val candidatePort = if (candidate.port >= 0) candidate.port else candidate.defaultPort"
-       "        require(candidate.protocol == \"https\" && candidate.host == config.apiUrl.host && candidatePort == basePort) {"
+       "        require(candidate.protocol == config.apiUrl.protocol && candidate.host == config.apiUrl.host && candidatePort == basePort) {"
        "            \"Remote URL escaped configured HTTPS origin\""
        "        }"
        "        return candidate"
@@ -3525,15 +3554,17 @@
        "        outputBytes: Long? = null,"
        "        contentType: String? = null,"
        "        acceptGzip: Boolean = false"
-       "    ): HttpsURLConnection {"
+       "    ): HttpURLConnection {"
        "        val config = remoteConfig ?: throw IllegalStateException(\"Remote SSD service is not locally configured\")"
-       "        val connection = (remoteUrl(relative).openConnection() as HttpsURLConnection).apply {"
-       "            sslSocketFactory = pinnedSocketFactory(config)"
+       "        val connection = (remoteUrl(relative).openConnection() as HttpURLConnection).apply {"
+       "            if (this is HttpsURLConnection && config.spkiSha256 != null) {"
+       "                sslSocketFactory = pinnedSocketFactory(config)"
+       "            }"
        "            requestMethod = method"
        "            connectTimeout = 5000"
        "            readTimeout = 30000"
        "            instanceFollowRedirects = false"
-       "            setRequestProperty(\"Authorization\", \"Bearer ${config.bearerToken}\")"
+       "            if (config.bearerToken.isNotBlank()) setRequestProperty(\"Authorization\", \"Bearer ${config.bearerToken}\")"
        "            setRequestProperty(\"Accept\", \"application/json\")"
        "            if (acceptGzip) setRequestProperty(\"Accept-Encoding\", \"gzip\")"
        "            if (contentType != null) setRequestProperty(\"Content-Type\", contentType)"
@@ -3544,17 +3575,20 @@
        "            }"
        "        }"
        "        connection.connect()"
-       "        val certificate = connection.serverCertificates.firstOrNull()"
-       "            ?: throw SSLPeerUnverifiedException(\"Server provided no certificate\")"
-       "        val actualPin = MessageDigest.getInstance(\"SHA-256\").digest(certificate.publicKey.encoded)"
-       "        if (!MessageDigest.isEqual(actualPin, config.spkiSha256)) {"
-       "            connection.disconnect()"
-       "            throw SSLPeerUnverifiedException(\"SSD service identity pin mismatch\")"
+       "        if (connection is HttpsURLConnection && config.spkiSha256 != null) {"
+       "            val certificate = connection.serverCertificates.firstOrNull()"
+       "                ?: throw SSLPeerUnverifiedException(\"Server provided no certificate\")"
+       "            val actualPin = MessageDigest.getInstance(\"SHA-256\").digest(certificate.publicKey.encoded)"
+       "            if (!MessageDigest.isEqual(actualPin, config.spkiSha256)) {"
+       "                connection.disconnect()"
+       "                throw SSLPeerUnverifiedException(\"SSD service identity pin mismatch\")"
+       "            }"
        "        }"
        "        return connection"
        "    }"
        ""
        "    private fun pinnedSocketFactory(config: RemoteConfig): SSLSocketFactory {"
+       "        val expectedPin = config.spkiSha256 ?: throw CertificateException(\"SPKI pin is required for pinned TLS\")"
        "        val managerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())"
        "        managerFactory.init(null as KeyStore?)"
        "        val platform = managerFactory.trustManagers.filterIsInstance<X509TrustManager>().single()"
@@ -3569,7 +3603,7 @@
        "                platform.checkServerTrusted(chain, authType)"
        "                val leaf = chain.firstOrNull() ?: throw CertificateException(\"Server provided no certificate\")"
        "                val actual = MessageDigest.getInstance(\"SHA-256\").digest(leaf.publicKey.encoded)"
-       "                if (!MessageDigest.isEqual(actual, config.spkiSha256)) {"
+       "                if (!MessageDigest.isEqual(actual, expectedPin)) {"
        "                    throw CertificateException(\"SSD service identity pin mismatch\")"
        "                }"
        "            }"
@@ -3648,7 +3682,7 @@
        "        }"
        "    }"
        ""
-       "    private fun readResponseText(connection: HttpsURLConnection): String {"
+       "    private fun readResponseText(connection: HttpURLConnection): String {"
        "        if (connection.contentLengthLong > MAX_HTTP_COMPRESSED_BYTES) {"
        "            throw IllegalStateException(\"HTTP response exceeds compressed-byte limit\")"
        "        }"