SetupWizard: OEM-contributed wizard pages from the config APK

A config APK can ship res/xml/oem_wizard.xml (declarative Rich schema:
section/text/image/toggle/choice/input/link, multi-page). ConfigProvisioner
emits Settings.Global pawlet.oem_wizard_pkg when it detects the file; the
wizard loads + parses it and appends the pages near the end of the flow.

- lib/oem: OemModels, OemWizardParser (pull-parser; text carried in a value
  attribute since compiled res/xml drops element text), OemWizardRepository
  (signal + default-package fallback), OemPendingStore (deferred secure/global
  writes).
- OemWizardScreen renders elements to Compose on the shared scaffold; toggle/
  choice/input selections are seeded with OEM defaults and recorded, then
  applied in finishSetupWizard.
- MainActivity appends OEM pages; no config-APK code runs (declarative only),
  link actions limited to explicit component:/intent:.
This commit is contained in:
oxmc
2026-07-18 07:40:06 -07:00
parent f636cd1a2a
commit c1ec0ccee3
7 changed files with 518 additions and 7 deletions
@@ -14,10 +14,13 @@ import me.pawlet.setupwizard.lib.FullScreenHelper
import me.pawlet.setupwizard.lib.BaseKioskActivity
import me.pawlet.setupwizard.lib.SetupWizardManager
import me.pawlet.setupwizard.lib.WizardFlags
import me.pawlet.setupwizard.lib.WizardPage
import me.pawlet.setupwizard.lib.buildBuiltinPages
import me.pawlet.setupwizard.lib.oem.OemWizardRepository
import me.pawlet.setupwizard.lib.utils.SetupUtils
import me.pawlet.setupwizard.ui.screens.AboutDeviceScreen
import me.pawlet.setupwizard.ui.screens.AndroidVersionScreen
import me.pawlet.setupwizard.ui.screens.wizard.OemWizardScreen
import me.pawlet.setupwizard.ui.theme.MainTheme
class MainActivity : BaseKioskActivity() {
@@ -39,14 +42,26 @@ class MainActivity : BaseKioskActivity() {
}
wizardManager = SetupWizardManager.getInstance(this)
wizardManager.registerPages(
buildBuiltinPages(
onFinish = { finish() },
onSecretUnlocked = {
wizardManager.showOverlay(SetupWizardManager.Overlay.AboutDevice)
val builtinPages = buildBuiltinPages(
onFinish = { finish() },
onSecretUnlocked = {
wizardManager.showOverlay(SetupWizardManager.Overlay.AboutDevice)
}
)
// OEM pages contributed by the config APK (ConfigProvisioner emits the
// signal; OemWizardRepository loads + parses the declarative XML). They
// land near the end of the flow, before "complete".
val oemPages = OemWizardRepository.load(this).mapIndexed { i, page ->
WizardPage(
id = "oem_${page.id}",
order = 92 + i,
content = { onNext, onBack ->
OemWizardScreen(page = page, onContinue = onNext, onBack = onBack)
}
)
)
}
wizardManager.registerPages(builtinPages + oemPages)
setContent {
MainTheme {
@@ -0,0 +1,60 @@
package me.pawlet.setupwizard.lib.oem
/**
* Parsed model of an OEM-provided wizard, loaded from a config APK's
* res/xml/oem_wizard.xml. Purely declarative — no code from the config APK is
* executed; the SetupWizard renders these to Compose.
*/
data class OemWizard(
val version: Int,
val pages: List<OemPage>
)
data class OemPage(
val id: String,
val title: String,
val subtitle: String?,
val icon: String?, // logical icon name mapped by the renderer
val elements: List<OemElement>
)
/** A setting target: "secure:<key>" or "global:<key>". Null = no persistence. */
typealias OemTarget = String
/** Value type stored for deferred application at finishSetupWizard. */
enum class OemValueType { BOOL, STRING }
sealed interface OemElement {
data class Section(val title: String) : OemElement
data class Text(val text: String) : OemElement
data class Image(val url: String) : OemElement
data class Toggle(
val key: String,
val title: String,
val subtitle: String?,
val default: Boolean,
val target: OemTarget?
) : OemElement
data class Choice(
val key: String,
val title: String,
val target: OemTarget?,
val default: String?,
val options: List<Option>
) : OemElement {
data class Option(val value: String, val label: String)
}
data class Input(
val key: String,
val title: String,
val hint: String?,
val default: String?,
val target: OemTarget?
) : OemElement
/** action: "component:<pkg>/<cls>" or "intent:<ACTION>" (whitelisted). */
data class Link(val label: String, val action: String) : OemElement
}
@@ -0,0 +1,81 @@
package me.pawlet.setupwizard.lib.oem
import android.content.Context
import android.provider.Settings
import android.util.Log
/**
* Deferred store for OEM page selections. Toggles/choices/inputs record their
* chosen value here during the wizard (keyed by "secure:<key>" / "global:<key>"
* target); [apply] writes them all to their real settings at
* finishSetupWizard() and clears the store. Values are encoded with a 1-char
* type marker: "B0"/"B1" for booleans, "S<text>" for strings.
*/
object OemPendingStore {
private const val PREFS = "oem_pending"
private const val TAG = "OemPendingStore"
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun setBool(context: Context, target: String, value: Boolean) {
prefs(context).edit().putString(target, if (value) "B1" else "B0").apply()
}
fun setString(context: Context, target: String, value: String) {
prefs(context).edit().putString(target, "S$value").apply()
}
/** Seed a default only if the user hasn't set this target yet. */
fun seedBool(context: Context, target: String, value: Boolean) {
if (!prefs(context).contains(target)) setBool(context, target, value)
}
fun seedString(context: Context, target: String, value: String) {
if (!prefs(context).contains(target)) setString(context, target, value)
}
fun currentBool(context: Context, target: String, def: Boolean): Boolean =
prefs(context).getString(target, null)?.let { it == "B1" } ?: def
fun currentString(context: Context, target: String, def: String): String =
prefs(context).getString(target, null)?.let {
if (it.startsWith("S")) it.substring(1) else def
} ?: def
/** Write all pending targets to their real settings, then clear. */
fun apply(context: Context) {
val cr = context.contentResolver
val all = prefs(context).all
for ((target, rawAny) in all) {
val raw = rawAny as? String ?: continue
val sep = target.indexOf(':')
if (sep <= 0) continue
val scope = target.substring(0, sep) // "secure" | "global"
val key = target.substring(sep + 1)
runCatching {
when (raw.firstOrNull()) {
'B' -> writeInt(cr, scope, key, if (raw == "B1") 1 else 0)
'S' -> writeString(cr, scope, key, raw.substring(1))
else -> {}
}
}.onFailure { Log.w(TAG, "apply $target failed", it) }
}
prefs(context).edit().clear().apply()
}
private fun writeInt(cr: android.content.ContentResolver, scope: String, key: String, v: Int) {
when (scope) {
"secure" -> Settings.Secure.putInt(cr, key, v)
"global" -> Settings.Global.putInt(cr, key, v)
}
}
private fun writeString(cr: android.content.ContentResolver, scope: String, key: String, v: String) {
when (scope) {
"secure" -> Settings.Secure.putString(cr, key, v)
"global" -> Settings.Global.putString(cr, key, v)
}
}
}
@@ -0,0 +1,107 @@
package me.pawlet.setupwizard.lib.oem
import org.xmlpull.v1.XmlPullParser
/**
* Parses res/xml/oem_wizard.xml (Rich schema) from a config APK into an
* [OemWizard]. Declarative only; unknown tags/attributes are ignored so the
* schema can grow without breaking older parsers.
*/
object OemWizardParser {
fun parse(parser: XmlPullParser): OemWizard {
var version = 1
val pages = mutableListOf<OemPage>()
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG) {
when (parser.name) {
"oemWizard" -> version = attrInt(parser, "version", 1)
"page" -> pages.add(parsePage(parser))
}
}
event = parser.next()
}
return OemWizard(version, pages)
}
private fun parsePage(parser: XmlPullParser): OemPage {
val id = attr(parser, "id").orEmpty()
val title = attr(parser, "title").orEmpty()
val subtitle = attr(parser, "subtitle")
val icon = attr(parser, "icon")
val elements = mutableListOf<OemElement>()
var event = parser.next()
while (!(event == XmlPullParser.END_TAG && parser.name == "page") &&
event != XmlPullParser.END_DOCUMENT
) {
if (event == XmlPullParser.START_TAG) {
parseElement(parser)?.let(elements::add)
}
event = parser.next()
}
return OemPage(id, title, subtitle, icon, elements)
}
private fun parseElement(parser: XmlPullParser): OemElement? = when (parser.name) {
"section" -> OemElement.Section(attr(parser, "title").orEmpty())
// Element text/CDATA is not preserved in compiled res/xml, so text
// content is carried in the "value" attribute.
"text" -> OemElement.Text(attr(parser, "value").orEmpty())
"image" -> OemElement.Image(attr(parser, "url").orEmpty())
"toggle" -> OemElement.Toggle(
key = attr(parser, "key").orEmpty(),
title = attr(parser, "title").orEmpty(),
subtitle = attr(parser, "subtitle"),
default = attrBool(parser, "default", false),
target = attr(parser, "target")
)
"input" -> OemElement.Input(
key = attr(parser, "key").orEmpty(),
title = attr(parser, "title").orEmpty(),
hint = attr(parser, "hint"),
default = attr(parser, "default"),
target = attr(parser, "target")
)
"link" -> OemElement.Link(
label = attr(parser, "label").orEmpty(),
action = attr(parser, "action").orEmpty()
)
"choice" -> parseChoice(parser)
else -> null
}
private fun parseChoice(parser: XmlPullParser): OemElement.Choice {
val key = attr(parser, "key").orEmpty()
val title = attr(parser, "title").orEmpty()
val target = attr(parser, "target")
val default = attr(parser, "default")
val options = mutableListOf<OemElement.Choice.Option>()
var event = parser.next()
while (!(event == XmlPullParser.END_TAG && parser.name == "choice") &&
event != XmlPullParser.END_DOCUMENT
) {
if (event == XmlPullParser.START_TAG && parser.name == "option") {
options.add(
OemElement.Choice.Option(
value = attr(parser, "value").orEmpty(),
label = attr(parser, "label").orEmpty()
)
)
}
event = parser.next()
}
return OemElement.Choice(key, title, target, default, options)
}
private fun attr(parser: XmlPullParser, name: String): String? =
parser.getAttributeValue(null, name)
private fun attrInt(parser: XmlPullParser, name: String, def: Int): Int =
attr(parser, name)?.toIntOrNull() ?: def
private fun attrBool(parser: XmlPullParser, name: String, def: Boolean): Boolean =
attr(parser, name)?.let { it == "true" || it == "1" } ?: def
}
@@ -0,0 +1,49 @@
package me.pawlet.setupwizard.lib.oem
import android.content.Context
import android.provider.Settings
/**
* Loads OEM wizard pages from a config APK. Discovery order:
* 1. Settings.Global "pawlet.oem_wizard_pkg" — set by ConfigProvisioner when it
* detects res/xml/oem_wizard.xml in the (possibly OTA-updated) config APK.
* 2. Fallback to the default config APK package, so the pages still appear on
* the first boot before ConfigProvisioner's boot service has run.
* Only a package that resolves + parses to non-empty pages is used.
*/
object OemWizardRepository {
/** Settings.Global key ConfigProvisioner writes with the OEM page package. */
const val SIGNAL_KEY = "pawlet.oem_wizard_pkg"
private const val DEFAULT_CONFIG_PKG = "app.pawlet.config"
private const val XML_NAME = "oem_wizard"
fun load(context: Context): List<OemPage> {
val pkg = resolvePackage(context) ?: return emptyList()
return runCatching {
val res = context.packageManager.getResourcesForApplication(pkg)
val xmlId = res.getIdentifier(XML_NAME, "xml", pkg)
if (xmlId == 0) return emptyList()
val parser = res.getXml(xmlId)
try {
OemWizardParser.parse(parser).pages
} finally {
parser.close()
}
}.getOrDefault(emptyList())
}
private fun resolvePackage(context: Context): String? {
val signal = runCatching {
Settings.Global.getString(context.contentResolver, SIGNAL_KEY)
}.getOrNull()?.takeIf { it.isNotBlank() }
val pkg = signal ?: DEFAULT_CONFIG_PKG
return if (isInstalled(context, pkg)) pkg else null
}
private fun isInstalled(context: Context, pkg: String): Boolean = runCatching {
context.packageManager.getPackageInfo(pkg, 0)
true
}.getOrDefault(false)
}
@@ -23,6 +23,7 @@ import android.telephony.TelephonyManager
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import me.pawlet.setupwizard.lib.oem.OemPendingStore
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
@@ -589,7 +590,9 @@ class SetupUtils {
WallpaperManager.getInstance(context).forgetLoadedWallpaper()
disableHome(context)
// Restore the status bar that was locked for setup.
// Apply deferred OEM page selections (config APK), then restore the
// status bar that was locked for setup.
OemPendingStore.apply(context)
enableStatusBar(context)
// Notify partner/system apps (e.g. ConfigProvisioner) that setup is done
@@ -0,0 +1,196 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import me.pawlet.setupwizard.lib.oem.OemElement
import me.pawlet.setupwizard.lib.oem.OemPage
import me.pawlet.setupwizard.lib.oem.OemPendingStore
import me.pawlet.setupwizard.ui.components.WizardRadioOption
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import me.pawlet.setupwizard.ui.components.WizardToggleCard
/**
* Renders one OEM-provided [OemPage] (from the config APK) as a wizard step.
* Toggle/choice/input selections are recorded in [OemPendingStore] (with the
* OEM's defaults seeded on entry) and applied at finishSetupWizard. No code from
* the config APK runs — this only interprets the declarative model.
*/
@Composable
fun OemWizardScreen(
page: OemPage,
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
// Seed OEM defaults so they apply even if the user doesn't interact.
LaunchedEffect(page.id) {
page.elements.forEach { el ->
when (el) {
is OemElement.Toggle -> el.target?.let { OemPendingStore.seedBool(context, it, el.default) }
is OemElement.Choice -> if (el.target != null && el.default != null)
OemPendingStore.seedString(context, el.target, el.default)
is OemElement.Input -> if (el.target != null && el.default != null)
OemPendingStore.seedString(context, el.target, el.default)
else -> {}
}
}
}
WizardStepScaffold(
icon = iconFor(page.icon),
title = page.title,
subtitle = page.subtitle,
onContinue = onContinue,
onBack = onBack
) {
page.elements.forEach { el ->
when (el) {
is OemElement.Section -> Text(
el.title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
is OemElement.Text -> Text(
el.text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.85f)
)
is OemElement.Image -> AsyncImage(
model = el.url,
contentDescription = null,
modifier = Modifier.fillMaxWidth()
)
is OemElement.Toggle -> OemToggle(el)
is OemElement.Choice -> OemChoice(el)
is OemElement.Input -> OemInput(el)
is OemElement.Link -> WizardSecondaryButton(
label = el.label,
onClick = { launchOemAction(context, el.action) }
)
}
}
}
}
@Composable
private fun OemToggle(el: OemElement.Toggle) {
val context = LocalContext.current
var checked by remember {
mutableStateOf(el.target?.let { OemPendingStore.currentBool(context, it, el.default) } ?: el.default)
}
WizardToggleCard(
title = el.title,
subtitle = el.subtitle,
checked = checked,
onCheckedChange = {
checked = it
el.target?.let { t -> OemPendingStore.setBool(context, t, it) }
}
)
}
@Composable
private fun OemChoice(el: OemElement.Choice) {
val context = LocalContext.current
var selected by remember {
mutableStateOf(
el.target?.let { OemPendingStore.currentString(context, it, el.default.orEmpty()) }
?: el.default.orEmpty()
)
}
if (el.title.isNotEmpty()) {
Text(el.title, style = MaterialTheme.typography.titleSmall)
}
el.options.forEach { opt ->
WizardRadioOption(
label = opt.label,
selected = selected == opt.value,
onSelect = {
selected = opt.value
el.target?.let { t -> OemPendingStore.setString(context, t, opt.value) }
}
)
}
}
@Composable
private fun OemInput(el: OemElement.Input) {
val context = LocalContext.current
var value by remember {
mutableStateOf(
el.target?.let { OemPendingStore.currentString(context, it, el.default.orEmpty()) }
?: el.default.orEmpty()
)
}
OutlinedTextField(
value = value,
onValueChange = {
value = it
el.target?.let { t -> OemPendingStore.setString(context, t, it) }
},
label = { Text(el.title) },
placeholder = el.hint?.let { { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
private fun iconFor(name: String?): ImageVector = when (name) {
"cellular" -> Icons.Filled.SignalCellularAlt
"cloud" -> Icons.Filled.Cloud
"settings" -> Icons.Filled.Settings
"info" -> Icons.Filled.Info
"star" -> Icons.Filled.Star
"business" -> Icons.Filled.Business
"shield" -> Icons.Filled.Shield
else -> Icons.Filled.Extension
}
/**
* Launch a whitelisted action from a Link element. Only explicit component
* targets ("component:pkg/cls") and explicit actions ("intent:ACTION") are
* honoured; anything else is ignored.
*/
private fun launchOemAction(context: android.content.Context, action: String) {
val intent = when {
action.startsWith("component:") -> {
val spec = action.removePrefix("component:")
val slash = spec.indexOf('/')
if (slash <= 0) return
val pkg = spec.substring(0, slash)
var cls = spec.substring(slash + 1)
if (cls.startsWith(".")) cls = pkg + cls
Intent().setClassName(pkg, cls)
}
action.startsWith("intent:") -> Intent(action.removePrefix("intent:"))
else -> return
}.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
}