Align with the new ota.php protocol; add the hardware-update daemon

- AIDL: android.os.Bundle cannot be imported by a structured aidl_interface
  (the -N include path has no framework types); requestComponentUpdate now
  takes a structured pawletos.bgupd.UpdateParams parcelable instead.
- webview: map ABI strings to the catalog's short arch keys (arm64-v8a ->
  arm64); package_name/signature_sha256 are optional in the catalog (e.g.
  chromite) — parse them as nullable and refuse to install without them.
- Only run manifest entries the APK pipeline owns (webview); the hardware
  entry is handled by the new HardwareUpdateExecutor.
- hardware/: firmware-update daemon per the PawletOS update-server protocol.
  Image-local supported_hardware.json (ro.pawlet.hardware.manifest) is the
  authoritative allowlist; mode=hardware&target=update checks send optional
  identity params only when known; eligibility chain fails closed at every
  step (manifest allowlist -> revision -> handler authorization -> size +
  SHA-256). FirmwareInstallerRegistry ships empty so nothing installs until
  real handlers land. Detected hardware comes from the SetupWizard-written
  /data/pawlet/detected_hardware.json (schema in DetectedHardwareStore);
  bg-upd does no probing itself.
- db v2: hardware_state table; upgrades are now additive instead of
  drop-and-recreate.
This commit is contained in:
oxmc
2026-07-15 13:50:08 -07:00
parent 413fa8888b
commit 10af1ec246
16 changed files with 752 additions and 28 deletions
+2 -2
View File
@@ -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);
+14
View File
@@ -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;
}
+13 -5
View File
@@ -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)
}
}
}
+8
View File
@@ -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
}
+31 -8
View File
@@ -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 {
+22 -5
View File
@@ -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)
}
}
}
@@ -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<HardwareComponentState> {
val result = mutableListOf<HardwareComponentState>()
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?,
@@ -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<HardwareComponentState> {
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<HardwareComponentState> {
val json = JSONObject(body)
val components = json.optJSONArray("components") ?: return emptyList()
val result = mutableListOf<HardwareComponentState>()
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
}
}
@@ -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<String, FirmwareInstaller>()
fun register(installer: FirmwareInstaller) {
handlers[installer.handlerId] = installer
}
fun get(handlerId: String): FirmwareInstaller? = handlers[handlerId]
}
@@ -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<String>,
/** Handler IDs authorized by the image for this component. */
val installerHandlers: List<String>,
)
data class SupportedProduct(
val id: String,
val manufacturer: String?,
val productClass: String?,
val support: String?,
val supportedRevisions: List<String>,
val components: Map<String, SupportedComponent>,
)
class HardwareManifest private constructor(
val schemaVersion: Int,
val device: String?,
val buildVersion: String?,
val products: Map<String, SupportedProduct>,
) {
fun findComponent(hardwareId: String, componentId: String): Pair<SupportedProduct, SupportedComponent>? {
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<String, SupportedProduct>()
val list = json.optJSONArray("supported_hardware")
if (list != null) {
for (i in 0 until list.length()) {
val p = list.getJSONObject(i)
val components = LinkedHashMap<String, SupportedComponent>()
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<String> {
if (this == null) return emptyList()
return (0 until length()).map { getString(it) }
}
}
}
@@ -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?,
)
@@ -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),
)
}
}
@@ -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<String>, 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)
}
}
}
+8 -2
View File
@@ -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 {
@@ -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()
@@ -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,
)