2 Commits

Author SHA1 Message Date
oxmc 10af1ec246 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.
2026-07-15 13:50:08 -07:00
oxmc 413fa8888b aidl: import android.os.Bundle in IBgUpdaterService 2026-07-15 13:06:11 -07:00
16 changed files with 752 additions and 27 deletions
+2 -1
View File
@@ -6,10 +6,11 @@ package pawletos.bgupd;
import pawletos.bgupd.IUpdateCallback; import pawletos.bgupd.IUpdateCallback;
import pawletos.bgupd.ComponentStatus; import pawletos.bgupd.ComponentStatus;
import pawletos.bgupd.UpdateParams;
interface IBgUpdaterService { interface IBgUpdaterService {
// Immediate, user-triggered (e.g. settings toggle changing WebView provider) // 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 // Query current state without forcing a network check
ComponentStatus getComponentStatus(String componentId); 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;
}
+12 -4
View File
@@ -6,12 +6,13 @@ package os.pawlet.bgupd
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import os.pawlet.bgupd.hardware.HardwareUpdateExecutor
import os.pawlet.bgupd.manifest.ManifestClient import os.pawlet.bgupd.manifest.ManifestClient
import pawletos.bgupd.ComponentStatus import pawletos.bgupd.ComponentStatus
import pawletos.bgupd.IBgUpdaterService import pawletos.bgupd.IBgUpdaterService
import pawletos.bgupd.IUpdateCallback import pawletos.bgupd.IUpdateCallback
import pawletos.bgupd.UpdateParams
import java.util.concurrent.Executors import java.util.concurrent.Executors
/** /**
@@ -36,10 +37,10 @@ class BgUpdaterService : Service() {
private val binder = object : IBgUpdaterService.Stub() { private val binder = object : IBgUpdaterService.Stub() {
override fun requestComponentUpdate( override fun requestComponentUpdate(
componentId: String, componentId: String,
params: Bundle?, params: UpdateParams?,
callback: IUpdateCallback?, callback: IUpdateCallback?,
) { ) {
val provider = params?.getString("provider") val provider = params?.provider
workers.execute { workers.execute {
executor.runComponent(componentId, provider, Constants.TriggerType.USER_TOGGLE, callback) executor.runComponent(componentId, provider, Constants.TriggerType.USER_TOGGLE, callback)
} }
@@ -65,9 +66,16 @@ class BgUpdaterService : Service() {
override fun forceCheckAll(callback: IUpdateCallback?) { override fun forceCheckAll(callback: IUpdateCallback?) {
workers.execute { workers.execute {
ManifestClient().fetchManifest().keys.forEach { componentId -> val manifest = ManifestClient().fetchManifest()
manifest.keys
.filter { it in Constants.SUPPORTED_COMPONENTS }
.forEach { componentId ->
executor.runComponent(componentId, null, Constants.TriggerType.MANUAL_CHECK, callback) 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 TAG = "BgUpd"
const val COMPONENT_WEBVIEW = "webview" 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 { object TriggerType {
const val SCHEDULED = "scheduled" const val SCHEDULED = "scheduled"
@@ -22,6 +27,9 @@ object Constants {
const val SIGNATURE_MISMATCH = 3 const val SIGNATURE_MISMATCH = 3
const val INSTALL_FAILED = 4 const val INSTALL_FAILED = 4
const val WEBVIEW_SWITCH_FAILED = 5 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 const val UNKNOWN = 99
} }
+31 -8
View File
@@ -68,19 +68,30 @@ class UpdateExecutor(private val context: Context) {
return 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) progress(componentId, 30, "Downloading ${update.version}", callback)
val apkFile = download(componentId, update) val apkFile = download(componentId, update)
progress(componentId, 60, "Verifying signature", callback) progress(componentId, 60, "Verifying signature", callback)
if (!SignatureVerifier.verify(context.packageManager, apkFile, update.signatureSha256)) { if (!SignatureVerifier.verify(context.packageManager, apkFile, signatureSha256)) {
apkFile.delete() apkFile.delete()
fail(componentId, provider, Constants.ErrorCode.SIGNATURE_MISMATCH, fail(componentId, provider, Constants.ErrorCode.SIGNATURE_MISMATCH,
"Signature mismatch for ${update.packageName}", triggerType, callback) "Signature mismatch for $packageName", triggerType, callback)
return return
} }
progress(componentId, 80, "Installing", callback) progress(componentId, 80, "Installing", callback)
val result = installer.installSilently(apkFile, update.packageName) val result = installer.installSilently(apkFile, packageName)
apkFile.delete() apkFile.delete()
if (!result.success) { if (!result.success) {
fail(componentId, provider, Constants.ErrorCode.INSTALL_FAILED, fail(componentId, provider, Constants.ErrorCode.INSTALL_FAILED,
@@ -88,7 +99,7 @@ class UpdateExecutor(private val context: Context) {
return return
} }
onInstallSuccess(config, update, provider) onInstallSuccess(config, update, provider, packageName, signatureSha256)
succeed(componentId, update.version, triggerType, update.provider ?: provider, callback) succeed(componentId, update.version, triggerType, update.provider ?: provider, callback)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(Constants.TAG, "runComponent($componentId) failed", e) Log.e(Constants.TAG, "runComponent($componentId) failed", e)
@@ -101,6 +112,8 @@ class UpdateExecutor(private val context: Context) {
config: ComponentConfig, config: ComponentConfig,
update: ComponentUpdateResponse, update: ComponentUpdateResponse,
requestedProvider: String?, requestedProvider: String?,
packageName: String,
signatureSha256: String,
) { ) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val providerId = update.provider ?: requestedProvider val providerId = update.provider ?: requestedProvider
@@ -117,15 +130,15 @@ class UpdateExecutor(private val context: Context) {
ProviderState( ProviderState(
componentId = config.componentId, componentId = config.componentId,
providerId = providerId, providerId = providerId,
packageName = update.packageName, packageName = packageName,
installedVersion = update.version, installedVersion = update.version,
enabled = true, enabled = true,
signatureHash = update.signatureSha256, signatureHash = signatureSha256,
installedAt = now, installedAt = now,
) )
) )
if (config.componentId == Constants.COMPONENT_WEBVIEW) { 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 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?) { private fun progress(componentId: String, percent: Int, message: String, callback: IUpdateCallback?) {
try { try {
+22 -5
View File
@@ -13,11 +13,12 @@ class BgUpdDbHelper(context: Context) :
companion object { companion object {
const val DATABASE_NAME = "bgupd.db" const val DATABASE_NAME = "bgupd.db"
const val DATABASE_VERSION = 1 const val DATABASE_VERSION = 2
const val TABLE_COMPONENTS = "components" const val TABLE_COMPONENTS = "components"
const val TABLE_PROVIDER_STATE = "provider_state" const val TABLE_PROVIDER_STATE = "provider_state"
const val TABLE_UPDATE_LOG = "update_log" const val TABLE_UPDATE_LOG = "update_log"
const val TABLE_HARDWARE_STATE = "hardware_state"
private const val SQL_CREATE_COMPONENTS = """ private const val SQL_CREATE_COMPONENTS = """
CREATE TABLE $TABLE_COMPONENTS ( CREATE TABLE $TABLE_COMPONENTS (
@@ -57,18 +58,34 @@ class BgUpdDbHelper(context: Context) :
timestamp INTEGER NOT NULL 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) { override fun onCreate(db: SQLiteDatabase) {
db.execSQL(SQL_CREATE_COMPONENTS) db.execSQL(SQL_CREATE_COMPONENTS)
db.execSQL(SQL_CREATE_PROVIDER_STATE) db.execSQL(SQL_CREATE_PROVIDER_STATE)
db.execSQL(SQL_CREATE_UPDATE_LOG) db.execSQL(SQL_CREATE_UPDATE_LOG)
db.execSQL(SQL_CREATE_HARDWARE_STATE)
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS $TABLE_UPDATE_LOG") if (oldVersion < 2) {
db.execSQL("DROP TABLE IF EXISTS $TABLE_PROVIDER_STATE") db.execSQL(SQL_CREATE_HARDWARE_STATE)
db.execSQL("DROP TABLE IF EXISTS $TABLE_COMPONENTS") }
onCreate(db)
} }
} }
@@ -7,6 +7,7 @@ package os.pawlet.bgupd.db
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import os.pawlet.bgupd.hardware.HardwareComponentState
import os.pawlet.bgupd.model.ComponentState import os.pawlet.bgupd.model.ComponentState
import os.pawlet.bgupd.model.ProviderState import os.pawlet.bgupd.model.ProviderState
@@ -129,6 +130,78 @@ class ComponentRepository(context: Context) {
fun getProviderState(componentId: String, providerId: String): ProviderState? = fun getProviderState(componentId: String, providerId: String): ProviderState? =
getProviderStates(componentId).find { it.providerId == providerId } 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( fun insertUpdateLog(
componentId: String, componentId: String,
providerId: 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 android.util.Log
import os.pawlet.bgupd.Constants import os.pawlet.bgupd.Constants
import os.pawlet.bgupd.UpdateExecutor import os.pawlet.bgupd.UpdateExecutor
import os.pawlet.bgupd.hardware.HardwareUpdateExecutor
import os.pawlet.bgupd.manifest.ManifestClient import os.pawlet.bgupd.manifest.ManifestClient
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -26,12 +27,17 @@ class BgUpdJobService : JobService() {
workers.execute { workers.execute {
val executor = UpdateExecutor(this) val executor = UpdateExecutor(this)
try { try {
val componentIds = ManifestClient().fetchManifest().keys val manifest = ManifestClient().fetchManifest()
for (componentId in componentIds) { for (componentId in manifest.keys.filter { it in Constants.SUPPORTED_COMPONENTS }) {
if (!running) break if (!running) break
executor.runComponent(componentId, null, Constants.TriggerType.SCHEDULED, null) executor.runComponent(componentId, null, Constants.TriggerType.SCHEDULED, null)
} }
if (running) executor.reconcileWebView() if (running) executor.reconcileWebView()
if (running) {
manifest[Constants.COMPONENT_HARDWARE]?.let { hw ->
HardwareUpdateExecutor(this).runAll(hw.url, Constants.TriggerType.SCHEDULED, null)
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(Constants.TAG, "Scheduled check failed", e) Log.e(Constants.TAG, "Scheduled check failed", e)
} finally { } finally {
@@ -66,9 +66,9 @@ class ManifestClient {
provider = json.optString("provider", null), provider = json.optString("provider", null),
version = json.getString("version"), version = json.getString("version"),
downloadUrl = json.getString("download_url"), downloadUrl = json.getString("download_url"),
packageName = json.getString("package_name"), packageName = json.optString("package_name", null),
signatureSha256 = json.getString("signature_sha256"), signatureSha256 = json.optString("signature_sha256", null),
sizeBytes = json.getLong("size_bytes"), sizeBytes = json.optLong("size_bytes", -1L),
) )
} finally { } finally {
connection.disconnect() connection.disconnect()
@@ -12,12 +12,18 @@ data class ComponentConfig(
val defaultProvider: String?, 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( data class ComponentUpdateResponse(
val provider: String?, val provider: String?,
val version: String, val version: String,
val downloadUrl: String, val downloadUrl: String,
val packageName: String, val packageName: String?,
val signatureSha256: String, val signatureSha256: String?,
val sizeBytes: Long, val sizeBytes: Long,
) )