diff --git a/aidl/pawletos/bgupd/IBgUpdaterService.aidl b/aidl/pawletos/bgupd/IBgUpdaterService.aidl index 0c6095c..571bf9d 100644 --- a/aidl/pawletos/bgupd/IBgUpdaterService.aidl +++ b/aidl/pawletos/bgupd/IBgUpdaterService.aidl @@ -6,11 +6,11 @@ package pawletos.bgupd; import pawletos.bgupd.IUpdateCallback; import pawletos.bgupd.ComponentStatus; -import android.os.Bundle; +import pawletos.bgupd.UpdateParams; interface IBgUpdaterService { // Immediate, user-triggered (e.g. settings toggle changing WebView provider) - void requestComponentUpdate(String componentId, in Bundle params, IUpdateCallback callback); + void requestComponentUpdate(String componentId, in @nullable UpdateParams params, IUpdateCallback callback); // Query current state without forcing a network check ComponentStatus getComponentStatus(String componentId); diff --git a/aidl/pawletos/bgupd/UpdateParams.aidl b/aidl/pawletos/bgupd/UpdateParams.aidl new file mode 100644 index 0000000..fef9771 --- /dev/null +++ b/aidl/pawletos/bgupd/UpdateParams.aidl @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package pawletos.bgupd; + +/** + * Options for a user-triggered component update. A structured parcelable + * rather than a Bundle: android.os.Bundle is not usable from a structured + * aidl_interface module, and this stays extensible by adding fields. + */ +parcelable UpdateParams { + @nullable String provider; +} diff --git a/src/os/pawlet/bgupd/BgUpdaterService.kt b/src/os/pawlet/bgupd/BgUpdaterService.kt index 828cbbf..0ce5027 100644 --- a/src/os/pawlet/bgupd/BgUpdaterService.kt +++ b/src/os/pawlet/bgupd/BgUpdaterService.kt @@ -6,12 +6,13 @@ package os.pawlet.bgupd import android.app.Service import android.content.Intent -import android.os.Bundle import android.os.IBinder +import os.pawlet.bgupd.hardware.HardwareUpdateExecutor import os.pawlet.bgupd.manifest.ManifestClient import pawletos.bgupd.ComponentStatus import pawletos.bgupd.IBgUpdaterService import pawletos.bgupd.IUpdateCallback +import pawletos.bgupd.UpdateParams import java.util.concurrent.Executors /** @@ -36,10 +37,10 @@ class BgUpdaterService : Service() { private val binder = object : IBgUpdaterService.Stub() { override fun requestComponentUpdate( componentId: String, - params: Bundle?, + params: UpdateParams?, callback: IUpdateCallback?, ) { - val provider = params?.getString("provider") + val provider = params?.provider workers.execute { executor.runComponent(componentId, provider, Constants.TriggerType.USER_TOGGLE, callback) } @@ -65,8 +66,15 @@ class BgUpdaterService : Service() { override fun forceCheckAll(callback: IUpdateCallback?) { workers.execute { - ManifestClient().fetchManifest().keys.forEach { componentId -> - executor.runComponent(componentId, null, Constants.TriggerType.MANUAL_CHECK, callback) + val manifest = ManifestClient().fetchManifest() + manifest.keys + .filter { it in Constants.SUPPORTED_COMPONENTS } + .forEach { componentId -> + executor.runComponent(componentId, null, Constants.TriggerType.MANUAL_CHECK, callback) + } + manifest[Constants.COMPONENT_HARDWARE]?.let { hw -> + HardwareUpdateExecutor(this@BgUpdaterService) + .runAll(hw.url, Constants.TriggerType.MANUAL_CHECK, callback) } } } diff --git a/src/os/pawlet/bgupd/Constants.kt b/src/os/pawlet/bgupd/Constants.kt index 8736e5e..5d9340e 100644 --- a/src/os/pawlet/bgupd/Constants.kt +++ b/src/os/pawlet/bgupd/Constants.kt @@ -9,6 +9,11 @@ object Constants { const val TAG = "BgUpd" const val COMPONENT_WEBVIEW = "webview" + const val COMPONENT_HARDWARE = "hardware" + + // Manifest entries handled by the APK-install pipeline (UpdateExecutor). + // The "hardware" manifest entry is handled by HardwareUpdateExecutor. + val SUPPORTED_COMPONENTS = setOf(COMPONENT_WEBVIEW) object TriggerType { const val SCHEDULED = "scheduled" @@ -22,6 +27,9 @@ object Constants { const val SIGNATURE_MISMATCH = 3 const val INSTALL_FAILED = 4 const val WEBVIEW_SWITCH_FAILED = 5 + const val HW_NOT_AUTHORIZED = 6 + const val HW_CHECKSUM_MISMATCH = 7 + const val HW_INSTALL_FAILED = 8 const val UNKNOWN = 99 } diff --git a/src/os/pawlet/bgupd/UpdateExecutor.kt b/src/os/pawlet/bgupd/UpdateExecutor.kt index 71c12e8..4044d71 100644 --- a/src/os/pawlet/bgupd/UpdateExecutor.kt +++ b/src/os/pawlet/bgupd/UpdateExecutor.kt @@ -68,19 +68,30 @@ class UpdateExecutor(private val context: Context) { return } + // Fail closed: without a package name and signing-cert digest the + // download can be neither verified nor safely installed. + val packageName = update.packageName + val signatureSha256 = update.signatureSha256 + if (packageName.isNullOrEmpty() || signatureSha256.isNullOrEmpty()) { + fail(componentId, provider, Constants.ErrorCode.SIGNATURE_MISMATCH, + "Catalog entry for $componentId (${provider ?: "?"}) is missing " + + "package_name/signature_sha256 — refusing to install", triggerType, callback) + return + } + progress(componentId, 30, "Downloading ${update.version}", callback) val apkFile = download(componentId, update) progress(componentId, 60, "Verifying signature", callback) - if (!SignatureVerifier.verify(context.packageManager, apkFile, update.signatureSha256)) { + if (!SignatureVerifier.verify(context.packageManager, apkFile, signatureSha256)) { apkFile.delete() fail(componentId, provider, Constants.ErrorCode.SIGNATURE_MISMATCH, - "Signature mismatch for ${update.packageName}", triggerType, callback) + "Signature mismatch for $packageName", triggerType, callback) return } progress(componentId, 80, "Installing", callback) - val result = installer.installSilently(apkFile, update.packageName) + val result = installer.installSilently(apkFile, packageName) apkFile.delete() if (!result.success) { fail(componentId, provider, Constants.ErrorCode.INSTALL_FAILED, @@ -88,7 +99,7 @@ class UpdateExecutor(private val context: Context) { return } - onInstallSuccess(config, update, provider) + onInstallSuccess(config, update, provider, packageName, signatureSha256) succeed(componentId, update.version, triggerType, update.provider ?: provider, callback) } catch (e: Exception) { Log.e(Constants.TAG, "runComponent($componentId) failed", e) @@ -101,6 +112,8 @@ class UpdateExecutor(private val context: Context) { config: ComponentConfig, update: ComponentUpdateResponse, requestedProvider: String?, + packageName: String, + signatureSha256: String, ) { val now = System.currentTimeMillis() val providerId = update.provider ?: requestedProvider @@ -117,15 +130,15 @@ class UpdateExecutor(private val context: Context) { ProviderState( componentId = config.componentId, providerId = providerId, - packageName = update.packageName, + packageName = packageName, installedVersion = update.version, enabled = true, - signatureHash = update.signatureSha256, + signatureHash = signatureSha256, installedAt = now, ) ) if (config.componentId == Constants.COMPONENT_WEBVIEW) { - WebViewProviderSwitcher.switchTo(update.packageName) + WebViewProviderSwitcher.switchTo(packageName) } } @@ -280,7 +293,17 @@ class UpdateExecutor(private val context: Context) { return outFile } - private fun arch(): String = Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a" + /** + * The update catalog is keyed by short arch names ("arm64"), not Android + * ABI strings ("arm64-v8a") — see webview-versions.json on the server. + */ + private fun arch(): String = when (Build.SUPPORTED_ABIS.firstOrNull()) { + "arm64-v8a" -> "arm64" + "armeabi-v7a", "armeabi" -> "arm" + "x86_64" -> "x86_64" + "x86" -> "x86" + else -> "arm64" + } private fun progress(componentId: String, percent: Int, message: String, callback: IUpdateCallback?) { try { diff --git a/src/os/pawlet/bgupd/db/BgUpdDbHelper.kt b/src/os/pawlet/bgupd/db/BgUpdDbHelper.kt index 06c30bb..15f05e5 100644 --- a/src/os/pawlet/bgupd/db/BgUpdDbHelper.kt +++ b/src/os/pawlet/bgupd/db/BgUpdDbHelper.kt @@ -13,11 +13,12 @@ class BgUpdDbHelper(context: Context) : companion object { const val DATABASE_NAME = "bgupd.db" - const val DATABASE_VERSION = 1 + const val DATABASE_VERSION = 2 const val TABLE_COMPONENTS = "components" const val TABLE_PROVIDER_STATE = "provider_state" const val TABLE_UPDATE_LOG = "update_log" + const val TABLE_HARDWARE_STATE = "hardware_state" private const val SQL_CREATE_COMPONENTS = """ CREATE TABLE $TABLE_COMPONENTS ( @@ -57,18 +58,34 @@ class BgUpdDbHelper(context: Context) : timestamp INTEGER NOT NULL ) """ + + // Hardware components this device keeps firmware-current — merged from + // the SetupWizard-written detected-hardware file plus explicit config. + private const val SQL_CREATE_HARDWARE_STATE = """ + CREATE TABLE $TABLE_HARDWARE_STATE ( + hardware_id TEXT NOT NULL, + component_id TEXT NOT NULL, + hardware_revision TEXT, + installed_version TEXT, + bootloader_version TEXT, + source TEXT NOT NULL, + last_checked_at INTEGER, + last_success_at INTEGER, + PRIMARY KEY (hardware_id, component_id) + ) + """ } override fun onCreate(db: SQLiteDatabase) { db.execSQL(SQL_CREATE_COMPONENTS) db.execSQL(SQL_CREATE_PROVIDER_STATE) db.execSQL(SQL_CREATE_UPDATE_LOG) + db.execSQL(SQL_CREATE_HARDWARE_STATE) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - db.execSQL("DROP TABLE IF EXISTS $TABLE_UPDATE_LOG") - db.execSQL("DROP TABLE IF EXISTS $TABLE_PROVIDER_STATE") - db.execSQL("DROP TABLE IF EXISTS $TABLE_COMPONENTS") - onCreate(db) + if (oldVersion < 2) { + db.execSQL(SQL_CREATE_HARDWARE_STATE) + } } } diff --git a/src/os/pawlet/bgupd/db/ComponentRepository.kt b/src/os/pawlet/bgupd/db/ComponentRepository.kt index 947bd95..5c6bdad 100644 --- a/src/os/pawlet/bgupd/db/ComponentRepository.kt +++ b/src/os/pawlet/bgupd/db/ComponentRepository.kt @@ -7,6 +7,7 @@ package os.pawlet.bgupd.db import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase +import os.pawlet.bgupd.hardware.HardwareComponentState import os.pawlet.bgupd.model.ComponentState import os.pawlet.bgupd.model.ProviderState @@ -129,6 +130,78 @@ class ComponentRepository(context: Context) { fun getProviderState(componentId: String, providerId: String): ProviderState? = getProviderStates(componentId).find { it.providerId == providerId } + // ------------------------------------------------------------------ + // Hardware components (firmware updates) + // ------------------------------------------------------------------ + + /** + * Upsert from the detected-hardware inventory. Identity fields from the + * incoming row win when known; DB values are kept where the inventory is + * silent (notably installed_version, which this app updates after a + * successful flash, and the check/success timestamps). + */ + fun mergeHardwareComponent(incoming: HardwareComponentState) { + val existing = getHardwareComponent(incoming.hardwareId, incoming.componentId) + val values = ContentValues().apply { + put("hardware_id", incoming.hardwareId) + put("component_id", incoming.componentId) + put("hardware_revision", incoming.hardwareRevision ?: existing?.hardwareRevision) + put("installed_version", incoming.installedVersion ?: existing?.installedVersion) + put("bootloader_version", incoming.bootloaderVersion ?: existing?.bootloaderVersion) + put("source", incoming.source) + put("last_checked_at", existing?.lastCheckedAt ?: 0) + put("last_success_at", existing?.lastSuccessAt ?: 0) + } + dbHelper.writableDatabase.insertWithOnConflict( + BgUpdDbHelper.TABLE_HARDWARE_STATE, null, values, SQLiteDatabase.CONFLICT_REPLACE + ) + } + + fun getHardwareComponent(hardwareId: String, componentId: String): HardwareComponentState? = + getHardwareComponents().find { it.hardwareId == hardwareId && it.componentId == componentId } + + fun getHardwareComponents(): List { + val result = mutableListOf() + dbHelper.readableDatabase.query( + BgUpdDbHelper.TABLE_HARDWARE_STATE, null, null, null, null, null, null + ).use { cursor -> + while (cursor.moveToNext()) { + result.add( + HardwareComponentState( + hardwareId = cursor.getString(cursor.getColumnIndexOrThrow("hardware_id")), + componentId = cursor.getString(cursor.getColumnIndexOrThrow("component_id")), + hardwareRevision = cursor.getString(cursor.getColumnIndexOrThrow("hardware_revision")), + installedVersion = cursor.getString(cursor.getColumnIndexOrThrow("installed_version")), + bootloaderVersion = cursor.getString(cursor.getColumnIndexOrThrow("bootloader_version")), + source = cursor.getString(cursor.getColumnIndexOrThrow("source")), + lastCheckedAt = cursor.getLong(cursor.getColumnIndexOrThrow("last_checked_at")), + lastSuccessAt = cursor.getLong(cursor.getColumnIndexOrThrow("last_success_at")), + ) + ) + } + } + return result + } + + fun touchHardwareChecked(hardwareId: String, componentId: String, timestamp: Long) { + val values = ContentValues().apply { put("last_checked_at", timestamp) } + dbHelper.writableDatabase.update( + BgUpdDbHelper.TABLE_HARDWARE_STATE, values, + "hardware_id = ? AND component_id = ?", arrayOf(hardwareId, componentId) + ) + } + + fun setHardwareInstalledVersion(hardwareId: String, componentId: String, version: String, timestamp: Long) { + val values = ContentValues().apply { + put("installed_version", version) + put("last_success_at", timestamp) + } + dbHelper.writableDatabase.update( + BgUpdDbHelper.TABLE_HARDWARE_STATE, values, + "hardware_id = ? AND component_id = ?", arrayOf(hardwareId, componentId) + ) + } + fun insertUpdateLog( componentId: String, providerId: String?, diff --git a/src/os/pawlet/bgupd/hardware/DetectedHardwareStore.kt b/src/os/pawlet/bgupd/hardware/DetectedHardwareStore.kt new file mode 100644 index 0000000..cae32d3 --- /dev/null +++ b/src/os/pawlet/bgupd/hardware/DetectedHardwareStore.kt @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.bgupd.hardware + +import android.util.Log +import org.json.JSONObject +import os.pawlet.bgupd.Constants +import java.io.File + +/** + * Reader for the detected-hardware inventory written by the PawletOS + * SetupWizard (and rewritable later by settings tooling). bg-upd does no + * hardware probing itself — this file is the interchange contract. + * + * Path: /data/pawlet/detected_hardware.json + * Schema: + * { + * "schema_version": 1, + * "components": [ + * { + * "hardware_id": "waveshare-7inch-dsi-lcd-c", + * "component_id": "waveshare-7inch-dsi-lcd-c-touch-controller", + * "hardware_revision": "B", // optional — omit when unknown + * "installed_version": "2.3.0", // optional — omit when unknown + * "bootloader_version": "1.4.0" // optional — omit when unknown + * } + * ] + * } + * + * Unknown identity stays unknown: revision-restricted updates never apply to + * a component whose revision is not recorded here (fail closed, both server- + * and client-side). + */ +object DetectedHardwareStore { + + const val DETECTED_HARDWARE_PATH = "/data/pawlet/detected_hardware.json" + + fun read(path: String = DETECTED_HARDWARE_PATH): List { + val file = File(path) + if (!file.isFile) return emptyList() + return try { + parse(file.readText()) + } catch (e: Exception) { + Log.e(Constants.TAG, "Unparseable detected-hardware file at $path — ignoring", e) + emptyList() + } + } + + fun parse(body: String): List { + val json = JSONObject(body) + val components = json.optJSONArray("components") ?: return emptyList() + val result = mutableListOf() + for (i in 0 until components.length()) { + val c = components.getJSONObject(i) + result.add( + HardwareComponentState( + hardwareId = c.getString("hardware_id"), + componentId = c.getString("component_id"), + hardwareRevision = c.optString("hardware_revision", null), + installedVersion = c.optString("installed_version", null), + bootloaderVersion = c.optString("bootloader_version", null), + source = "detected", + lastCheckedAt = 0, + lastSuccessAt = 0, + ) + ) + } + return result + } +} diff --git a/src/os/pawlet/bgupd/hardware/FirmwareInstaller.kt b/src/os/pawlet/bgupd/hardware/FirmwareInstaller.kt new file mode 100644 index 0000000..39d8741 --- /dev/null +++ b/src/os/pawlet/bgupd/hardware/FirmwareInstaller.kt @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.bgupd.hardware + +import java.io.File + +/** + * A firmware installer handler compiled into this image. The server catalog + * names a handler per update (installer.handler), but the catalog is only a + * suggestion — an update is applied solely when its handler is (a) listed in + * the component's installer_handlers allowlist in the image-local manifest + * AND (b) registered here. Anything else fails closed. + */ +interface FirmwareInstaller { + /** Stable handler ID, e.g. "gt911_firmware". */ + val handlerId: String + + /** Transport this handler drives, e.g. "i2c", "usb", "can". */ + val transport: String + + /** + * Applies [payload] (already size- and checksum-verified) to [component]. + * Implementations own transport-level safety (bus serialization, power + * preconditions) and must be idempotent enough to survive a retry. + * + * @return null on success, or a human-readable failure message. + */ + fun install(component: HardwareComponentState, update: HardwareUpdateResponse, payload: File): String? +} + +/** + * Registry of handlers shipped in this build. Intentionally empty until real + * handlers land — with no registration every hardware update is rejected at + * the authorization step, which is the correct fail-closed default. + */ +object FirmwareInstallerRegistry { + + private val handlers = LinkedHashMap() + + fun register(installer: FirmwareInstaller) { + handlers[installer.handlerId] = installer + } + + fun get(handlerId: String): FirmwareInstaller? = handlers[handlerId] +} diff --git a/src/os/pawlet/bgupd/hardware/HardwareManifest.kt b/src/os/pawlet/bgupd/hardware/HardwareManifest.kt new file mode 100644 index 0000000..d96a947 --- /dev/null +++ b/src/os/pawlet/bgupd/hardware/HardwareManifest.kt @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.bgupd.hardware + +import android.os.SystemProperties +import android.util.Log +import org.json.JSONObject +import os.pawlet.bgupd.Constants +import java.io.File + +/** + * The image-local supported-hardware manifest (/system/etc/pawlet/ + * supported_hardware.json, published via ro.pawlet.hardware.manifest). + * + * This is the authoritative allowlist: the server catalog can only ever + * *offer* firmware; products, components, revisions, and installer handlers + * that this manifest does not list must be ignored. + */ +data class SupportedComponent( + val id: String, + val componentClass: String?, + val role: String?, + val support: String?, + /** Empty = the manifest does not constrain revisions for this component. */ + val supportedRevisions: List, + /** Handler IDs authorized by the image for this component. */ + val installerHandlers: List, +) + +data class SupportedProduct( + val id: String, + val manufacturer: String?, + val productClass: String?, + val support: String?, + val supportedRevisions: List, + val components: Map, +) + +class HardwareManifest private constructor( + val schemaVersion: Int, + val device: String?, + val buildVersion: String?, + val products: Map, +) { + fun findComponent(hardwareId: String, componentId: String): Pair? { + val product = products[hardwareId] ?: return null + val component = product.components[componentId] ?: return null + return product to component + } + + companion object { + const val PROP_MANIFEST_PATH = "ro.pawlet.hardware.manifest" + private const val DEFAULT_PATH = "/system/etc/pawlet/supported_hardware.json" + + /** Returns null when the image ships no manifest — hardware updating is then disabled. */ + fun load(): HardwareManifest? { + val path = SystemProperties.get(PROP_MANIFEST_PATH, DEFAULT_PATH) + val file = File(path) + if (!file.isFile) { + Log.i(Constants.TAG, "No hardware manifest at $path — hardware updates disabled") + return null + } + return try { + parse(file.readText()) + } catch (e: Exception) { + Log.e(Constants.TAG, "Unparseable hardware manifest at $path — hardware updates disabled", e) + null + } + } + + fun parse(body: String): HardwareManifest { + val json = JSONObject(body) + val products = LinkedHashMap() + val list = json.optJSONArray("supported_hardware") + if (list != null) { + for (i in 0 until list.length()) { + val p = list.getJSONObject(i) + val components = LinkedHashMap() + p.optJSONArray("components")?.let { comps -> + for (j in 0 until comps.length()) { + val c = comps.getJSONObject(j) + val id = c.getString("id") + components[id] = SupportedComponent( + id = id, + componentClass = c.optString("class", null), + role = c.optString("role", null), + support = c.optString("support", null), + supportedRevisions = c.optJSONArray("supported_revisions").toStringList(), + installerHandlers = c.optJSONArray("installer_handlers").toStringList(), + ) + } + } + val id = p.getString("id") + products[id] = SupportedProduct( + id = id, + manufacturer = p.optString("manufacturer", null), + productClass = p.optString("class", null), + support = p.optString("support", null), + supportedRevisions = p.optJSONArray("supported_revisions").toStringList(), + components = components, + ) + } + } + return HardwareManifest( + schemaVersion = json.optInt("schema_version", 1), + device = json.optString("device", null), + buildVersion = json.optString("build_version", null), + products = products, + ) + } + + private fun org.json.JSONArray?.toStringList(): List { + if (this == null) return emptyList() + return (0 until length()).map { getString(it) } + } + } +} diff --git a/src/os/pawlet/bgupd/hardware/HardwareModels.kt b/src/os/pawlet/bgupd/hardware/HardwareModels.kt new file mode 100644 index 0000000..b869dee --- /dev/null +++ b/src/os/pawlet/bgupd/hardware/HardwareModels.kt @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.bgupd.hardware + +/** + * One hardware component this device should keep firmware-current: either + * detected at runtime (device-tree scan) or explicitly configured. Persisted + * in the hardware_state table. + */ +data class HardwareComponentState( + val hardwareId: String, + val componentId: String, + /** null = revision unknown; revision-restricted updates then never apply (fail closed). */ + val hardwareRevision: String?, + /** null = installed firmware version unknown. */ + val installedVersion: String?, + val bootloaderVersion: String?, + /** "detected" or "configured" — how this row came to exist. */ + val source: String, + val lastCheckedAt: Long, + val lastSuccessAt: Long, +) + +/** Parsed response from ota.php?mode=hardware&target=update (HTTP 200 case). */ +data class HardwareUpdateResponse( + val manufacturer: String?, + val hardwareClass: String?, + val hardwareId: String, + val componentId: String, + val version: String, + val filename: String?, + val releaseId: String?, + val sizeBytes: Long, + val url: String, + val checksumSha256: String?, + val installerType: String?, + val installerHandler: String?, +) diff --git a/src/os/pawlet/bgupd/hardware/HardwareUpdateClient.kt b/src/os/pawlet/bgupd/hardware/HardwareUpdateClient.kt new file mode 100644 index 0000000..bfd8347 --- /dev/null +++ b/src/os/pawlet/bgupd/hardware/HardwareUpdateClient.kt @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.bgupd.hardware + +import android.net.Uri +import android.os.SystemProperties +import org.json.JSONObject +import java.io.BufferedReader +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +/** + * Client for the hardware side of ota.php: per-component update checks + * (mode=hardware&target=update). + * + * The server is fail-closed: an update restricted by hardware_revisions is + * not offered when the request omits hardware_revision, so optional identity + * parameters are only sent when actually known. + */ +class HardwareUpdateClient(private val updateUrl: String) { + + /** Returns null when the server reports no applicable update (HTTP 204). */ + fun checkComponent(state: HardwareComponentState): HardwareUpdateResponse? { + val builder = Uri.parse(updateUrl).buildUpon() + .appendQueryParameter("device", SystemProperties.get("ro.product.device", "")) + .appendQueryParameter("pawlet_version", SystemProperties.get("ro.pawlet.build.version", "")) + .appendQueryParameter("hardware_id", state.hardwareId) + .appendQueryParameter("component_id", state.componentId) + state.hardwareRevision?.let { builder.appendQueryParameter("hardware_revision", it) } + state.installedVersion?.let { builder.appendQueryParameter("current_version", it) } + state.bootloaderVersion?.let { builder.appendQueryParameter("bootloader_version", it) } + + val connection = URL(builder.build().toString()).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 15_000 + connection.readTimeout = 15_000 + connection.requestMethod = "GET" + if (connection.responseCode == HttpURLConnection.HTTP_NO_CONTENT) return null + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw IOException("HTTP ${connection.responseCode} from hardware update check") + } + val body = connection.inputStream.bufferedReader().use(BufferedReader::readText) + return parseUpdate(body) + } finally { + connection.disconnect() + } + } + + private fun parseUpdate(body: String): HardwareUpdateResponse { + val json = JSONObject(body) + val hardware = json.getJSONObject("hardware") + val component = json.getJSONObject("component") + val update = json.getJSONObject("update") + val installer = update.optJSONObject("installer") + return HardwareUpdateResponse( + manufacturer = json.optString("manufacturer", null), + hardwareClass = json.optString("hardware_class", null), + hardwareId = hardware.getString("id"), + componentId = component.getString("id"), + version = update.getString("version"), + filename = update.optString("filename", null), + releaseId = update.optString("id", null), + sizeBytes = update.optLong("size_bytes", -1L), + url = update.getString("url"), + checksumSha256 = update.optString("checksum_sha256", null), + installerType = installer?.optString("type", null), + installerHandler = installer?.optString("handler", null), + ) + } +} diff --git a/src/os/pawlet/bgupd/hardware/HardwareUpdateExecutor.kt b/src/os/pawlet/bgupd/hardware/HardwareUpdateExecutor.kt new file mode 100644 index 0000000..ffba37d --- /dev/null +++ b/src/os/pawlet/bgupd/hardware/HardwareUpdateExecutor.kt @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: oxmc / PawletOS + * SPDX-License-Identifier: Apache-2.0 + */ +package os.pawlet.bgupd.hardware + +import android.content.Context +import android.os.RemoteException +import android.util.Log +import os.pawlet.bgupd.Constants +import os.pawlet.bgupd.db.ComponentRepository +import pawletos.bgupd.IUpdateCallback +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +/** + * The hardware-update side of bg-upd: keeps firmware on detected/configured + * hardware components current, per the PawletOS update-server protocol. + * + * Eligibility chain (every step fails closed): + * component detected/configured + * -> product+component IDs in the image-local supported-hardware manifest + * -> detected revision allowed by that manifest (when it constrains) + * -> server offers a compatible update (server re-checks compat too) + * -> installer handler authorized by the manifest AND registered in this build + * -> payload size and SHA-256 verified + * -> handler applies it + */ +class HardwareUpdateExecutor(private val context: Context) { + + private val repository = ComponentRepository(context) + + /** + * Full pass: merge the SetupWizard-written inventory into persistent + * state, then check/apply for every known component. + * @param updateUrl the hardware update-check endpoint from the top-level manifest + */ + fun runAll(updateUrl: String, triggerType: String, callback: IUpdateCallback?) { + val manifest = HardwareManifest.load() ?: return + + for (detected in DetectedHardwareStore.read()) { + repository.mergeHardwareComponent(detected) + } + + val client = HardwareUpdateClient(updateUrl) + for (state in repository.getHardwareComponents()) { + runComponent(manifest, client, state, triggerType, callback) + } + } + + private fun runComponent( + manifest: HardwareManifest, + client: HardwareUpdateClient, + state: HardwareComponentState, + triggerType: String, + callback: IUpdateCallback?, + ) { + val now = System.currentTimeMillis() + try { + val supported = manifest.findComponent(state.hardwareId, state.componentId) + if (supported == null) { + Log.i(Constants.TAG, "hw ${state.componentId}: not in image manifest — skipping") + return + } + val (product, component) = supported + if (!revisionAllowed(product.supportedRevisions, state.hardwareRevision) || + !revisionAllowed(component.supportedRevisions, state.hardwareRevision) + ) { + Log.i(Constants.TAG, "hw ${state.componentId}: revision ${state.hardwareRevision} " + + "not supported by this image — skipping") + return + } + + progress(state.componentId, 10, "Checking firmware", callback) + val update = client.checkComponent(state) + repository.touchHardwareChecked(state.hardwareId, state.componentId, now) + if (update == null || update.version == state.installedVersion) { + return + } + + // Server response must be about the component we asked for. + if (update.hardwareId != state.hardwareId || update.componentId != state.componentId) { + fail(state, Constants.ErrorCode.HW_NOT_AUTHORIZED, + "Server answered for ${update.componentId}, asked about ${state.componentId}", + triggerType, callback) + return + } + + val handlerId = update.installerHandler + if (handlerId.isNullOrEmpty() || handlerId !in component.installerHandlers) { + fail(state, Constants.ErrorCode.HW_NOT_AUTHORIZED, + "Handler '${handlerId ?: "?"}' not authorized for ${state.componentId} " + + "by the image manifest", triggerType, callback) + return + } + val handler = FirmwareInstallerRegistry.get(handlerId) + if (handler == null || (update.installerType != null && handler.transport != update.installerType)) { + fail(state, Constants.ErrorCode.HW_NOT_AUTHORIZED, + "Handler '$handlerId' (${update.installerType ?: "?"}) not available in this build", + triggerType, callback) + return + } + + val checksum = update.checksumSha256 + if (checksum.isNullOrEmpty()) { + fail(state, Constants.ErrorCode.HW_CHECKSUM_MISMATCH, + "Update for ${state.componentId} has no checksum_sha256 — refusing", + triggerType, callback) + return + } + + progress(state.componentId, 30, "Downloading firmware ${update.version}", callback) + val payload = download(state.componentId, update) + try { + progress(state.componentId, 60, "Verifying firmware", callback) + if (update.sizeBytes >= 0 && payload.length() != update.sizeBytes) { + fail(state, Constants.ErrorCode.HW_CHECKSUM_MISMATCH, + "Size mismatch for ${state.componentId}: got ${payload.length()}, " + + "expected ${update.sizeBytes}", triggerType, callback) + return + } + if (!sha256(payload).equals(checksum, ignoreCase = true)) { + fail(state, Constants.ErrorCode.HW_CHECKSUM_MISMATCH, + "Checksum mismatch for ${state.componentId}", triggerType, callback) + return + } + + progress(state.componentId, 80, "Flashing via $handlerId", callback) + val error = handler.install(state, update, payload) + if (error != null) { + fail(state, Constants.ErrorCode.HW_INSTALL_FAILED, error, triggerType, callback) + return + } + } finally { + payload.delete() + } + + repository.setHardwareInstalledVersion(state.hardwareId, state.componentId, update.version, now) + repository.insertUpdateLog( + state.componentId, handlerId, state.installedVersion, update.version, + triggerType, "success", null, now, + ) + try { + callback?.onSuccess(state.componentId, update.version) + } catch (e: RemoteException) { + Log.w(Constants.TAG, "Callback died after hw success for ${state.componentId}", e) + } + } catch (e: Exception) { + Log.e(Constants.TAG, "hardware update for ${state.componentId} failed", e) + fail(state, Constants.ErrorCode.UNKNOWN, e.message ?: e.javaClass.simpleName, + triggerType, callback) + } + } + + /** Empty constraint list = unconstrained; a constraint with unknown revision fails closed. */ + private fun revisionAllowed(supported: List, revision: String?): Boolean = + supported.isEmpty() || (revision != null && revision in supported) + + private fun download(componentId: String, update: HardwareUpdateResponse): File { + val outFile = File(context.cacheDir, "hw-$componentId-${update.version}.bin") + val connection = URL(update.url).openConnection() as HttpURLConnection + connection.connectTimeout = 15_000 + connection.readTimeout = 60_000 + try { + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw java.io.IOException("HTTP ${connection.responseCode} downloading ${update.url}") + } + connection.inputStream.use { input -> + outFile.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() + } + return outFile + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun progress(componentId: String, percent: Int, message: String, callback: IUpdateCallback?) { + try { + callback?.onProgress(componentId, percent, message) + } catch (e: RemoteException) { + Log.w(Constants.TAG, "Callback died mid-hw-update for $componentId", e) + } + } + + private fun fail( + state: HardwareComponentState, + errorCode: Int, + message: String, + triggerType: String, + callback: IUpdateCallback?, + ) { + Log.w(Constants.TAG, "hw ${state.componentId}: $message") + repository.insertUpdateLog( + state.componentId, null, state.installedVersion, null, triggerType, "failure", + message, System.currentTimeMillis(), + ) + try { + callback?.onFailure(state.componentId, errorCode, message) + } catch (e: RemoteException) { + Log.w(Constants.TAG, "Callback died after hw failure for ${state.componentId}", e) + } + } +} diff --git a/src/os/pawlet/bgupd/job/BgUpdJobService.kt b/src/os/pawlet/bgupd/job/BgUpdJobService.kt index 6bef6c6..07aae9a 100644 --- a/src/os/pawlet/bgupd/job/BgUpdJobService.kt +++ b/src/os/pawlet/bgupd/job/BgUpdJobService.kt @@ -9,6 +9,7 @@ import android.app.job.JobService import android.util.Log import os.pawlet.bgupd.Constants import os.pawlet.bgupd.UpdateExecutor +import os.pawlet.bgupd.hardware.HardwareUpdateExecutor import os.pawlet.bgupd.manifest.ManifestClient import java.util.concurrent.Executors @@ -26,12 +27,17 @@ class BgUpdJobService : JobService() { workers.execute { val executor = UpdateExecutor(this) try { - val componentIds = ManifestClient().fetchManifest().keys - for (componentId in componentIds) { + val manifest = ManifestClient().fetchManifest() + for (componentId in manifest.keys.filter { it in Constants.SUPPORTED_COMPONENTS }) { if (!running) break executor.runComponent(componentId, null, Constants.TriggerType.SCHEDULED, null) } if (running) executor.reconcileWebView() + if (running) { + manifest[Constants.COMPONENT_HARDWARE]?.let { hw -> + HardwareUpdateExecutor(this).runAll(hw.url, Constants.TriggerType.SCHEDULED, null) + } + } } catch (e: Exception) { Log.e(Constants.TAG, "Scheduled check failed", e) } finally { diff --git a/src/os/pawlet/bgupd/manifest/ManifestClient.kt b/src/os/pawlet/bgupd/manifest/ManifestClient.kt index 50008dc..b26db4b 100644 --- a/src/os/pawlet/bgupd/manifest/ManifestClient.kt +++ b/src/os/pawlet/bgupd/manifest/ManifestClient.kt @@ -66,9 +66,9 @@ class ManifestClient { provider = json.optString("provider", null), version = json.getString("version"), downloadUrl = json.getString("download_url"), - packageName = json.getString("package_name"), - signatureSha256 = json.getString("signature_sha256"), - sizeBytes = json.getLong("size_bytes"), + packageName = json.optString("package_name", null), + signatureSha256 = json.optString("signature_sha256", null), + sizeBytes = json.optLong("size_bytes", -1L), ) } finally { connection.disconnect() diff --git a/src/os/pawlet/bgupd/manifest/ManifestModels.kt b/src/os/pawlet/bgupd/manifest/ManifestModels.kt index cade12b..d2935d5 100644 --- a/src/os/pawlet/bgupd/manifest/ManifestModels.kt +++ b/src/os/pawlet/bgupd/manifest/ManifestModels.kt @@ -12,12 +12,18 @@ data class ComponentConfig( val defaultProvider: String?, ) -/** Parsed response body from a component's own endpoint (e.g. webview.php). */ +/** + * Parsed response body from a component's update-check endpoint + * (ota.php?mode=webview&info=1&...). package_name and signature_sha256 are + * optional in the catalog (e.g. the chromite provider omits them today); + * UpdateExecutor fails closed when they are missing rather than installing + * an unverifiable APK. + */ data class ComponentUpdateResponse( val provider: String?, val version: String, val downloadUrl: String, - val packageName: String, - val signatureSha256: String, + val packageName: String?, + val signatureSha256: String?, val sizeBytes: Long, )