From 54f44d34171569e9cfb7b4675ec865fea6e5556d Mon Sep 17 00:00:00 2001 From: oxmc7769 Date: Thu, 9 Jul 2026 01:23:46 -0700 Subject: [PATCH] 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. --- Android.bp | 38 ++ AndroidManifest.xml | 55 +++ aidl/pawletos/bgupd/ComponentStatus.aidl | 14 + aidl/pawletos/bgupd/IBgUpdaterService.aidl | 24 ++ aidl/pawletos/bgupd/IUpdateCallback.aidl | 11 + os.pawlet.bgupd.xml | 13 + res/values/strings.xml | 8 + src/os/pawlet/bgupd/BgUpdaterService.kt | 76 ++++ src/os/pawlet/bgupd/BootReceiver.kt | 28 ++ src/os/pawlet/bgupd/Constants.kt | 30 ++ src/os/pawlet/bgupd/UpdateExecutor.kt | 329 ++++++++++++++++++ src/os/pawlet/bgupd/db/BgUpdDbHelper.kt | 74 ++++ src/os/pawlet/bgupd/db/ComponentRepository.kt | 154 ++++++++ .../bgupd/install/PackageInstallerHelper.kt | 88 +++++ .../pawlet/bgupd/install/SignatureVerifier.kt | 41 +++ src/os/pawlet/bgupd/job/BgUpdJobService.kt | 48 +++ .../pawlet/bgupd/manifest/ManifestClient.kt | 93 +++++ .../pawlet/bgupd/manifest/ManifestModels.kt | 23 ++ src/os/pawlet/bgupd/model/DbModels.kt | 24 ++ .../bgupd/webview/WebViewProviderSwitcher.kt | 32 ++ 20 files changed, 1203 insertions(+) create mode 100644 Android.bp create mode 100644 AndroidManifest.xml create mode 100644 aidl/pawletos/bgupd/ComponentStatus.aidl create mode 100644 aidl/pawletos/bgupd/IBgUpdaterService.aidl create mode 100644 aidl/pawletos/bgupd/IUpdateCallback.aidl create mode 100644 os.pawlet.bgupd.xml create mode 100644 res/values/strings.xml create mode 100644 src/os/pawlet/bgupd/BgUpdaterService.kt create mode 100644 src/os/pawlet/bgupd/BootReceiver.kt create mode 100644 src/os/pawlet/bgupd/Constants.kt create mode 100644 src/os/pawlet/bgupd/UpdateExecutor.kt create mode 100644 src/os/pawlet/bgupd/db/BgUpdDbHelper.kt create mode 100644 src/os/pawlet/bgupd/db/ComponentRepository.kt create mode 100644 src/os/pawlet/bgupd/install/PackageInstallerHelper.kt create mode 100644 src/os/pawlet/bgupd/install/SignatureVerifier.kt create mode 100644 src/os/pawlet/bgupd/job/BgUpdJobService.kt create mode 100644 src/os/pawlet/bgupd/manifest/ManifestClient.kt create mode 100644 src/os/pawlet/bgupd/manifest/ManifestModels.kt create mode 100644 src/os/pawlet/bgupd/model/DbModels.kt create mode 100644 src/os/pawlet/bgupd/webview/WebViewProviderSwitcher.kt diff --git a/Android.bp b/Android.bp new file mode 100644 index 0000000..13c2ba6 --- /dev/null +++ b/Android.bp @@ -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, + }, +} diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..97b5677 --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/aidl/pawletos/bgupd/ComponentStatus.aidl b/aidl/pawletos/bgupd/ComponentStatus.aidl new file mode 100644 index 0000000..9cdf025 --- /dev/null +++ b/aidl/pawletos/bgupd/ComponentStatus.aidl @@ -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 disabledProvidersPresent; +} diff --git a/aidl/pawletos/bgupd/IBgUpdaterService.aidl b/aidl/pawletos/bgupd/IBgUpdaterService.aidl new file mode 100644 index 0000000..779f34e --- /dev/null +++ b/aidl/pawletos/bgupd/IBgUpdaterService.aidl @@ -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 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); +} diff --git a/aidl/pawletos/bgupd/IUpdateCallback.aidl b/aidl/pawletos/bgupd/IUpdateCallback.aidl new file mode 100644 index 0000000..45f6bd7 --- /dev/null +++ b/aidl/pawletos/bgupd/IUpdateCallback.aidl @@ -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); +} diff --git a/os.pawlet.bgupd.xml b/os.pawlet.bgupd.xml new file mode 100644 index 0000000..2b36452 --- /dev/null +++ b/os.pawlet.bgupd.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml new file mode 100644 index 0000000..78a4120 --- /dev/null +++ b/res/values/strings.xml @@ -0,0 +1,8 @@ + + + + PawletOS Updater + diff --git a/src/os/pawlet/bgupd/BgUpdaterService.kt b/src/os/pawlet/bgupd/BgUpdaterService.kt new file mode 100644 index 0000000..828cbbf --- /dev/null +++ b/src/os/pawlet/bgupd/BgUpdaterService.kt @@ -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 = + 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 +} diff --git a/src/os/pawlet/bgupd/BootReceiver.kt b/src/os/pawlet/bgupd/BootReceiver.kt new file mode 100644 index 0000000..dd70715 --- /dev/null +++ b/src/os/pawlet/bgupd/BootReceiver.kt @@ -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) + } +} diff --git a/src/os/pawlet/bgupd/Constants.kt b/src/os/pawlet/bgupd/Constants.kt new file mode 100644 index 0000000..8736e5e --- /dev/null +++ b/src/os/pawlet/bgupd/Constants.kt @@ -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 +} diff --git a/src/os/pawlet/bgupd/UpdateExecutor.kt b/src/os/pawlet/bgupd/UpdateExecutor.kt new file mode 100644 index 0000000..71c12e8 --- /dev/null +++ b/src/os/pawlet/bgupd/UpdateExecutor.kt @@ -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 = + 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) + } + } +} diff --git a/src/os/pawlet/bgupd/db/BgUpdDbHelper.kt b/src/os/pawlet/bgupd/db/BgUpdDbHelper.kt new file mode 100644 index 0000000..06c30bb --- /dev/null +++ b/src/os/pawlet/bgupd/db/BgUpdDbHelper.kt @@ -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) + } +} diff --git a/src/os/pawlet/bgupd/db/ComponentRepository.kt b/src/os/pawlet/bgupd/db/ComponentRepository.kt new file mode 100644 index 0000000..947bd95 --- /dev/null +++ b/src/os/pawlet/bgupd/db/ComponentRepository.kt @@ -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 { + val result = mutableListOf() + 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 { + val result = mutableListOf() + 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) + } +} diff --git a/src/os/pawlet/bgupd/install/PackageInstallerHelper.kt b/src/os/pawlet/bgupd/install/PackageInstallerHelper.kt new file mode 100644 index 0000000..869d1a6 --- /dev/null +++ b/src/os/pawlet/bgupd/install/PackageInstallerHelper.kt @@ -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 { + 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(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()) + } +} diff --git a/src/os/pawlet/bgupd/install/SignatureVerifier.kt b/src/os/pawlet/bgupd/install/SignatureVerifier.kt new file mode 100644 index 0000000..6c690b1 --- /dev/null +++ b/src/os/pawlet/bgupd/install/SignatureVerifier.kt @@ -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() +} diff --git a/src/os/pawlet/bgupd/job/BgUpdJobService.kt b/src/os/pawlet/bgupd/job/BgUpdJobService.kt new file mode 100644 index 0000000..6bef6c6 --- /dev/null +++ b/src/os/pawlet/bgupd/job/BgUpdJobService.kt @@ -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 + } +} diff --git a/src/os/pawlet/bgupd/manifest/ManifestClient.kt b/src/os/pawlet/bgupd/manifest/ManifestClient.kt new file mode 100644 index 0000000..50008dc --- /dev/null +++ b/src/os/pawlet/bgupd/manifest/ManifestClient.kt @@ -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 { + val body = get(URL(Constants.MANIFEST_URL)) ?: return emptyMap() + val components = JSONObject(body).getJSONObject("components") + val result = LinkedHashMap() + for (componentId in components.keys()) { + val entry = components.getJSONObject(componentId) + val providers = mutableListOf() + 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() + } + } +} diff --git a/src/os/pawlet/bgupd/manifest/ManifestModels.kt b/src/os/pawlet/bgupd/manifest/ManifestModels.kt new file mode 100644 index 0000000..cade12b --- /dev/null +++ b/src/os/pawlet/bgupd/manifest/ManifestModels.kt @@ -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, + 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, +) diff --git a/src/os/pawlet/bgupd/model/DbModels.kt b/src/os/pawlet/bgupd/model/DbModels.kt new file mode 100644 index 0000000..4f50e00 --- /dev/null +++ b/src/os/pawlet/bgupd/model/DbModels.kt @@ -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, +) diff --git a/src/os/pawlet/bgupd/webview/WebViewProviderSwitcher.kt b/src/os/pawlet/bgupd/webview/WebViewProviderSwitcher.kt new file mode 100644 index 0000000..cbd1cbf --- /dev/null +++ b/src/os/pawlet/bgupd/webview/WebViewProviderSwitcher.kt @@ -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 = + service().allWebViewPackages.map(WebViewProviderInfo::packageName) +}