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:
+38
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user