diff --git a/c/set-protocol/ports/android/README.md b/c/set-protocol/ports/android/README.md index 65424f8..73616aa 100644 --- a/c/set-protocol/ports/android/README.md +++ b/c/set-protocol/ports/android/README.md @@ -13,3 +13,9 @@ the Android adapter for protocol operations; the portable protocol core remains workspace, up to 2×16384 doubles). `trends/SpectrumAnalyzer` maps timestamps and errors but does not duplicate FFT/filter math. Run it off the UI thread. `trends/PlotViewport` is a toolkit-free normalized zoom/pan model. + +`kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt` is a UI-independent +firmware release client. It reads the optional `firmware.releases` array from +the shared `update.json`, accepts only HTTPS assets, limits their size and +returns image bytes only after SHA-256 verification. Applications provide +their own UI and pass optional HTTP Basic credentials. diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt new file mode 100644 index 0000000..ffdb56c --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt @@ -0,0 +1,170 @@ +package ru.setcorp.setprotocol.update + +import android.util.Base64 +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +/** Учётные данные HTTP Basic для закрытого каталога релизов. */ +data class FirmwareCatalogCredentials(val login: String, val password: String) + +/** Одна проверенная запись секции `firmware.releases` общего update.json. */ +data class FirmwareRelease( + val product: String, + val versionName: String, + val versionCode: Long, + val imageUrl: String, + val fileName: String, + val sha256: String, + val notes: String, + val transport: String, + val baseAddress: Long?, +) + +/** + * Переиспользуемый клиент базы прошивок для Android-приложений SET. + * + * Не зависит от UI: читает общий manifest, валидирует записи, загружает образ + * только по HTTPS и возвращает байты после обязательной проверки SHA-256. + */ +class FirmwareCatalogClient(private val userAgent: String) { + fun readCatalog(manifestUrl: String, credentials: FirmwareCatalogCredentials?): List { + val connection = openChecked(URL(manifestUrl), "application/json", credentials) + val text = try { + connection.inputStream.bufferedReader().use { reader -> + reader.readText().also { + require(it.length <= MAX_MANIFEST_CHARS) { "Файл каталога прошивок слишком большой" } + } + } + } finally { + connection.disconnect() + } + return parse(manifestUrl, text) + } + + fun download( + release: FirmwareRelease, + credentials: FirmwareCatalogCredentials?, + onProgress: (Float) -> Unit = {}, + ): ByteArray { + val connection = openChecked(URL(release.imageUrl), "application/octet-stream", credentials) + val total = connection.contentLengthLong + require(total < 0 || total <= MAX_FIRMWARE_BYTES) { "Файл прошивки превышает допустимый размер" } + return try { + val bytes = connection.inputStream.use { input -> + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var received = 0L + while (true) { + val count = input.read(buffer) + if (count < 0) break + received += count + require(received <= MAX_FIRMWARE_BYTES) { "Файл прошивки превышает допустимый размер" } + output.write(buffer, 0, count) + if (total > 0) onProgress((received.toFloat() / total).coerceIn(0f, 1f)) + } + output.toByteArray() + } + require(bytes.isNotEmpty()) { "Сервер вернул пустой файл прошивки" } + val actual = MessageDigest.getInstance("SHA-256").digest(bytes).toHex() + require(actual.equals(release.sha256, ignoreCase = true)) { + "SHA-256 загруженной прошивки не совпадает с каталогом" + } + onProgress(1f) + bytes + } finally { + connection.disconnect() + } + } + + private fun openChecked( + initial: URL, + accept: String, + credentials: FirmwareCatalogCredentials?, + ): HttpURLConnection { + var current = initial.requireHttps() + val credentialHost = current.host + for (redirect in 0..MAX_REDIRECTS) { + val connection = current.openConnection() as HttpURLConnection + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.instanceFollowRedirects = false + connection.setRequestProperty("Accept", accept) + connection.setRequestProperty("User-Agent", userAgent) + if (credentials != null && current.host.equals(credentialHost, ignoreCase = true)) { + val basic = Base64.encodeToString( + "${credentials.login}:${credentials.password}".toByteArray(), Base64.NO_WRAP, + ) + connection.setRequestProperty("Authorization", "Basic $basic") + } + val code = connection.responseCode + if (code in REDIRECT_CODES) { + val location = connection.getHeaderField("Location") + ?: run { connection.disconnect(); error("Перенаправление без адреса") } + connection.disconnect() + require(redirect < MAX_REDIRECTS) { "Слишком много перенаправлений" } + current = URL(current, location).requireHttps() + continue + } + if (code !in 200..299) { + connection.disconnect() + error("Сервер каталога прошивок вернул HTTP $code") + } + return connection + } + error("Слишком много перенаправлений") + } + + companion object { + fun parse(manifestUrl: String, text: String): List { + require(text.length <= MAX_MANIFEST_CHARS) { "Файл каталога прошивок слишком большой" } + val root = JSONObject(text) + val firmware = root.optJSONObject("firmware") + ?: error("В update.json не опубликован каталог прошивок") + val rows = firmware.optJSONArray("releases") + ?: error("В каталоге отсутствует массив releases") + return buildList { + for (index in 0 until rows.length()) { + val row = rows.getJSONObject(index) + val product = row.optString("product", row.optString("device")).trim() + val versionName = row.optString("versionName", row.optString("version")).trim() + val versionCode = row.optLong("versionCode", -1) + val imageUrl = URL(URL(manifestUrl), row.getString("imageUrl")).toString() + URL(imageUrl).requireHttps() + val sha256 = row.getString("sha256").trim().lowercase() + val fileName = row.optString("fileName", URL(imageUrl).path.substringAfterLast('/')).trim() + val transport = row.optString("transport", "rs485").trim().lowercase() + require(product.isNotEmpty() && versionName.isNotEmpty() && versionCode >= 0) { + "Не заполнены обязательные поля прошивки №${index + 1}" + } + require(sha256.matches(Regex("[0-9a-f]{64}"))) { "Некорректный SHA-256 прошивки №${index + 1}" } + require(fileName.matches(Regex("[A-Za-zА-Яа-яЁё0-9._ -]+\\.(bin|hex)", RegexOption.IGNORE_CASE))) { + "Некорректное имя файла прошивки №${index + 1}" + } + require(transport in setOf("rs485", "can", "stm32")) { "Некорректный канал прошивки №${index + 1}" } + val baseAddress = row.opt("baseAddress")?.takeUnless { it == JSONObject.NULL }?.toString()?.let(::parseAddress) + add(FirmwareRelease( + product, versionName, versionCode, imageUrl, fileName, sha256, + row.optString("notes").trim(), transport, baseAddress, + )) + } + }.sortedWith(compareBy { it.product.lowercase() }.thenByDescending { it.versionCode }) + } + + private fun parseAddress(value: String): Long = + if (value.startsWith("0x", ignoreCase = true)) value.drop(2).toLong(16) else value.toLong() + + private fun URL.requireHttps(): URL = also { + require(protocol.equals("https", ignoreCase = true)) { "Обновления разрешены только по HTTPS" } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + private const val CONNECT_TIMEOUT_MS = 15_000 + private const val READ_TIMEOUT_MS = 60_000 + private const val MAX_REDIRECTS = 5 + private const val MAX_MANIFEST_CHARS = 128 * 1024 + private const val MAX_FIRMWARE_BYTES = 128L * 1024 * 1024 + private val REDIRECT_CODES = setOf(301, 302, 303, 307, 308) + } +}