Initial import of bg-upd, PawletOS's background component updater

Polls a manifest to discover per-component update endpoints (currently
just webview) and silently installs/switches providers without a full
system OTA.
This commit is contained in:
2026-07-09 01:23:46 -07:00
commit 54f44d3417
20 changed files with 1203 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
//
// Copyright (C) 2026 oxmc / PawletOS
//
// SPDX-License-Identifier: Apache-2.0
//
// Shared with app_PawletSettings so the settings addon can bind IBgUpdaterService
// without duplicating the AIDL sources across two module trees.
aidl_interface {
name: "pawletos.bgupd-aidl",
unstable: true,
local_include_dir: "aidl",
srcs: ["aidl/pawletos/bgupd/*.aidl"],
}
android_app {
name: "BgUpd",
srcs: ["src/**/*.kt"],
resource_dirs: ["res"],
manifest: "AndroidManifest.xml",
// Needed for the hidden android.webkit.IWebViewUpdateService binder.
platform_apis: true,
privileged: true,
privapp_allowlist: "os.pawlet.bgupd.xml",
certificate: "platform",
system_ext_specific: true,
static_libs: [
"androidx.core_core-ktx",
"pawletos.bgupd-aidl-java",
],
optimize: {
enabled: false,
},
}
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="os.pawlet.bgupd"
android:versionCode="1">
<uses-permission android:name="android.permission.INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.DELETE_PACKAGES" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Disable a superseded WebView/launcher provider without uninstalling it -->
<uses-permission android:name="android.permission.CHANGE_COMPONENT_ENABLED_STATE" />
<!-- Required by IWebViewUpdateService#changeProviderAndSetting -->
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
<!-- Signature-level permission so only platform-signed callers (the PawletOS
settings addon) can bind IBgUpdaterService. -->
<permission
android:name="pawletos.bgupd.permission.BIND_UPDATER"
android:protectionLevel="signature" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:persistent="false"
android:supportsRtl="true">
<service
android:name=".BgUpdaterService"
android:exported="true"
android:permission="pawletos.bgupd.permission.BIND_UPDATER">
<intent-filter>
<action android:name="pawletos.bgupd.action.BIND" />
</intent-filter>
</service>
<service
android:name=".job.BgUpdJobService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" />
<receiver android:name=".BootReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
+14
View File
@@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package pawletos.bgupd;
parcelable ComponentStatus {
String componentId;
String activeProvider;
String installedVersion;
long lastCheckedTimestamp;
boolean updateAvailable;
List<String> disabledProvidersPresent;
}
@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package pawletos.bgupd;
import pawletos.bgupd.IUpdateCallback;
import pawletos.bgupd.ComponentStatus;
interface IBgUpdaterService {
// Immediate, user-triggered (e.g. settings toggle changing WebView provider)
void requestComponentUpdate(String componentId, in Bundle params, IUpdateCallback callback);
// Query current state without forcing a network check
ComponentStatus getComponentStatus(String componentId);
List<ComponentStatus> getAllComponentStatus();
// Explicit user actions from the PawletOS settings addon
void setPreferredProvider(String componentId, String providerId);
void deleteDisabledProvider(String componentId, String providerId, IUpdateCallback callback);
// Manual "check now" button, separate from the scheduled job
void forceCheckAll(IUpdateCallback callback);
}
+11
View File
@@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package pawletos.bgupd;
oneway interface IUpdateCallback {
void onProgress(String componentId, int percent, String message);
void onSuccess(String componentId, String newVersion);
void onFailure(String componentId, int errorCode, String message);
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
-->
<permissions>
<privapp-permissions package="os.pawlet.bgupd">
<permission name="android.permission.INSTALL_PACKAGES"/>
<permission name="android.permission.DELETE_PACKAGES"/>
<permission name="android.permission.CHANGE_COMPONENT_ENABLED_STATE"/>
<permission name="android.permission.WRITE_SECURE_SETTINGS"/>
</privapp-permissions>
</permissions>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
-->
<resources>
<string name="app_name">PawletOS Updater</string>
</resources>
+76
View File
@@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd
import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import os.pawlet.bgupd.manifest.ManifestClient
import pawletos.bgupd.ComponentStatus
import pawletos.bgupd.IBgUpdaterService
import pawletos.bgupd.IUpdateCallback
import java.util.concurrent.Executors
/**
* Bound entry point for the immediate (user-triggered) update path — see UpdateExecutor
* for the install/verify logic shared with the scheduled JobScheduler path.
*/
class BgUpdaterService : Service() {
private lateinit var executor: UpdateExecutor
private val workers = Executors.newCachedThreadPool()
override fun onCreate() {
super.onCreate()
executor = UpdateExecutor(this)
}
override fun onDestroy() {
workers.shutdown()
super.onDestroy()
}
private val binder = object : IBgUpdaterService.Stub() {
override fun requestComponentUpdate(
componentId: String,
params: Bundle?,
callback: IUpdateCallback?,
) {
val provider = params?.getString("provider")
workers.execute {
executor.runComponent(componentId, provider, Constants.TriggerType.USER_TOGGLE, callback)
}
}
override fun getComponentStatus(componentId: String): ComponentStatus? =
executor.getComponentStatus(componentId)
override fun getAllComponentStatus(): List<ComponentStatus> =
executor.getAllComponentStatus()
override fun setPreferredProvider(componentId: String, providerId: String) {
workers.execute { executor.setPreferredProvider(componentId, providerId) }
}
override fun deleteDisabledProvider(
componentId: String,
providerId: String,
callback: IUpdateCallback?,
) {
workers.execute { executor.deleteDisabledProvider(componentId, providerId, callback) }
}
override fun forceCheckAll(callback: IUpdateCallback?) {
workers.execute {
ManifestClient().fetchManifest().keys.forEach { componentId ->
executor.runComponent(componentId, null, Constants.TriggerType.MANUAL_CHECK, callback)
}
}
}
}
override fun onBind(intent: Intent?): IBinder = binder
}
+28
View File
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import os.pawlet.bgupd.job.BgUpdJobService
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val jobScheduler = context.getSystemService(JobScheduler::class.java)
val jobInfo = JobInfo.Builder(
Constants.JOB_ID_PERIODIC_CHECK,
ComponentName(context, BgUpdJobService::class.java),
)
.setPeriodic(Constants.PERIODIC_CHECK_INTERVAL_MS)
.setRequiresBatteryNotLow(true)
.setPersisted(true)
.build()
jobScheduler.schedule(jobInfo)
}
}
+30
View File
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd
object Constants {
const val MANIFEST_URL = "https://oxmc.me/apis/aosp/ota.php?mode=manifest"
const val TAG = "BgUpd"
const val COMPONENT_WEBVIEW = "webview"
object TriggerType {
const val SCHEDULED = "scheduled"
const val USER_TOGGLE = "user_toggle"
const val MANUAL_CHECK = "manual_check"
}
object ErrorCode {
const val NETWORK = 1
const val NO_SUCH_COMPONENT = 2
const val SIGNATURE_MISMATCH = 3
const val INSTALL_FAILED = 4
const val WEBVIEW_SWITCH_FAILED = 5
const val UNKNOWN = 99
}
const val JOB_ID_PERIODIC_CHECK = 1001
val PERIODIC_CHECK_INTERVAL_MS: Long = 12 * 60 * 60 * 1000L // 12h, JobScheduler enforces a 15min floor anyway
}
+329
View File
@@ -0,0 +1,329 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.RemoteException
import android.util.Log
import os.pawlet.bgupd.db.ComponentRepository
import os.pawlet.bgupd.install.PackageInstallerHelper
import os.pawlet.bgupd.install.SignatureVerifier
import os.pawlet.bgupd.manifest.ComponentConfig
import os.pawlet.bgupd.manifest.ManifestClient
import os.pawlet.bgupd.manifest.ComponentUpdateResponse
import os.pawlet.bgupd.model.ComponentState
import os.pawlet.bgupd.model.ProviderState
import os.pawlet.bgupd.webview.WebViewProviderSwitcher
import pawletos.bgupd.ComponentStatus
import pawletos.bgupd.IUpdateCallback
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Single install/verify pipeline shared by the immediate (AIDL, user-triggered)
* path and the scheduled JobScheduler path — see class docs on BgUpdaterService
* and BgUpdJobService for how each funnels in here.
*/
class UpdateExecutor(private val context: Context) {
private val manifestClient = ManifestClient()
private val repository = ComponentRepository(context)
private val installer = PackageInstallerHelper(context)
/** @param requestedProvider only meaningful for multi-provider components (e.g. webview) */
fun runComponent(
componentId: String,
requestedProvider: String?,
triggerType: String,
callback: IUpdateCallback?,
) {
val now = System.currentTimeMillis()
try {
val manifest = manifestClient.fetchManifest()
val config = manifest[componentId]
if (config == null) {
fail(componentId, null, Constants.ErrorCode.NO_SUCH_COMPONENT,
"Unknown component: $componentId", triggerType, callback)
return
}
val provider = requestedProvider
?: repository.getComponent(componentId)?.activeProvider
?: config.defaultProvider
val currentVersion = repository.getComponent(componentId)?.activeVersion
progress(componentId, 10, "Checking for updates", callback)
val update = manifestClient.fetchComponentUpdate(
config, arch(), provider, currentVersion
)
repository.touchLastChecked(componentId, now)
if (update == null) {
succeed(componentId, currentVersion, triggerType, provider, 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)) {
apkFile.delete()
fail(componentId, provider, Constants.ErrorCode.SIGNATURE_MISMATCH,
"Signature mismatch for ${update.packageName}", triggerType, callback)
return
}
progress(componentId, 80, "Installing", callback)
val result = installer.installSilently(apkFile, update.packageName)
apkFile.delete()
if (!result.success) {
fail(componentId, provider, Constants.ErrorCode.INSTALL_FAILED,
result.message ?: "Install failed", triggerType, callback)
return
}
onInstallSuccess(config, update, provider)
succeed(componentId, update.version, triggerType, update.provider ?: provider, callback)
} catch (e: Exception) {
Log.e(Constants.TAG, "runComponent($componentId) failed", e)
fail(componentId, requestedProvider, Constants.ErrorCode.UNKNOWN,
e.message ?: e.javaClass.simpleName, triggerType, callback)
}
}
private fun onInstallSuccess(
config: ComponentConfig,
update: ComponentUpdateResponse,
requestedProvider: String?,
) {
val now = System.currentTimeMillis()
val providerId = update.provider ?: requestedProvider
if (providerId != null) {
val previous = repository.getComponent(config.componentId)?.activeProvider
if (previous != null && previous != providerId) {
repository.getProviderState(config.componentId, previous)?.let { prevState ->
disableComponent(prevState.packageName)
repository.setProviderEnabled(config.componentId, previous, enabled = false)
}
}
repository.upsertProviderState(
ProviderState(
componentId = config.componentId,
providerId = providerId,
packageName = update.packageName,
installedVersion = update.version,
enabled = true,
signatureHash = update.signatureSha256,
installedAt = now,
)
)
if (config.componentId == Constants.COMPONENT_WEBVIEW) {
WebViewProviderSwitcher.switchTo(update.packageName)
}
}
repository.upsertComponent(
ComponentState(
componentId = config.componentId,
manifestUrl = config.url,
activeProvider = providerId,
activeVersion = update.version,
lastCheckedAt = now,
lastSuccessAt = now,
)
)
}
private fun disableComponent(packageName: String) = setComponentEnabled(packageName, false)
private fun enableComponent(packageName: String) = setComponentEnabled(packageName, true)
private fun setComponentEnabled(packageName: String, enabled: Boolean) {
try {
context.packageManager.setApplicationEnabledSetting(
packageName,
if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
0,
)
} catch (e: IllegalArgumentException) {
Log.w(Constants.TAG, "Cannot toggle $packageName, not installed for this user", e)
}
}
/** Fast switch-back to an already-installed, disabled provider — no redownload. */
fun setPreferredProvider(componentId: String, providerId: String) {
val target = repository.getProviderState(componentId, providerId) ?: return
val current = repository.getComponent(componentId)?.activeProvider
if (current == providerId) return
enableComponent(target.packageName)
repository.setProviderEnabled(componentId, providerId, enabled = true)
current?.let { previous ->
repository.getProviderState(componentId, previous)?.let { prevState ->
disableComponent(prevState.packageName)
repository.setProviderEnabled(componentId, previous, enabled = false)
}
}
if (componentId == Constants.COMPONENT_WEBVIEW) {
WebViewProviderSwitcher.switchTo(target.packageName)
}
val existing = repository.getComponent(componentId)
repository.upsertComponent(
(existing ?: ComponentState(componentId, "", null, null, 0, 0)).copy(
activeProvider = providerId,
activeVersion = target.installedVersion,
)
)
repository.insertUpdateLog(
componentId, providerId, current, providerId, Constants.TriggerType.USER_TOGGLE,
"success", null, System.currentTimeMillis(),
)
}
/** Explicit user action only — actually uninstalls, unlike disableComponent(). */
fun deleteDisabledProvider(componentId: String, providerId: String, callback: IUpdateCallback?) {
val state = repository.getProviderState(componentId, providerId)
if (state == null) {
fail(componentId, providerId, Constants.ErrorCode.NO_SUCH_COMPONENT,
"No such provider state", Constants.TriggerType.USER_TOGGLE, callback)
return
}
if (state.enabled) {
fail(componentId, providerId, Constants.ErrorCode.UNKNOWN,
"Cannot delete the active provider", Constants.TriggerType.USER_TOGGLE, callback)
return
}
val result = installer.uninstall(state.packageName)
if (!result.success) {
fail(componentId, providerId, Constants.ErrorCode.INSTALL_FAILED,
result.message ?: "Uninstall failed", Constants.TriggerType.USER_TOGGLE, callback)
return
}
repository.deleteProviderState(componentId, providerId)
succeed(componentId, null, Constants.TriggerType.USER_TOGGLE, providerId, callback)
}
/**
* WebViewUpdateService can fall back to a different provider than bg-upd's own
* state table expects (e.g. after an interrupted switch) — reconcile against it
* as ground truth rather than trusting our DB blindly.
*/
fun reconcileWebView() {
val actualPackage = WebViewProviderSwitcher.getCurrentProviderPackageName() ?: return
val state = repository.getComponent(Constants.COMPONENT_WEBVIEW)
val activeState = state?.activeProvider
?.let { repository.getProviderState(Constants.COMPONENT_WEBVIEW, it) }
if (activeState?.packageName == actualPackage) return
val matching = repository.getProviderStates(Constants.COMPONENT_WEBVIEW)
.find { it.packageName == actualPackage } ?: return
state?.activeProvider?.let { previous ->
if (previous != matching.providerId) {
repository.setProviderEnabled(Constants.COMPONENT_WEBVIEW, previous, enabled = false)
}
}
repository.setProviderEnabled(Constants.COMPONENT_WEBVIEW, matching.providerId, enabled = true)
repository.upsertComponent(
(state ?: ComponentState(Constants.COMPONENT_WEBVIEW, "", null, null, 0, 0)).copy(
activeProvider = matching.providerId,
activeVersion = matching.installedVersion,
)
)
}
fun getComponentStatus(componentId: String): ComponentStatus? {
val state = repository.getComponent(componentId) ?: return null
return toStatus(state)
}
fun getAllComponentStatus(): List<ComponentStatus> =
repository.getAllComponents().map(::toStatus)
private fun toStatus(state: ComponentState): ComponentStatus {
val status = ComponentStatus()
status.componentId = state.componentId
status.activeProvider = state.activeProvider
status.installedVersion = state.activeVersion
status.lastCheckedTimestamp = state.lastCheckedAt
// Every check that finds an update applies it immediately (see runComponent) — there is
// no persisted "checked, update pending" state, so this is only ever true mid-check.
status.updateAvailable = false
status.disabledProvidersPresent = repository.getProviderStates(state.componentId)
.filter { !it.enabled }
.map { it.providerId }
return status
}
private fun download(componentId: String, update: ComponentUpdateResponse): File {
val outFile = File(context.cacheDir, "$componentId-${update.version}.apk")
val connection = URL(update.downloadUrl).openConnection() as HttpURLConnection
connection.connectTimeout = 15_000
connection.readTimeout = 30_000
try {
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw java.io.IOException("HTTP ${connection.responseCode} downloading ${update.downloadUrl}")
}
connection.inputStream.use { input ->
outFile.outputStream().use { output -> input.copyTo(output) }
}
} finally {
connection.disconnect()
}
return outFile
}
private fun arch(): String = Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a"
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-update for $componentId", e)
}
}
private fun succeed(
componentId: String,
version: String?,
triggerType: String,
providerId: String?,
callback: IUpdateCallback?,
) {
repository.insertUpdateLog(
componentId, providerId, null, version, triggerType, "success", null,
System.currentTimeMillis(),
)
try {
callback?.onSuccess(componentId, version ?: "")
} catch (e: RemoteException) {
Log.w(Constants.TAG, "Callback died after success for $componentId", e)
}
}
private fun fail(
componentId: String,
providerId: String?,
errorCode: Int,
message: String,
triggerType: String,
callback: IUpdateCallback?,
) {
repository.insertUpdateLog(
componentId, providerId, null, null, triggerType, "failure", message,
System.currentTimeMillis(),
)
try {
callback?.onFailure(componentId, errorCode, message)
} catch (e: RemoteException) {
Log.w(Constants.TAG, "Callback died after failure for $componentId", e)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.db
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class BgUpdDbHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object {
const val DATABASE_NAME = "bgupd.db"
const val DATABASE_VERSION = 1
const val TABLE_COMPONENTS = "components"
const val TABLE_PROVIDER_STATE = "provider_state"
const val TABLE_UPDATE_LOG = "update_log"
private const val SQL_CREATE_COMPONENTS = """
CREATE TABLE $TABLE_COMPONENTS (
component_id TEXT PRIMARY KEY,
manifest_url TEXT NOT NULL,
active_provider TEXT,
active_version TEXT,
last_checked_at INTEGER,
last_success_at INTEGER
)
"""
private const val SQL_CREATE_PROVIDER_STATE = """
CREATE TABLE $TABLE_PROVIDER_STATE (
component_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
package_name TEXT NOT NULL,
installed_version TEXT,
enabled INTEGER NOT NULL,
signature_hash TEXT NOT NULL,
installed_at INTEGER,
PRIMARY KEY (component_id, provider_id),
FOREIGN KEY (component_id) REFERENCES $TABLE_COMPONENTS(component_id)
)
"""
private const val SQL_CREATE_UPDATE_LOG = """
CREATE TABLE $TABLE_UPDATE_LOG (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component_id TEXT NOT NULL,
provider_id TEXT,
from_version TEXT,
to_version TEXT,
trigger_type TEXT NOT NULL,
result TEXT NOT NULL,
error_message TEXT,
timestamp INTEGER NOT NULL
)
"""
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(SQL_CREATE_COMPONENTS)
db.execSQL(SQL_CREATE_PROVIDER_STATE)
db.execSQL(SQL_CREATE_UPDATE_LOG)
}
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)
}
}
@@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.db
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import os.pawlet.bgupd.model.ComponentState
import os.pawlet.bgupd.model.ProviderState
class ComponentRepository(context: Context) {
private val dbHelper = BgUpdDbHelper(context.applicationContext)
fun upsertComponent(state: ComponentState) {
val values = ContentValues().apply {
put("component_id", state.componentId)
put("manifest_url", state.manifestUrl)
put("active_provider", state.activeProvider)
put("active_version", state.activeVersion)
put("last_checked_at", state.lastCheckedAt)
put("last_success_at", state.lastSuccessAt)
}
dbHelper.writableDatabase.insertWithOnConflict(
BgUpdDbHelper.TABLE_COMPONENTS, null, values, SQLiteDatabase.CONFLICT_REPLACE
)
}
fun getComponent(componentId: String): ComponentState? {
dbHelper.readableDatabase.query(
BgUpdDbHelper.TABLE_COMPONENTS, null, "component_id = ?", arrayOf(componentId),
null, null, null
).use { cursor ->
if (!cursor.moveToFirst()) return null
return ComponentState(
componentId = cursor.getString(cursor.getColumnIndexOrThrow("component_id")),
manifestUrl = cursor.getString(cursor.getColumnIndexOrThrow("manifest_url")),
activeProvider = cursor.getString(cursor.getColumnIndexOrThrow("active_provider")),
activeVersion = cursor.getString(cursor.getColumnIndexOrThrow("active_version")),
lastCheckedAt = cursor.getLong(cursor.getColumnIndexOrThrow("last_checked_at")),
lastSuccessAt = cursor.getLong(cursor.getColumnIndexOrThrow("last_success_at")),
)
}
}
fun getAllComponents(): List<ComponentState> {
val result = mutableListOf<ComponentState>()
dbHelper.readableDatabase.query(
BgUpdDbHelper.TABLE_COMPONENTS, null, null, null, null, null, null
).use { cursor ->
while (cursor.moveToNext()) {
result.add(
ComponentState(
componentId = cursor.getString(cursor.getColumnIndexOrThrow("component_id")),
manifestUrl = cursor.getString(cursor.getColumnIndexOrThrow("manifest_url")),
activeProvider = cursor.getString(cursor.getColumnIndexOrThrow("active_provider")),
activeVersion = cursor.getString(cursor.getColumnIndexOrThrow("active_version")),
lastCheckedAt = cursor.getLong(cursor.getColumnIndexOrThrow("last_checked_at")),
lastSuccessAt = cursor.getLong(cursor.getColumnIndexOrThrow("last_success_at")),
)
)
}
}
return result
}
fun touchLastChecked(componentId: String, timestamp: Long) {
val values = ContentValues().apply { put("last_checked_at", timestamp) }
dbHelper.writableDatabase.update(
BgUpdDbHelper.TABLE_COMPONENTS, values, "component_id = ?", arrayOf(componentId)
)
}
fun upsertProviderState(state: ProviderState) {
val values = ContentValues().apply {
put("component_id", state.componentId)
put("provider_id", state.providerId)
put("package_name", state.packageName)
put("installed_version", state.installedVersion)
put("enabled", if (state.enabled) 1 else 0)
put("signature_hash", state.signatureHash)
put("installed_at", state.installedAt)
}
dbHelper.writableDatabase.insertWithOnConflict(
BgUpdDbHelper.TABLE_PROVIDER_STATE, null, values, SQLiteDatabase.CONFLICT_REPLACE
)
}
fun setProviderEnabled(componentId: String, providerId: String, enabled: Boolean) {
val values = ContentValues().apply { put("enabled", if (enabled) 1 else 0) }
dbHelper.writableDatabase.update(
BgUpdDbHelper.TABLE_PROVIDER_STATE, values,
"component_id = ? AND provider_id = ?", arrayOf(componentId, providerId)
)
}
fun deleteProviderState(componentId: String, providerId: String) {
dbHelper.writableDatabase.delete(
BgUpdDbHelper.TABLE_PROVIDER_STATE,
"component_id = ? AND provider_id = ?", arrayOf(componentId, providerId)
)
}
fun getProviderStates(componentId: String): List<ProviderState> {
val result = mutableListOf<ProviderState>()
dbHelper.readableDatabase.query(
BgUpdDbHelper.TABLE_PROVIDER_STATE, null, "component_id = ?", arrayOf(componentId),
null, null, null
).use { cursor ->
while (cursor.moveToNext()) {
result.add(
ProviderState(
componentId = cursor.getString(cursor.getColumnIndexOrThrow("component_id")),
providerId = cursor.getString(cursor.getColumnIndexOrThrow("provider_id")),
packageName = cursor.getString(cursor.getColumnIndexOrThrow("package_name")),
installedVersion = cursor.getString(cursor.getColumnIndexOrThrow("installed_version")),
enabled = cursor.getInt(cursor.getColumnIndexOrThrow("enabled")) != 0,
signatureHash = cursor.getString(cursor.getColumnIndexOrThrow("signature_hash")),
installedAt = cursor.getLong(cursor.getColumnIndexOrThrow("installed_at")),
)
)
}
}
return result
}
fun getProviderState(componentId: String, providerId: String): ProviderState? =
getProviderStates(componentId).find { it.providerId == providerId }
fun insertUpdateLog(
componentId: String,
providerId: String?,
fromVersion: String?,
toVersion: String?,
triggerType: String,
result: String,
errorMessage: String?,
timestamp: Long,
) {
val values = ContentValues().apply {
put("component_id", componentId)
put("provider_id", providerId)
put("from_version", fromVersion)
put("to_version", toVersion)
put("trigger_type", triggerType)
put("result", result)
put("error_message", errorMessage)
put("timestamp", timestamp)
}
dbHelper.writableDatabase.insert(BgUpdDbHelper.TABLE_UPDATE_LOG, null, values)
}
}
@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.install
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
data class InstallResult(val success: Boolean, val message: String?)
/**
* Blocking wrapper around PackageInstaller. Callers already run on a background
* executor thread (see UpdateExecutor), so blocking here just serializes that thread
* until the install session's result broadcast arrives.
*/
class PackageInstallerHelper(private val context: Context) {
private var nextRequestCode = 9000
fun installSilently(apkFile: File, packageName: String): InstallResult {
val packageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(packageName)
val sessionId = packageInstaller.createSession(params)
val session = packageInstaller.openSession(sessionId)
session.use { s ->
apkFile.inputStream().use { input ->
s.openWrite("bgupd_component", 0, apkFile.length()).use { out ->
input.copyTo(out)
s.fsync(out)
}
}
val (action, pendingIntent) = registerCompletionReceiver(sessionId)
return awaitResult(action, pendingIntent) { s.commit(it.intentSender) }
}
}
fun uninstall(packageName: String): InstallResult {
val requestCode = synchronized(this) { nextRequestCode++ }
val (action, pendingIntent) = registerCompletionReceiver(requestCode)
return awaitResult(action, pendingIntent) {
context.packageManager.packageInstaller.uninstall(packageName, it.intentSender)
}
}
private fun registerCompletionReceiver(requestCode: Int): Pair<String, PendingIntent> {
val action = "os.pawlet.bgupd.action.INSTALL_COMPLETE.$requestCode"
val intent = Intent(action).setPackage(context.packageName)
val pendingIntent = PendingIntent.getBroadcast(
context, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
return action to pendingIntent
}
private fun awaitResult(
action: String,
pendingIntent: PendingIntent,
commit: (PendingIntent) -> Unit,
): InstallResult {
val latch = CountDownLatch(1)
val status = AtomicInteger(PackageInstaller.STATUS_FAILURE)
val message = AtomicReference<String?>(null)
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
status.set(intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE))
message.set(intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
context.unregisterReceiver(this)
latch.countDown()
}
}
context.registerReceiver(receiver, IntentFilter(action), Context.RECEIVER_NOT_EXPORTED)
commit(pendingIntent)
latch.await(2, TimeUnit.MINUTES)
return InstallResult(status.get() == PackageInstaller.STATUS_SUCCESS, message.get())
}
}
@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.install
import android.content.pm.PackageManager
import java.io.File
import java.security.MessageDigest
/** Verifies a downloaded APK's signing certificate against the manifest-declared digest. */
object SignatureVerifier {
/**
* @param expectedSha256 colon-separated hex, e.g. "AB:CD:...", as declared by the
* component endpoint. Comparison is case- and separator-insensitive.
*/
fun verify(packageManager: PackageManager, apkFile: File, expectedSha256: String): Boolean {
val info = packageManager.getPackageArchiveInfo(
apkFile.absolutePath,
PackageManager.GET_SIGNING_CERTIFICATES,
) ?: return false
val signingInfo = info.signingInfo ?: return false
val certs = if (signingInfo.hasMultipleSigners()) {
signingInfo.apkContentsSigners
} else {
signingInfo.signingCertificateHistory
}
val expected = normalize(expectedSha256)
return certs.any { cert ->
val digest = MessageDigest.getInstance("SHA-256").digest(cert.toByteArray())
normalize(toHex(digest)) == expected
}
}
private fun toHex(bytes: ByteArray): String =
bytes.joinToString(":") { "%02X".format(it) }
private fun normalize(hex: String): String =
hex.replace(":", "").uppercase()
}
@@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.job
import android.app.job.JobParameters
import android.app.job.JobService
import android.util.Log
import os.pawlet.bgupd.Constants
import os.pawlet.bgupd.UpdateExecutor
import os.pawlet.bgupd.manifest.ManifestClient
import java.util.concurrent.Executors
/**
* Periodic, Doze-friendly check for every component in the manifest. Respects battery/idle
* constraints via JobScheduler rather than running as a persistent foreground service.
*/
class BgUpdJobService : JobService() {
private val workers = Executors.newSingleThreadExecutor()
@Volatile private var running = true
override fun onStartJob(params: JobParameters?): Boolean {
running = true
workers.execute {
val executor = UpdateExecutor(this)
try {
val componentIds = ManifestClient().fetchManifest().keys
for (componentId in componentIds) {
if (!running) break
executor.runComponent(componentId, null, Constants.TriggerType.SCHEDULED, null)
}
if (running) executor.reconcileWebView()
} catch (e: Exception) {
Log.e(Constants.TAG, "Scheduled check failed", e)
} finally {
jobFinished(params, false)
}
}
return true
}
override fun onStopJob(params: JobParameters?): Boolean {
running = false
return true
}
}
@@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.manifest
import android.net.Uri
import android.util.Log
import org.json.JSONObject
import os.pawlet.bgupd.Constants
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
/** Fetches the top-level component manifest and individual component endpoints. */
class ManifestClient {
fun fetchManifest(): Map<String, ComponentConfig> {
val body = get(URL(Constants.MANIFEST_URL)) ?: return emptyMap()
val components = JSONObject(body).getJSONObject("components")
val result = LinkedHashMap<String, ComponentConfig>()
for (componentId in components.keys()) {
val entry = components.getJSONObject(componentId)
val providers = mutableListOf<String>()
entry.optJSONArray("providers")?.let { arr ->
for (i in 0 until arr.length()) providers.add(arr.getString(i))
}
result[componentId] = ComponentConfig(
componentId = componentId,
url = entry.getString("url"),
providers = providers,
defaultProvider = entry.optString("default", null),
)
}
return result
}
/** Returns null if the server reports no update available (HTTP 204). */
fun fetchComponentUpdate(
config: ComponentConfig,
arch: String,
provider: String?,
currentVersion: String?,
): ComponentUpdateResponse? {
val uriBuilder = Uri.parse(config.url).buildUpon()
.appendQueryParameter("arch", arch)
if (provider != null) uriBuilder.appendQueryParameter("provider", provider)
if (currentVersion != null) uriBuilder.appendQueryParameter("current_version", currentVersion)
val url = URL(uriBuilder.build().toString())
val connection = url.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 java.io.IOException("HTTP ${connection.responseCode} from ${config.url}")
}
val body = connection.inputStream.bufferedReader().use(BufferedReader::readText)
val json = JSONObject(body)
return ComponentUpdateResponse(
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"),
)
} finally {
connection.disconnect()
}
}
private fun get(url: URL): String? {
val connection = url.openConnection() as HttpURLConnection
try {
connection.connectTimeout = 15_000
connection.readTimeout = 15_000
connection.requestMethod = "GET"
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
Log.w(Constants.TAG, "Manifest fetch failed: HTTP ${connection.responseCode}")
return null
}
return InputStreamReader(connection.inputStream).use { it.readText() }
} finally {
connection.disconnect()
}
}
}
@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.manifest
/** One entry under the top-level manifest's "components" map. */
data class ComponentConfig(
val componentId: String,
val url: String,
val providers: List<String>,
val defaultProvider: String?,
)
/** Parsed response body from a component's own endpoint (e.g. webview.php). */
data class ComponentUpdateResponse(
val provider: String?,
val version: String,
val downloadUrl: String,
val packageName: String,
val signatureSha256: String,
val sizeBytes: Long,
)
+24
View File
@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.model
data class ComponentState(
val componentId: String,
val manifestUrl: String,
val activeProvider: String?,
val activeVersion: String?,
val lastCheckedAt: Long,
val lastSuccessAt: Long,
)
data class ProviderState(
val componentId: String,
val providerId: String,
val packageName: String,
val installedVersion: String?,
val enabled: Boolean,
val signatureHash: String,
val installedAt: Long,
)
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package os.pawlet.bgupd.webview
import android.content.Context
import android.os.ServiceManager
import android.webkit.IWebViewUpdateService
import android.webkit.WebViewProviderInfo
/**
* Talks to the platform's hidden WebViewUpdateService directly. This is a
* @SystemApi(client = MODULE_LIBRARIES) surface not meant for regular apps, but
* bg-upd is a platform-signed priv-app installed on /system_ext, which the
* framework exempts from hidden-API enforcement.
*/
object WebViewProviderSwitcher {
private fun service(): IWebViewUpdateService =
IWebViewUpdateService.Stub.asInterface(
ServiceManager.getService(Context.WEBVIEW_UPDATE_SERVICE)
)
/** Returns the provider package now active, which may differ if [packageName] was rejected. */
fun switchTo(packageName: String): String? = service().changeProviderAndSetting(packageName)
fun getCurrentProviderPackageName(): String? = service().currentWebViewPackageName
fun getAllProviderPackageNames(): List<String> =
service().allWebViewPackages.map(WebViewProviderInfo::packageName)
}