package `in`.caresoft.sahi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import java.io.BufferedReader import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.nio.charset.StandardCharsets /** * Talks to the Sahi server. * * HttpURLConnection and org.json, both in the platform. A keyboard is the most * sensitive app anyone installs, and every library it pulls in is something the * hospital's security review has to take on trust. Three endpoints do not * justify that. */ object SahiApi { class ApiException(message: String) : Exception(message) suspend fun check(text: String): List = withContext(Dispatchers.IO) { val body = JSONObject() .put("text", text) .put("variety", Prefs.variety) .put("host", "android") val response = post("/api/check.php", body) parseIssues(response.optJSONArray("issues"), text) } suspend fun rewrite(text: String, mode: String): String = withContext(Dispatchers.IO) { val body = JSONObject() .put("text", text) .put("mode", mode) .put("host", "android") post("/api/rewrite.php", body).optString("text", text) } suspend fun ping(): String = withContext(Dispatchers.IO) { post("/api/ping.php", JSONObject()).optString("org", "your organisation") } /** Fire and forget — a failed count must never interrupt someone typing. */ suspend fun report(action: String, issue: Issue) = withContext(Dispatchers.IO) { try { post( "/api/event.php", JSONObject() .put("action", action) .put("type", issue.type) .put("original", issue.original) ) } catch (e: Exception) { // Intentionally swallowed. } Unit } private fun parseIssues(array: JSONArray?, source: String): List { if (array == null) return emptyList() val out = ArrayList(array.length()) for (i in 0 until array.length()) { val item = array.optJSONObject(i) ?: continue val replacements = item.optJSONArray("replacements") if (replacements == null || replacements.length() == 0) continue val offset = item.optInt("offset", -1) val length = item.optInt("length", 0) val original = item.optString("original") // Trust nothing about offsets that arrived over the wire. If the // text at that position is not what the server says it is, the // correction is dropped: a wrong offset silently deletes the wrong // words, and that is the one failure a user never forgives. if (offset < 0 || length <= 0 || offset + length > source.length) continue if (source.substring(offset, offset + length) != original) continue out.add( Issue( offset = offset, length = length, original = original, replacement = replacements.optString(0), type = item.optString("type", "grammar"), message = item.optString("message") ) ) } return out } private fun post(path: String, body: JSONObject): JSONObject { if (!Prefs.isConfigured) { throw ApiException("Open Sahi and enter your server address and key.") } val connection = (URL(Prefs.server + path).openConnection() as HttpURLConnection).apply { requestMethod = "POST" doOutput = true connectTimeout = 8_000 readTimeout = 20_000 setRequestProperty("Content-Type", "application/json; charset=utf-8") setRequestProperty("X-Sahi-Key", Prefs.accessKey) } try { connection.outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) } val code = connection.responseCode val stream = if (code in 200..299) connection.inputStream else connection.errorStream val raw = stream?.bufferedReader(StandardCharsets.UTF_8)?.use(BufferedReader::readText).orEmpty() val json = try { JSONObject(raw) } catch (e: Exception) { throw ApiException("The server sent something Sahi could not read.") } if (code !in 200..299) { throw ApiException(json.optString("error", "The server returned $code.")) } return json } catch (e: IOException) { throw ApiException("Cannot reach the Sahi server. Check the connection.") } finally { connection.disconnect() } } /** * Apply corrections to a string, highest offset first so earlier offsets * stay valid as the text changes. Anything that no longer matches is * skipped rather than guessed at. */ fun applyAll(text: String, issues: List): String { var result = text issues.sortedByDescending { it.offset }.forEach { issue -> if (issue.end <= result.length && result.substring(issue.offset, issue.end) == issue.original ) { result = result.substring(0, issue.offset) + issue.replacement + result.substring(issue.end) } } return result } }