11 Commits
Author SHA1 Message Date
oxmc c1ec0ccee3 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:.
2026-07-18 07:40:06 -07:00
oxmc f636cd1a2a SetupWizard: hide + lock system UI during setup
Decouple the setup UI hiding from the managed kiosk lockdown:
- New WizardFlags.IMMERSIVE (default true): hide the status/nav bars during
  setup (sticky immersive) independent of KIOSK. KIOSK stays the aggressive
  managed lockdown (overlay window + non-sticky + future lock-task).
- BaseKioskActivity / MainActivity hide the bars when IMMERSIVE || KIOSK.
- Add StatusBarManager.setDisabledForSetup(true) on wizard start
  (EntryPoint + MainActivity) and setDisabledForSetup(false) in
  finishSetupWizard, so the shade/quick-settings/Home/Recents are actually
  locked during setup (matches LineageOS). Called via reflection so the
  module still builds under Gradle (the API is @SystemApi).
2026-07-18 02:20:18 -07:00
oxmc b328dbea85 SetupWizard: link okhttp3 + okio directly to fix R8 missing classes
coil-network-okhttp / coil-core declare okhttp3 and okio as static_libs,
but that transitive link (java_import jars through the Coil AAR imports)
does not reach this app's R8 program, so R8 reported okhttp3.*/okio.* as
missing and Coil's OkHttp fetcher would NoClassDefFoundError when loading
network images (device render from deviceInfo[4]). List okhttp3 and okio
directly in static_libs so their classes are in the dex.
2026-07-18 01:10:44 -07:00
oxmc 0453c772a7 SetupWizard: full step set, cleanup, theme, kiosk/deeplink off, OTA check
Steps: add Bluetooth, SIM-missing, Date/Time, Restore (auto-skip),
Location, microG, Screen-lock, Biometric, Navigation, Theme,
Privacy/Metrics, Device-specific, Recovery-update, and an OTA
update-check step, all on a shared WizardStepScaffold with DeviceProfile
gating. PartnerReceiver + SETUP_COMPLETE broadcast added.

microG: gate the step on microG's GmsCore being installed
(microGAvailable) rather than hasGMS(), which additionally requires
Google's setup wizard and is therefore always false on a microG build.

OTA: UpdateChecker mirrors the Updater's read-only check (server URL from
the Updater's own resources, same version/timestamp/release-type rules)
and delegates download + A/B install to the Updater (update_engine). Props
are read via reflection so the module still builds under Gradle.

Kiosk + deep links gated off via WizardFlags (immersive skipped entirely,
oxmc http/oxn VIEW filters removed from both manifests). Permissions gate
removed (platform-signed SETUP_WIZARD already holds what it needs).

Cleanup: purge Applebee's-mockup code (login/AuthManager, oxmcservers,
ManagerInfo/UserInfo, TemperatureUnit, SettingsScreen, weather + oxmc API
strings). Rework theme off the all-purple palette to neutral surfaces with
violet/pink/teal accents; fix white-on-primary button contrast.
2026-07-17 19:43:27 -07:00
oxmc ec943e82b9 SetupWizard: allowlist DOMAIN_VERIFICATION_AGENT privileged permission
The manifest declares android.permission.DOMAIN_VERIFICATION_AGENT
(signature|privileged) but it was missing from the privapp-permissions
allowlist. On enforcing builds PermissionManagerService.onSystemReady()
throws IllegalStateException for an unallowlisted privileged permission,
killing system_server every boot (bootanim loop). Add the entry.
2026-07-17 08:10:40 -07:00
oxmc 0d784ed55b Migrate to Coil 3.5.0
The tree pins prebuilt_libs_coil at 3.5.0 (coil3.* packages); move the
three image-loading screens to coil3.compose and add coil-network-okhttp
— Coil 3 core ships no network fetcher, so http(s) image URLs need it
(it self-registers through ServiceLoader). Gradle catalog bumped to the
matching io.coil-kt.coil3 coordinates.
2026-07-15 15:54:10 -07:00
oxmc f7f9adbc01 Fix the Soong build: restore library wiring, fix API 36 skew
- Android.bp: android-device-info is used by the wizard source (b11cf5b
  dropped it in error); re-add its android_library_import plus the
  core-splashscreen and coil-compose imports from
  prebuilts/application_libs, and a Soong-only BuildConfig stub under
  soong-stubs/ (Gradle generates its own, so the stub stays out of the
  Gradle source set).
- onNewIntent takes a non-null Intent on API 36.
- Region/Locale wizard pages pass a value-discarding lambda; the wizard
  only advances.
- SetupCompleteScreen: drawContent() needs the explicit drawWithContent
  receiver inside clipPath after the Compose receiver-scoping change.
2026-07-15 15:45:05 -07:00
oxmc 0caf9c7ef2 build: set minSdk=36 to match Android 16 target 2026-06-17 02:50:39 -07:00
oxmc ba7dabeb39 themes: replace Material3 view themes with platform DeviceDefault base
Theme.Material3.* and Theme.MaterialComponents.* are from the
Material view library which is not in the AOSP prebuilts.

The wizard is Compose-only so the XML theme only needs to control
window properties (background, transparent bars). All Material3
colour/typography tokens are applied inside Compose.

Also removes values-v21/v23/v31 overrides — minSdk=31 makes v21/v23
unreachable, and v31 only existed for the removed splash screen dep.
2026-06-17 02:47:37 -07:00
oxmc b11cf5b947 Android.bp: drop android-device-info (not used in wizard source) 2026-06-17 01:05:41 -07:00
oxmc 316043e9c0 Android.bp: drop core-splashscreen (not in AOSP prebuilts, not used) 2026-06-17 00:56:52 -07:00
57 changed files with 2242 additions and 1966 deletions
+21 -13
View File
@@ -1,16 +1,14 @@
// SPDX-FileCopyrightText: The PawletOS Project
// SPDX-License-Identifier: Apache-2.0
android_library_import {
name: "android-device-info",
aars: ["app/libs/android-device-info.aar"],
sdk_version: "current",
}
android_app {
name: "PawletSetupWizard",
srcs: ["app/src/main/java/**/*.kt"],
srcs: [
"app/src/main/java/**/*.kt",
// Soong-only BuildConfig stand-in (Gradle generates its own).
"soong-stubs/**/*.kt",
],
resource_dirs: ["app/src/main/res"],
@@ -61,12 +59,22 @@ android_app {
// Telephony helpers
"telephony-common",
// Device info library (local prebuilt)
// Device info library — source android_library from
// external/application_libs/AndroidDeviceInfo (the app/libs/ AAR is
// Gradle-only; defining an import here would collide with that module)
"android-device-info",
],
// OkHttp and Coil are not available as AOSP static_libs.
// Add them as android_library_import entries pointing to AARs in app/libs/
// once prebuilts are sourced from the Gradle dependency cache.
// See: https://git.oxmc.me/PawletOS/android_packages_apps_SetupWizard
// Images — Coil 3 from prebuilts/application_libs/coil. The network
// module is mandatory for http(s) image URLs (Coil 3 core ships no
// fetcher; the okhttp one self-registers via ServiceLoader).
"coil-compose",
"coil-network-okhttp",
// okhttp3/okio are static_libs of coil-network-okhttp/coil-core, but
// that transitive link (java_import jars through AAR imports) does not
// reach this app's R8 program, leaving okhttp3.*/okio.* as "Missing
// class" and breaking network image loading at runtime. List them
// directly so they're in the dex.
"okhttp3",
"okio",
],
}
+15 -27
View File
@@ -89,29 +89,10 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- oxmc domains: config APK downloads, OTA payloads, provisioning manifests -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="cdn.oxmc.me" />
<data android:scheme="https" android:host="cdn.oxmc.me" />
<data android:scheme="http" android:host="oxmc.me" />
<data android:scheme="https" android:host="oxmc.me" />
<data android:scheme="http" android:host="pawlet.oxmc.me" />
<data android:scheme="https" android:host="pawlet.oxmc.me" />
</intent-filter>
<!-- oxn:// internal scheme for system service → wizard IPC
oxn://apk?url=...&silent=true silent APK install during setup
oxn://page?id=... jump to a registered wizard page
oxn://mng management/provisioning commands -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="oxn" />
</intent-filter>
<!-- Deep links disabled for now (WizardFlags.DEEP_LINKS). The oxmc
http(s) VIEW filters and the oxn:// IPC scheme were removed so the
wizard is only launched as the SETUP_WIZARD HOME app. Re-add these
and flip WizardFlags.DEEP_LINKS to restore provisioning deep links. -->
</activity>
<activity
@@ -120,10 +101,6 @@
android:immersive="true"
android:excludeFromRecents="true" />
<activity
android:name=".activities.PermissionsActivity"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="me.pawlet.setupwizard.fileprovider"
@@ -133,5 +110,16 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Partner/provisioning hook: a trusted app holding FINISH_SETUP may
broadcast SETUP_FINISHED to complete setup without user input. -->
<receiver
android:name=".PartnerReceiver"
android:exported="true"
android:permission="me.pawlet.setupwizard.permission.FINISH_SETUP">
<intent-filter>
<action android:name="me.pawlet.setupwizard.SETUP_FINISHED" />
</intent-filter>
</receiver>
</application>
</manifest>
+3 -2
View File
@@ -10,7 +10,7 @@ android {
defaultConfig {
applicationId = "me.pawlet.setupwizard"
minSdk = 31
minSdk = 36
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
@@ -55,9 +55,10 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat)
// Coil (images)
// Coil 3 (images) — the network module is required for http(s) URLs
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.coil.network.okhttp)
// OkHttp (networking)
implementation(libs.okhttp)
+3 -28
View File
@@ -50,29 +50,9 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- oxmc domains — config APK downloads, OTA payloads, provisioning manifests -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="cdn.oxmc.me" />
<data android:scheme="https" android:host="cdn.oxmc.me" />
<data android:scheme="http" android:host="oxmc.me" />
<data android:scheme="https" android:host="oxmc.me" />
<data android:scheme="http" android:host="pawlet.oxmc.me" />
<data android:scheme="https" android:host="pawlet.oxmc.me" />
</intent-filter>
<!-- oxn:// — internal scheme for system service→wizard IPC
oxn://apk?url=...&silent=true — silent APK install during setup
oxn://page?id=... — jump to a specific wizard page
oxn://mng — management/provisioning commands -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="oxn" />
</intent-filter>
<!-- Deep links disabled for now (WizardFlags.DEEP_LINKS); see the
Soong AndroidManifest.xml. Re-add the oxmc http(s) VIEW filters
and the oxn:// scheme here to restore them. -->
</activity>
<!-- Main activity -->
@@ -80,11 +60,6 @@
android:exported="true">
</activity>
<!-- Permissions activity -->
<activity android:name="dev.oxmc.setupwizard.activities.PermissionsActivity"
android:exported="true">
</activity>
<!-- File provider -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -0,0 +1,39 @@
package me.pawlet.setupwizard
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import me.pawlet.setupwizard.lib.utils.SetupUtils
/**
* Partner customization / remote-finish hook.
*
* A trusted system app that holds me.pawlet.setupwizard.permission.FINISH_SETUP
* (e.g. ConfigProvisioner during zero-touch provisioning) can broadcast
* [ACTION_SETUP_FINISHED] to complete setup without user interaction. When setup
* completes, [SetupUtils.finishSetupWizard] fires [ACTION_SETUP_COMPLETE] so
* partner apps can apply their own customizations.
*/
class PartnerReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_SETUP_FINISHED -> {
Log.i(TAG, "Remote SETUP_FINISHED received; completing setup")
runCatching { SetupUtils().finishSetupWizard(context) }
.onFailure { Log.e(TAG, "finishSetupWizard failed", it) }
}
}
}
companion object {
private const val TAG = "PartnerReceiver"
/** Command: a trusted app asks the wizard to finish now. */
const val ACTION_SETUP_FINISHED = "me.pawlet.setupwizard.SETUP_FINISHED"
/** Notification: setup has completed; partners may customize. */
const val ACTION_SETUP_COMPLETE = "me.pawlet.setupwizard.SETUP_COMPLETE"
}
}
@@ -1,37 +0,0 @@
package me.pawlet.setupwizard
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import me.pawlet.setupwizard.lib.internal.PermissionManager
@SuppressLint("ObsoleteSdkInt")
fun Perms(context: Context): PermissionManager {
return PermissionManager.create(context) {
// Storage (Android 1112L, API 3032)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
addPermissions(Manifest.permission.READ_EXTERNAL_STORAGE)
setPermissionName(Manifest.permission.READ_EXTERNAL_STORAGE, "Read Storage")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Storage (Android 13+, API 33+)
addPermissions(Manifest.permission.READ_MEDIA_AUDIO)
setPermissionName(Manifest.permission.READ_MEDIA_AUDIO, "Read Audio")
}
}
// Notifications (Android 13+, API 33+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
addPermissions(Manifest.permission.POST_NOTIFICATIONS)
setPermissionName(Manifest.permission.POST_NOTIFICATIONS, "Show Notifications")
}
// Phone state (All versions)
addPermissions(Manifest.permission.READ_PHONE_STATE)
setPermissionName(Manifest.permission.READ_PHONE_STATE, "Read Phone State")
// Install packages (All versions)
includeInstallPermission(true)
}
}
@@ -1,55 +0,0 @@
package me.pawlet.setupwizard.activities
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import me.pawlet.setupwizard.ui.screens.LoginScreen
import me.pawlet.setupwizard.lib.internal.AuthManager
import me.pawlet.setupwizard.lib.internal.Helpers
import me.pawlet.setupwizard.lib.internal.PrefManager
import me.pawlet.setupwizard.ui.theme.MainTheme
import kotlinx.coroutines.launch
class LoginActivity : ComponentActivity() {
private lateinit var prefs: PrefManager
private lateinit var authManager: AuthManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = PrefManager(this)
authManager = AuthManager(this)
setContent {
MainTheme {
LoginScreen(
onLoginAttempt = { username, password, storeId, onComplete ->
handleLogin(username, password, storeId, onComplete)
}
)
}
}
}
private fun handleLogin(username: String, password: String, storeId: String, onComplete: (Boolean, String) -> Unit) {
lifecycleScope.launch {
val result = authManager.login(username, password, storeId)
if (result.success) {
onComplete(true, "Login successful")
prefs.saveBoolean("LOGIN_COMPLETE", true)
prefs.saveString("STORE_ID", storeId)
// Navigate to MainActivity
startActivity(Intent(this@LoginActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
})
finish()
} else {
onComplete(false, result.message)
Helpers.Notify.toast(this@LoginActivity, result.message)
}
}
}
}
@@ -13,28 +13,55 @@ import androidx.compose.runtime.getValue
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() {
private lateinit var wizardManager: SetupWizardManager
override fun onCreate(savedInstanceState: Bundle?) {
FullScreenHelper.prepareKioskWindow(this)
FullScreenHelper.enableKioskMode(this)
// Kiosk needs the overlay window type set before setContent; the actual
// bar-hiding (immersive or kiosk) is applied in onResume via
// BaseKioskActivity for both KIOSK and IMMERSIVE.
if (WizardFlags.KIOSK) {
FullScreenHelper.prepareKioskWindow(this)
}
super.onCreate(savedInstanceState)
// Lock the status bar (shade/home/recents) for the duration of setup;
// restored in SetupUtils.finishSetupWizard().
if (WizardFlags.KIOSK || WizardFlags.IMMERSIVE) {
SetupUtils().disableStatusBarForSetup(this)
}
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 {
@@ -43,7 +70,10 @@ class MainActivity : BaseKioskActivity() {
val deviceInfo by wizardManager.deviceInfo.collectAsState()
LaunchedEffect(index) {
FullScreenHelper.enableKioskMode(this@MainActivity)
// Re-assert hidden system bars when the page changes.
if (WizardFlags.KIOSK || WizardFlags.IMMERSIVE) {
FullScreenHelper.onResume(this@MainActivity, WizardFlags.KIOSK)
}
}
when (overlay) {
@@ -1,221 +0,0 @@
package me.pawlet.setupwizard.activities
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.lib.internal.PermissionManager
import me.pawlet.setupwizard.lib.internal.PrefManager
import me.pawlet.setupwizard.ui.theme.MainTheme
import androidx.core.net.toUri
import me.pawlet.setupwizard.EntryPoint
import me.pawlet.setupwizard.Perms
import kotlin.text.get
class PermissionsActivity : ComponentActivity() {
private lateinit var prefs: PrefManager
private lateinit var permissionManager: PermissionManager
// Launchers for permission requests
private lateinit var singlePermissionLauncher: ActivityResultLauncher<String>
private lateinit var multiplePermissionLauncher: ActivityResultLauncher<Array<String>>
private lateinit var installPermissionLauncher: ActivityResultLauncher<Intent>
// Track which permission is being requested (for single permission launcher)
private var currentRequestedPermission: String? = null
// Track permission states
private val permissionStates = mutableStateMapOf<String, Boolean>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = PrefManager(this)
// Create permission manager with the permissions you need
permissionManager = Perms(this)
// Initialize permission states
permissionManager.allPermissions.forEach { permission ->
permissionStates[permission] = permissionManager.isPermissionGranted(permission)
}
// Setup launchers
singlePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
// Update the specific permission that was requested
currentRequestedPermission?.let { permission ->
// Update the state with the actual permission check
permissionStates[permission] = permissionManager.isPermissionGranted(permission)
currentRequestedPermission = null
}
}
multiplePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
// Update ALL permission states with fresh checks
permissionManager.allPermissions.forEach { permission ->
permissionStates[permission] = permissionManager.isPermissionGranted(permission)
}
}
installPermissionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
// Update install permission state with fresh check
permissionStates[PermissionManager.INSTALL_PERMISSION_KEY] =
permissionManager.isPermissionGranted(PermissionManager.INSTALL_PERMISSION_KEY)
}
// REMOVED the auto-continue check - user must press continue button
setContent {
MainTheme {
PermissionsScreen(
permissionManager = permissionManager,
permissionStates = permissionStates,
onRequestPermission = ::requestPermission,
onRequestAllPermissions = ::requestAllPermissions,
onContinue = ::finishWithSuccess
)
}
}
}
private fun requestPermission(permission: String) {
if (permission == PermissionManager.INSTALL_PERMISSION_KEY) {
// Handle install permission separately
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:$packageName".toUri()
}
installPermissionLauncher.launch(intent)
} else {
// Store which permission we're requesting and launch the request
currentRequestedPermission = permission
singlePermissionLauncher.launch(permission)
}
}
private fun requestAllPermissions() {
permissionManager.requestAllMissingPermissions(multiplePermissionLauncher)
// Also request install permission if needed
if (permissionManager.config.includeInstallPermission &&
!permissionManager.isPermissionGranted(PermissionManager.INSTALL_PERMISSION_KEY)) {
requestPermission(PermissionManager.INSTALL_PERMISSION_KEY)
}
}
private fun finishWithSuccess() {
prefs.hasCompletedInitialPermissions = true
startActivity(Intent(this, EntryPoint::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
})
finish()
}
@Composable
fun PermissionsScreen(
permissionManager: PermissionManager,
permissionStates: Map<String, Boolean>,
onRequestPermission: (String) -> Unit,
onRequestAllPermissions: () -> Unit,
onContinue: () -> Unit
) {
// Calculate all granted state based on current permission states
val allGranted = permissionManager.allPermissions.all { permission ->
permissionStates[permission] == true
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Required Permissions",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(32.dp))
// Display each permission
permissionManager.allPermissions.forEach { permission ->
val displayName = permissionManager.permissionDisplayNames[permission] ?: permission
val granted = permissionStates[permission] ?: false
PermissionRequestItem(
name = displayName,
granted = granted,
onRequest = { onRequestPermission(permission) }
)
Spacer(Modifier.height(16.dp))
}
// Request all button
if (!allGranted) {
Button(
onClick = onRequestAllPermissions,
modifier = Modifier.fillMaxWidth()
) {
Text("Allow All Permissions")
}
Spacer(Modifier.height(16.dp))
}
// Continue button
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
enabled = allGranted
) {
Text(if (allGranted) "Continue to App" else "Complete all permissions to continue")
}
}
}
}
@Composable
private fun PermissionRequestItem(
name: String,
granted: Boolean,
onRequest: () -> Unit
) {
Button(
onClick = onRequest,
enabled = !granted,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (granted) "$name - Granted" else "Allow $name",
maxLines = 1
)
}
}
}
@@ -10,12 +10,12 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import me.pawlet.setupwizard.lib.ApkManager
import me.pawlet.setupwizard.lib.FullScreenHelper
import me.pawlet.setupwizard.activities.MainActivity
import me.pawlet.setupwizard.activities.PermissionsActivity
import me.pawlet.setupwizard.lib.BaseKioskActivity
import me.pawlet.setupwizard.lib.SetupWizardManager
import me.pawlet.setupwizard.lib.internal.PermissionManager
import me.pawlet.setupwizard.lib.WizardFlags
import me.pawlet.setupwizard.lib.internal.PrefManager
import me.pawlet.setupwizard.lib.internal.UriHandler
import me.pawlet.setupwizard.lib.utils.SetupUtils
import me.pawlet.setupwizard.ui.screens.SplashScreen
import me.pawlet.setupwizard.ui.theme.MainTheme
import kotlinx.coroutines.runBlocking
@@ -24,7 +24,6 @@ class EntryPoint : BaseKioskActivity() {
private lateinit var prefs: PrefManager
private lateinit var uriHandler: UriHandler
private lateinit var apkManager: ApkManager
private lateinit var permissionManager: PermissionManager
private lateinit var wizardManager: SetupWizardManager
@SuppressLint("ObsoleteSdkInt")
@@ -43,11 +42,15 @@ class EntryPoint : BaseKioskActivity() {
super.onCreate(savedInstanceState)
// Lock the status bar for setup as early as the splash.
if (WizardFlags.KIOSK || WizardFlags.IMMERSIVE) {
SetupUtils().disableStatusBarForSetup(this)
}
// Initialize managers
prefs = PrefManager(this)
uriHandler = UriHandler(this)
apkManager = ApkManager(this)
permissionManager = Perms(this)
wizardManager = SetupWizardManager.getInstance(this)
// Preload device info synchronously before deciding which splash to show
@@ -55,8 +58,8 @@ class EntryPoint : BaseKioskActivity() {
wizardManager.loadDeviceInfo()
}
// Handle deep link routing first
if (uriHandler.handleInitialIntent(intent)) {
// Handle deep link routing first (disabled unless WizardFlags.DEEP_LINKS)
if (WizardFlags.DEEP_LINKS && uriHandler.handleInitialIntent(intent)) {
finish()
return
}
@@ -76,17 +79,17 @@ class EntryPoint : BaseKioskActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
uriHandler.handleIntent(intent)
if (WizardFlags.DEEP_LINKS) uriHandler.handleIntent(intent)
}
private fun proceed() {
val targetActivity = if (prefs.hasCompletedInitialPermissions &&
permissionManager.areAllPermissionsGranted()
) MainActivity::class.java else PermissionsActivity::class.java
startActivity(Intent(this, targetActivity).apply {
// Runtime-permission gate removed: as the platform-signed, privileged
// SETUP_WIZARD package, the app already holds the permissions it needs
// (signature perms + DefaultPermissionGrantPolicy grants), so go straight
// into the wizard.
startActivity(Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
})
finish()
@@ -50,47 +50,6 @@ data class OTAInfoCard(
val onClick: () -> Unit = {}
)
// User data classes
data class UserInfo(
val username: String,
val permissions: List<String>,
val token: String? = null,
val expiration: Long? = null
)
// Unified Manager data class for both UI and API
data class ManagerInfo(
val name: String,
val pronouns: String,
val picture: String,
val storeId: String,
val storeName: String,
val setAt: String = "",
val setBy: String = ""
)
// UI-specific version of ManagerInfo
data class ManagerDetail(
val name: String,
val pronouns: String,
val picture: String,
val storeId: String = "",
val storeName: String = ""
) {
// Convert from ManagerInfo to ManagerDetail
companion object {
fun fromManagerInfo(info: ManagerInfo): ManagerDetail {
return ManagerDetail(
name = info.name,
pronouns = info.pronouns,
picture = info.picture,
storeId = info.storeId,
storeName = info.storeName
)
}
}
}
// Data class for update information
data class UpdateInfo(
val latestVersion: String,
@@ -1,7 +1,10 @@
package me.pawlet.setupwizard.lib
package me.pawlet.setupwizard.lib
import android.app.backup.BackupManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import me.pawlet.setupwizard.lib.utils.SetupUtils
enum class FormFactor { PHONE, TABLET, EMBEDDED }
@@ -10,6 +13,15 @@ data class DeviceProfile(
val hasWifi: Boolean,
val hasTelephony: Boolean,
val hasLeanback: Boolean,
val hasBluetooth: Boolean,
val hasLocation: Boolean,
val hasBiometric: Boolean,
val hasGms: Boolean,
val microGAvailable: Boolean,
val simPresent: Boolean,
val hasRecoveryUpdater: Boolean,
val deviceSpecificAvailable: Boolean,
val backupAvailable: Boolean,
val vendorId: String
) {
val isEmbedded: Boolean get() = formFactor == FormFactor.EMBEDDED
@@ -17,11 +29,40 @@ data class DeviceProfile(
val isTablet: Boolean get() = formFactor == FormFactor.TABLET
companion object {
/** OEM/device-specific setup hook other apps may implement. */
const val ACTION_DEVICE_SPECIFIC = "me.pawlet.setupwizard.DEVICE_SPECIFIC"
fun detect(context: Context): DeviceProfile {
val pm = context.packageManager
val utils = SetupUtils()
val hasWifi = pm.hasSystemFeature(PackageManager.FEATURE_WIFI)
val hasTelephony = pm.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
val hasLeanback = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
val hasBluetooth = pm.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)
val hasLocation = pm.hasSystemFeature(PackageManager.FEATURE_LOCATION)
val hasBiometric = runCatching { utils.hasBiometric(context) }.getOrDefault(false)
// hasGms is true only when the *Google* setup wizard is present too;
// microGAvailable is true whenever microG's GmsCore is installed,
// which is what gates the in-wizard microG step on a Pawlet build.
val hasGms = runCatching { utils.hasGMS(context) }.getOrDefault(false)
val microGAvailable = runCatching {
utils.isPackageInstalled(context, SetupUtils.GMS_PACKAGE)
}.getOrDefault(false)
val simPresent =
hasTelephony && runCatching { !utils.simMissing(context) }.getOrDefault(false)
val hasRecoveryUpdater =
runCatching { utils.hasRecoveryUpdater(context) }.getOrDefault(false)
val deviceSpecificAvailable = runCatching {
pm.resolveActivity(Intent(ACTION_DEVICE_SPECIFIC), 0) != null
}.getOrDefault(false)
// No backup transport ships today, so this stays false and the
// Restore step auto-skips until one is configured.
val backupAvailable =
runCatching { BackupManager(context).isBackupEnabled }.getOrDefault(false)
val formFactor = when {
hasLeanback -> FormFactor.EMBEDDED
@@ -39,6 +80,15 @@ data class DeviceProfile(
hasWifi = hasWifi,
hasTelephony = hasTelephony,
hasLeanback = hasLeanback,
hasBluetooth = hasBluetooth,
hasLocation = hasLocation,
hasBiometric = hasBiometric,
hasGms = hasGms,
microGAvailable = microGAvailable,
simPresent = simPresent,
hasRecoveryUpdater = hasRecoveryUpdater,
deviceSpecificAvailable = deviceSpecificAvailable,
backupAvailable = backupAvailable,
vendorId = vendorId
)
}
@@ -6,11 +6,18 @@ import me.pawlet.setupwizard.lib.FullScreenHelper
open class BaseKioskActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
FullScreenHelper.onResume(this, true)
// Hide the system bars during setup (immersive), or full kiosk when
// enabled. onResume(_, isKioskMode) hides the bars either way; the bool
// only picks sticky-immersive vs non-sticky kiosk style.
if (WizardFlags.KIOSK || WizardFlags.IMMERSIVE) {
FullScreenHelper.onResume(this, WizardFlags.KIOSK)
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
FullScreenHelper.onWindowFocusChanged(this, hasFocus, true)
if (WizardFlags.KIOSK || WizardFlags.IMMERSIVE) {
FullScreenHelper.onWindowFocusChanged(this, hasFocus, WizardFlags.KIOSK)
}
}
}
@@ -0,0 +1,172 @@
package me.pawlet.setupwizard.lib
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
/**
* Lightweight OTA update check for the setup wizard.
*
* This replicates ONLY the read-only "is there a newer build?" logic of the
* Updater app (me.pawlet.updater): same server URL, same property substitution,
* and the same [isCompatible] rules (version / timestamp / release-type). The
* actual download + A/B install is NOT done here — it is delegated to the
* Updater via [launchUpdater] so update_engine owns the install. Keeping the
* server URL sourced from the Updater's own resources avoids format drift.
*
* Property access goes through reflection (not android.os.SystemProperties)
* because this module is also compiled by the Gradle build, where the platform
* SystemProperties class is not on the SDK classpath.
*/
object UpdateChecker {
private const val UPDATER_PACKAGE = "me.pawlet.updater"
private const val UPDATER_SERVER_URL_RES = "updater_server_url"
// Mirrors me.pawlet.updater.misc.Constants — keep in sync with the Updater.
private const val PROP_BUILD_VERSION = "ro.pawlet.build.version"
private const val PROP_BUILD_DATE = "ro.build.date.utc"
private const val PROP_RELEASE_TYPE = "ro.pawlet.releasetype"
private const val PROP_DEVICE = "ro.product.device"
private const val PROP_NEXT_DEVICE = "ro.updater.next_device"
private const val PROP_BUILD_VERSION_INCREMENTAL = "ro.build.version.incremental"
private const val PROP_UPDATER_URI = "pawlet.updater.uri"
private const val PROP_ALLOW_DOWNGRADING = "pawlet.updater.allow_downgrading"
// Fallback only; the real value is read from the Updater's resources.
private const val DEFAULT_SERVER_URL =
"https://oxmc.me/apis/aosp/ota.php?mode=sysup&device={device}&type={type}&sn={sn}"
data class AvailableUpdate(
val downloadId: String,
val version: String,
val filename: String,
val romType: String,
val sizeBytes: Long,
val downloadUrl: String,
val timestamp: Long
)
/**
* Returns the newest compatible update, or null if the device is up to date,
* offline, or the check fails. Safe to call from the UI via a coroutine.
*/
suspend fun check(context: Context): AvailableUpdate? = withContext(Dispatchers.IO) {
if (!hasValidatedInternet(context)) return@withContext null
val url = buildServerUrl(context) ?: return@withContext null
val body = runCatching { httpGet(url) }.getOrNull() ?: return@withContext null
val candidates = runCatching { parseCompatible(body) }.getOrDefault(emptyList())
candidates.maxByOrNull { it.timestamp }
}
/** Hand off to the Updater app for the actual download + A/B install. */
fun launchUpdater(context: Context) {
val settings = Intent("android.settings.SYSTEM_UPDATE_SETTINGS")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(settings) }.onFailure {
runCatching {
context.packageManager.getLaunchIntentForPackage(UPDATER_PACKAGE)
?.let { context.startActivity(it) }
}
}
}
// --- internals ---
private fun hasValidatedInternet(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
private fun buildServerUrl(context: Context): String? {
val device = prop(PROP_NEXT_DEVICE).ifBlank { prop(PROP_DEVICE) }
if (device.isBlank()) return null
val type = prop(PROP_RELEASE_TYPE).lowercase()
val incr = prop(PROP_BUILD_VERSION_INCREMENTAL)
val serial = runCatching { Build.getSerial() }.getOrDefault(Build.UNKNOWN)
.let { if (it == Build.UNKNOWN) "" else it }
val template = prop(PROP_UPDATER_URI).ifBlank { updaterServerUrlFromResources(context) }
return template
.replace("{device}", device)
.replace("{type}", type)
.replace("{incr}", incr)
.replace("{sn}", serial)
}
private fun updaterServerUrlFromResources(context: Context): String = runCatching {
val res = context.packageManager.getResourcesForApplication(UPDATER_PACKAGE)
val id = res.getIdentifier(UPDATER_SERVER_URL_RES, "string", UPDATER_PACKAGE)
if (id != 0) res.getString(id) else DEFAULT_SERVER_URL
}.getOrDefault(DEFAULT_SERVER_URL)
private fun httpGet(url: String): String {
val conn = URL(url).openConnection() as HttpURLConnection
return conn.run {
requestMethod = "GET"
connectTimeout = 10_000
readTimeout = 10_000
try {
if (responseCode != HttpURLConnection.HTTP_OK) error("HTTP $responseCode")
inputStream.bufferedReader().use { it.readText() }
} finally {
disconnect()
}
}
}
/** Parse the "response" array and keep only compatible, newer builds. */
private fun parseCompatible(json: String): List<AvailableUpdate> {
val curVersion = prop(PROP_BUILD_VERSION)
val curDate = propLong(PROP_BUILD_DATE)
val curType = prop(PROP_RELEASE_TYPE)
val allowDowngrade = propBool(PROP_ALLOW_DOWNGRADING)
val arr = JSONObject(json).optJSONArray("response") ?: return emptyList()
val out = ArrayList<AvailableUpdate>()
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val version = o.optString("version")
val romType = o.optString("romtype")
val timestamp = o.optLong("datetime")
// Same rules as Updater Utils.isCompatible():
if (version.compareTo(curVersion) < 0) continue
if (!allowDowngrade && timestamp <= curDate) continue
if (!romType.equals(curType, ignoreCase = true)) continue
out += AvailableUpdate(
downloadId = o.optString("id"),
version = version,
filename = o.optString("filename"),
romType = romType,
sizeBytes = o.optLong("size"),
downloadUrl = o.optString("url"),
timestamp = timestamp
)
}
return out
}
private fun prop(key: String, def: String = ""): String = try {
@Suppress("DiscouragedPrivateApi")
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java, String::class.java)
.invoke(null, key, def) as String
} catch (_: Exception) { def }
private fun propLong(key: String, def: Long = 0L): Long = prop(key).toLongOrNull() ?: def
private fun propBool(key: String): Boolean = prop(key).let { it == "true" || it == "1" }
}
@@ -0,0 +1,27 @@
package me.pawlet.setupwizard.lib
/**
* Build-time feature switches for the setup wizard. Flipped here rather than
* scattered through the code so they're easy to re-enable.
*/
object WizardFlags {
/**
* Kiosk lock-down: overlay window type + non-sticky immersive + (future)
* lock-task, so the user cannot leave or reveal the bars. Intended for
* managed/enrolled devices. Off for the normal first-run experience.
*/
const val KIOSK = false
/**
* Hide the system bars (status + navigation) during setup — standard
* first-run behaviour, independent of the managed KIOSK lockdown. Uses
* sticky immersive when KIOSK is off. Turn off only for debugging.
*/
const val IMMERSIVE = true
/**
* Deep-link / URI entry points (oxn:// IPC scheme and http(s) VIEW links).
* Off for now — the wizard is launched only as the SETUP_WIZARD HOME app.
*/
const val DEEP_LINKS = false
}
@@ -1,11 +1,31 @@
package me.pawlet.setupwizard.lib
package me.pawlet.setupwizard.lib
import me.pawlet.setupwizard.ui.screens.wizard.BiometricScreen
import me.pawlet.setupwizard.ui.screens.wizard.BluetoothScreen
import me.pawlet.setupwizard.ui.screens.wizard.ConnectionSetupScreen
import me.pawlet.setupwizard.ui.screens.wizard.DateTimeScreen
import me.pawlet.setupwizard.ui.screens.wizard.DeviceSpecificScreen
import me.pawlet.setupwizard.ui.screens.wizard.LocaleSelectionScreen
import me.pawlet.setupwizard.ui.screens.wizard.LocationScreen
import me.pawlet.setupwizard.ui.screens.wizard.MicroGScreen
import me.pawlet.setupwizard.ui.screens.wizard.NavigationScreen
import me.pawlet.setupwizard.ui.screens.wizard.PrivacyScreen
import me.pawlet.setupwizard.ui.screens.wizard.RecoveryUpdateScreen
import me.pawlet.setupwizard.ui.screens.wizard.RegionSelectionScreen
import me.pawlet.setupwizard.ui.screens.wizard.RestoreScreen
import me.pawlet.setupwizard.ui.screens.wizard.ScreenLockScreen
import me.pawlet.setupwizard.ui.screens.wizard.SetupCompleteScreen
import me.pawlet.setupwizard.ui.screens.wizard.SimMissingScreen
import me.pawlet.setupwizard.ui.screens.wizard.ThemeScreen
import me.pawlet.setupwizard.ui.screens.wizard.UpdateCheckScreen
import me.pawlet.setupwizard.ui.screens.wizard.WelcomeScreen
/**
* The full built-in step list. Each page declares its own [WizardPage.order] and
* an optional [WizardPage.shouldShow] predicate; SetupWizardManager filters by
* DeviceProfile and sorts by order, so steps that don't apply to the hardware
* (e.g. SIM/biometric on a Raspberry Pi) simply drop out of the flow.
*/
fun buildBuiltinPages(
onFinish: () -> Unit,
onSecretUnlocked: () -> Unit
@@ -17,18 +37,36 @@ fun buildBuiltinPages(
WelcomeScreen(onGetStartedClick = onNext, onSecretUnlocked = onSecretUnlocked)
}
),
WizardPage(
id = "bluetooth",
order = 5,
shouldShow = { it.hasBluetooth },
content = { onNext, onBack ->
BluetoothScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "region",
order = 10,
content = { onNext, onBack ->
RegionSelectionScreen(onBack = onBack, onContinue = onNext)
// The screen persists the chosen region itself; the wizard only advances.
RegionSelectionScreen(onBack = onBack, onContinue = { onNext() })
}
),
WizardPage(
id = "locale",
order = 20,
content = { onNext, onBack ->
LocaleSelectionScreen(onBack = onBack, onContinue = onNext)
LocaleSelectionScreen(onBack = onBack, onContinue = { onNext() })
}
),
WizardPage(
id = "sim_missing",
order = 25,
// Only on telephony hardware with no SIM present (never on RPi).
shouldShow = { it.hasTelephony && !it.simPresent },
content = { onNext, onBack ->
SimMissingScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
@@ -40,6 +78,100 @@ fun buildBuiltinPages(
ConnectionSetupScreen(onBack = onBack, onContinue = onNext)
}
),
WizardPage(
id = "update_check",
order = 35,
// Runs after the connection step; self-skips when offline or up to date.
content = { onNext, onBack ->
UpdateCheckScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "datetime",
order = 40,
content = { onNext, onBack ->
DateTimeScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "restore",
order = 45,
// Auto-skips until a backup transport is configured.
shouldShow = { it.backupAvailable },
content = { onNext, onBack ->
RestoreScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "location",
order = 50,
shouldShow = { it.hasLocation },
content = { onNext, onBack ->
LocationScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "microg",
order = 55,
// Gate on microG's GmsCore being present (NOT hasGms, which additionally
// requires Google's own setup wizard — never present on a microG build).
shouldShow = { it.microGAvailable },
content = { onNext, onBack ->
MicroGScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "screenlock",
order = 60,
content = { onNext, onBack ->
ScreenLockScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "biometric",
order = 65,
shouldShow = { it.hasBiometric },
content = { onNext, onBack ->
BiometricScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "navigation",
order = 70,
content = { onNext, onBack ->
NavigationScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "theme",
order = 75,
content = { onNext, onBack ->
ThemeScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "privacy",
order = 80,
content = { onNext, onBack ->
PrivacyScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "device_specific",
order = 85,
shouldShow = { it.deviceSpecificAvailable },
content = { onNext, onBack ->
DeviceSpecificScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "recovery_update",
order = 90,
shouldShow = { it.hasRecoveryUpdater },
content = { onNext, onBack ->
RecoveryUpdateScreen(onContinue = onNext, onBack = onBack)
}
),
WizardPage(
id = "complete",
order = 100,
@@ -1,260 +0,0 @@
package me.pawlet.setupwizard.lib.internal
import android.content.Context
import android.util.Log
import me.pawlet.setupwizard.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONException
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
data class LoginResult(
val success: Boolean,
val message: String,
val token: String? = null
)
class AuthManager(private val context: Context) {
private val prefs = PrefManager(context)
private val baseUrl = context.getString(R.string.oxmc_api_url)
companion object {
private const val PREF_STORE_ID = "store_id"
private const val PREF_LOGIN_COMPLETE = "login_complete"
}
/**
* Performs login with email and password, saves token and store ID
*/
suspend fun login(email: String, password: String, storeId: String): LoginResult {
return withContext(Dispatchers.IO) {
try {
val url = URL("$baseUrl/login")
val connection = url.openConnection() as HttpURLConnection
connection.apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
connectTimeout = 10000
readTimeout = 10000
}
// Send login credentials
val jsonBody = JSONObject().apply {
put("email", email)
put("password", password)
}
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(jsonBody.toString())
writer.flush()
}
val responseCode = connection.responseCode
val response = if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader(InputStreamReader(connection.inputStream)).use { it.readText() }
} else {
BufferedReader(InputStreamReader(connection.errorStream)).use { it.readText() }
}
connection.disconnect()
// Try to parse as JSON, handle non-JSON responses
val jsonResponse = try {
JSONObject(response)
} catch (e: JSONException) {
// If response is not JSON, create a JSON object with the raw response as message
JSONObject().apply {
put("success", false)
put("message", response.takeIf { it.isNotBlank() } ?: "Unknown error")
}
}
if (responseCode == HttpURLConnection.HTTP_OK && jsonResponse.optBoolean("success", false)) {
val token = jsonResponse.optString("token")
// Save credentials and store ID
prefs.saveString("auth_token", token)
prefs.saveString("store_id", storeId)
prefs.saveString("email", email)
prefs.saveBoolean("is_logged_in", true)
Log.i("AuthManager", "Login successful for user: $email, store: $storeId")
LoginResult(
success = true,
message = "Login successful",
token = token
)
} else {
val errorMessage = jsonResponse.optString("message", "Login failed")
Log.e("AuthManager", "Login failed: $errorMessage")
LoginResult(success = false, message = errorMessage)
}
} catch (e: Exception) {
Log.e("AuthManager", "Login error: ${e.message}", e)
LoginResult(
success = false,
message = "Connection error: ${e.message ?: "Unknown error"}"
)
}
}
}
// Call this when login is successful
fun saveLoginState(storeId: String) {
prefs.saveBoolean(PREF_LOGIN_COMPLETE, true)
prefs.saveString(PREF_STORE_ID, storeId)
}
// Check if login is complete
fun isLoginComplete(): Boolean {
return prefs.getBoolean(PREF_LOGIN_COMPLETE, false)
}
// Clear login state (for logout)
fun clearLoginState() {
prefs.saveBoolean(PREF_LOGIN_COMPLETE, false)
prefs.saveString(PREF_STORE_ID, "")
}
/**
* Validates the current token with the server and handles automatic renewal
*/
suspend fun validateToken(): Boolean {
return withContext(Dispatchers.IO) {
try {
val token = prefs.getString("auth_token", null) ?: return@withContext false
val url = URL("$baseUrl/check-token")
val connection = url.openConnection() as HttpURLConnection
connection.apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
connectTimeout = 10000
readTimeout = 10000
}
val jsonBody = JSONObject().apply {
put("token", token)
}
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(jsonBody.toString())
writer.flush()
}
val responseCode = connection.responseCode
val response = BufferedReader(InputStreamReader(
if (responseCode == HttpURLConnection.HTTP_OK) connection.inputStream
else connection.errorStream
)).use { it.readText() }
connection.disconnect()
val jsonResponse = JSONObject(response)
val isValid = responseCode == HttpURLConnection.HTTP_OK &&
jsonResponse.optBoolean("success", false)
if (isValid) {
// Check if server provided a renewed token
val newToken = jsonResponse.optString("token", null)
val renewed = jsonResponse.optBoolean("renewed", false)
if (!newToken.isNullOrEmpty() && renewed) {
prefs.saveString("auth_token", newToken)
Log.i("AuthManager", "Token automatically renewed")
}
} else {
Log.w("AuthManager", "Token validation failed, clearing auth data")
clearAuthData()
}
isValid
} catch (e: Exception) {
Log.e("AuthManager", "Token validation error: ${e.message}", e)
false
}
}
}
/**
* Checks for token renewal in response headers and updates if present
* Call this after every authenticated API request
*/
fun checkAndUpdateTokenFromHeaders(connection: HttpURLConnection) {
try {
val newToken = connection.getHeaderField("X-New-Token")
if (!newToken.isNullOrEmpty()) {
prefs.saveString("auth_token", newToken)
Log.i("AuthManager", "Token automatically renewed from header")
}
} catch (e: Exception) {
Log.e("AuthManager", "Error checking token renewal: ${e.message}", e)
}
}
/**
* Gets the current auth token
*/
fun getToken(): String? {
return prefs.getString("auth_token", null)
}
/**
* Gets the current store ID
*/
fun getStoreId(): String? {
return prefs.getString("store_id", null)
}
/**
* Gets the current username
*/
fun getUsername(): String? {
return prefs.getString("username", null)
}
/**
* Checks if user is logged in
*/
fun isLoggedIn(): Boolean {
return prefs.getBoolean("is_logged_in", false) &&
getToken() != null &&
getStoreId() != null
}
/**
* Logs out the user and clears all auth data
*/
fun logout() {
clearAuthData()
Log.i("AuthManager", "User logged out")
}
/**
* Clears all authentication data
*/
private fun clearAuthData() {
prefs.remove("auth_token")
prefs.remove("store_id")
prefs.remove("username")
prefs.saveBoolean("is_logged_in", false)
}
/**
* Creates authorization header for API requests
*/
fun getAuthHeader(): String {
val token = getToken() ?: ""
return "Bearer $token"
}
}
@@ -35,20 +35,9 @@ import kotlin.coroutines.resume
import me.pawlet.setupwizard.lib.Region
import java.util.Currency
enum class TemperatureUnit(val displayName: String) {
FAHRENHEIT("Fahrenheit (°F)"),
CELSIUS("Celsius (°C)")
}
class Helpers {
companion object {
val arch: String get() = Build.SUPPORTED_ABIS.first().replace("-v", "_v")
fun convertTemperature(fahrenheit: Double, unit: TemperatureUnit): String {
return when (unit) {
TemperatureUnit.FAHRENHEIT -> "${fahrenheit.toInt()}°F"
TemperatureUnit.CELSIUS -> "${((fahrenheit - 32) * 5 / 9).toInt()}°C"
}
}
fun getSystemLocales(): List<Locale> {
return Locale.getAvailableLocales()
.distinctBy { it.toLanguageTag() }
@@ -1,283 +0,0 @@
package me.pawlet.setupwizard.lib.internal
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
class PermissionManager private constructor(
private val context: Context,
val config: Config
) {
data class Config(
val permissions: Set<String> = emptySet(),
val includeInstallPermission: Boolean = false,
val autoIncludeDependencies: Boolean = true,
val customPermissionNames: Map<String, String> = emptyMap()
)
class Builder(private val context: Context) {
private val permissions = mutableSetOf<String>()
private var includeInstallPermission = false
private var autoIncludeDependencies = true
private val customPermissionNames = mutableMapOf<String, String>()
/**
* Add a single permission to request
*/
fun addPermission(permission: String) = apply {
permissions.add(permission)
}
/**
* Add multiple permissions to request
*/
fun addPermissions(vararg permissions: String) = apply {
this.permissions.addAll(permissions)
}
/**
* Add permissions from a collection
*/
fun addPermissions(permissions: Collection<String>) = apply {
this.permissions.addAll(permissions)
}
/**
* Include the "Install Unknown Apps" permission (Android O+)
*/
fun includeInstallPermission(include: Boolean = true) = apply {
this.includeInstallPermission = include
}
/**
* Automatically include permission dependencies (e.g., location for Bluetooth)
*/
fun autoIncludeDependencies(autoInclude: Boolean = true) = apply {
this.autoIncludeDependencies = autoInclude
}
/**
* Set a custom display name for a permission
*/
fun setPermissionName(permission: String, displayName: String) = apply {
customPermissionNames[permission] = displayName
}
/**
* Set custom display names for multiple permissions
*/
fun setPermissionNames(names: Map<String, String>) = apply {
customPermissionNames.putAll(names)
}
fun build(): PermissionManager {
return PermissionManager(context, Config(
permissions = buildFinalPermissionSet(),
includeInstallPermission = includeInstallPermission,
autoIncludeDependencies = autoIncludeDependencies,
customPermissionNames = customPermissionNames
))
}
private fun buildFinalPermissionSet(): Set<String> {
val finalPermissions = permissions.toMutableSet()
if (autoIncludeDependencies) {
// Auto-add dependencies based on the permissions requested
if (finalPermissions.any { it.startsWith("android.permission.BLUETOOTH") }) {
finalPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (finalPermissions.contains(Manifest.permission.ACCESS_COARSE_LOCATION) &&
!finalPermissions.contains(Manifest.permission.ACCESS_FINE_LOCATION)) {
finalPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
return finalPermissions.filter { shouldRequestPermission(it) }.toSet()
}
/**
* Check if a permission should be requested based on Android version
*/
private fun shouldRequestPermission(permission: String): Boolean {
return when {
// Storage permissions are not needed on Android 11+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
(permission == Manifest.permission.READ_EXTERNAL_STORAGE ||
permission == Manifest.permission.WRITE_EXTERNAL_STORAGE) -> false
// Skip legacy Bluetooth permissions on Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(permission == Manifest.permission.BLUETOOTH ||
permission == Manifest.permission.BLUETOOTH_ADMIN) -> false
else -> true
}
}
}
// Runtime permissions that need to be requested
val runtimePermissions: Set<String> by lazy {
config.permissions.filter { shouldRequestPermission(it) }.toSet()
}
// All permissions including special ones like install permission
val allPermissions: Set<String> by lazy {
runtimePermissions + if (config.includeInstallPermission && shouldRequestInstallPermission()) {
setOf(INSTALL_PERMISSION_KEY)
} else {
emptySet()
}
}
val permissionDisplayNames: Map<String, String> by lazy {
allPermissions.associateWith { permission ->
config.customPermissionNames[permission] ?: when (permission) {
INSTALL_PERMISSION_KEY -> "Install Apps"
Manifest.permission.CAMERA -> "Camera"
Manifest.permission.READ_EXTERNAL_STORAGE -> "Storage Access"
Manifest.permission.WRITE_EXTERNAL_STORAGE -> "Storage Access"
Manifest.permission.POST_NOTIFICATIONS -> "Notifications"
Manifest.permission.ACCESS_FINE_LOCATION -> "Precise Location"
Manifest.permission.ACCESS_COARSE_LOCATION -> "Approximate Location"
Manifest.permission.BLUETOOTH -> "Bluetooth"
Manifest.permission.BLUETOOTH_ADMIN -> "Bluetooth Admin"
Manifest.permission.BLUETOOTH_SCAN -> "Bluetooth Scan"
Manifest.permission.BLUETOOTH_CONNECT -> "Bluetooth Connect"
Manifest.permission.BLUETOOTH_ADVERTISE -> "Bluetooth Advertise"
Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.READ_CONTACTS -> "Contacts"
Manifest.permission.WRITE_CONTACTS -> "Contacts"
Manifest.permission.READ_CALENDAR -> "Calendar"
Manifest.permission.WRITE_CALENDAR -> "Calendar"
Manifest.permission.READ_PHONE_STATE -> "Phone"
Manifest.permission.CALL_PHONE -> "Phone Calls"
Manifest.permission.READ_SMS -> "SMS"
Manifest.permission.RECEIVE_SMS -> "SMS"
Manifest.permission.SEND_SMS -> "SMS"
Manifest.permission.BODY_SENSORS -> "Body Sensors"
Manifest.permission.ACTIVITY_RECOGNITION -> "Physical Activity"
else -> permission.substringAfterLast('.').replace("_", " ")
}
}
}
/**
* Check if a specific permission is granted
*/
fun isPermissionGranted(permission: String): Boolean {
return when {
permission == INSTALL_PERMISSION_KEY -> isInstallPermissionGranted()
else -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
}
/**
* Check if all permissions are granted
*/
fun areAllPermissionsGranted(): Boolean {
return allPermissions.all { isPermissionGranted(it) }
}
/**
* Check if all runtime permissions (excluding install permission) are granted
*/
fun areRuntimePermissionsGranted(): Boolean {
return runtimePermissions.all { isPermissionGranted(it) }
}
/**
* Request a single permission
*/
fun requestPermission(
permission: String,
launcher: ActivityResultLauncher<String>
) {
if (permission == INSTALL_PERMISSION_KEY) {
requestInstallPermission()
} else {
launcher.launch(permission)
}
}
/**
* Request all missing permissions at once
*/
fun requestAllMissingPermissions(launcher: ActivityResultLauncher<Array<String>>) {
val missingPermissions = runtimePermissions.filter { !isPermissionGranted(it) }.toTypedArray()
if (missingPermissions.isNotEmpty()) {
launcher.launch(missingPermissions)
}
}
/**
* Get the list of granted permissions
*/
fun getGrantedPermissions(): Set<String> {
return allPermissions.filter { isPermissionGranted(it) }.toSet()
}
/**
* Get the list of missing permissions
*/
fun getMissingPermissions(): Set<String> {
return allPermissions.filter { !isPermissionGranted(it) }.toSet()
}
/**
* Check if a permission should be requested based on Android version
*/
fun shouldRequestPermission(permission: String): Boolean {
return when {
// Storage permissions are not needed on Android 11+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
(permission == Manifest.permission.READ_EXTERNAL_STORAGE ||
permission == Manifest.permission.WRITE_EXTERNAL_STORAGE) -> false
// Skip legacy Bluetooth permissions on Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(permission == Manifest.permission.BLUETOOTH ||
permission == Manifest.permission.BLUETOOTH_ADMIN) -> false
else -> true
}
}
private fun shouldRequestInstallPermission(): Boolean {
return config.includeInstallPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}
private fun isInstallPermissionGranted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true
}
}
@SuppressLint("InlinedApi")
private fun requestInstallPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
}
context.startActivity(intent)
}
}
companion object {
const val INSTALL_PERMISSION_KEY = "INSTALL_UNKNOWN_APPS"
fun create(context: Context, block: Builder.() -> Unit = {}): PermissionManager {
return Builder(context).apply(block).build()
}
}
}
@@ -1,305 +0,0 @@
package me.pawlet.setupwizard.lib.internal
import android.content.Context
import android.util.Log
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.ManagerInfo
import me.pawlet.setupwizard.lib.UpdateInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
sealed class ServerResult<out T> {
data class Success<T>(val data: T) : ServerResult<T>()
data class Error(val message: String) : ServerResult<Nothing>()
}
class OxmcServers(private val context: Context) {
private val authManager = AuthManager(context)
private val baseUrl = context.getString(R.string.oxmc_api_url)
/**
* Helper function to make authenticated requests and handle token renewal
*/
private suspend fun makeAuthenticatedRequest(
urlPath: String,
method: String,
body: JSONObject? = null,
requireAuth: Boolean = false
): Pair<Int, String> {
return withContext(Dispatchers.IO) {
val url = URL("$urlPath")
val connection = url.openConnection() as HttpURLConnection
try {
connection.apply {
requestMethod = method
setRequestProperty("Content-Type", "application/json")
if (requireAuth) {
val token = authManager.getToken()
if (token != null) {
setRequestProperty("Authorization", "Bearer $token")
}
}
if (body != null) {
doOutput = true
}
connectTimeout = 10000
readTimeout = 10000
}
// Send body if present
if (body != null) {
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(body.toString())
writer.flush()
}
}
val responseCode = connection.responseCode
val response = BufferedReader(InputStreamReader(
if (responseCode in 200..299) connection.inputStream
else connection.errorStream
)).use { it.readText() }
// Check for token renewal in response headers
if (requireAuth) {
authManager.checkAndUpdateTokenFromHeaders(connection)
}
Pair(responseCode, response)
} finally {
connection.disconnect()
}
}
}
/**
* Check for app updates
*/
suspend fun checkForUpdates(appId: String, currentVersion: String): ServerResult<UpdateInfo> {
return try {
val jsonBody = JSONObject().apply {
put("app", JSONObject().apply {
put("id", appId)
put("version", currentVersion)
})
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/updateCheck",
"POST",
jsonBody
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
ServerResult.Success(
UpdateInfo(
latestVersion = jsonResponse.getString("latestVersion"),
updateAvailable = jsonResponse.getBoolean("updateAvailable"),
downloadUrl = jsonResponse.getString("downloadUrl"),
releaseNotes = jsonResponse.optString("releaseNotes", null)
)
)
} else {
val errorMsg = try {
JSONObject(response).optString("error", "Update check failed")
} catch (e: Exception) {
"Update check failed"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Update check error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Get the current manager on duty for a store
*/
suspend fun getManagerOnDuty(storeId: String? = null): ServerResult<ManagerInfo> {
return try {
val store = storeId ?: authManager.getStoreId()
if (store == null) {
return ServerResult.Error("No store ID available")
}
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/manager/$store",
"GET",
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val manager = jsonResponse.getJSONObject("manager")
ServerResult.Success(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = manager.getString("storeId"),
storeName = manager.getString("storeName"),
setAt = manager.getString("setAt"),
setBy = manager.getString("setBy")
)
)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to get manager"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to get manager")
} catch (e: Exception) {
"Failed to get manager"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Get manager error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Set the manager on duty for a store
*/
suspend fun setManagerOnDuty(
managerId: String? = null,
name: String? = null,
pronouns: String? = null,
picture: String? = null,
storeId: String? = null
): ServerResult<ManagerInfo> {
return try {
val store = storeId ?: authManager.getStoreId()
if (store == null) {
return ServerResult.Error("No store ID available")
}
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val jsonBody = JSONObject().apply {
put("storeId", store)
if (managerId != null) {
put("managerId", managerId)
} else {
put("name", name)
put("pronouns", pronouns)
put("picture", picture)
}
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/manager",
"POST",
jsonBody,
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val manager = jsonResponse.getJSONObject("manager")
ServerResult.Success(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = manager.getString("storeId"),
storeName = manager.getString("storeName"),
setAt = manager.getString("setAt"),
setBy = manager.getString("setBy")
)
)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to set manager"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to set manager")
} catch (e: Exception) {
"Failed to set manager"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Set manager error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Get list of all available managers
*/
suspend fun getAvailableManagers(): ServerResult<List<ManagerInfo>> {
return try {
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/managers",
"GET",
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val managersArray = jsonResponse.getJSONArray("managers")
val managers = mutableListOf<ManagerInfo>()
for (i in 0 until managersArray.length()) {
val manager = managersArray.getJSONObject(i)
managers.add(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = "",
storeName = "",
setAt = manager.optString("created_at", ""),
setBy = ""
)
)
}
ServerResult.Success(managers)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to get managers"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to get managers")
} catch (e: Exception) {
"Failed to get managers"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Get managers error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
}
@@ -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
@@ -533,6 +534,29 @@ class SetupUtils {
}
}
/**
* Lock the status bar for setup: disables the notification shade / quick
* settings, Home, Recents, and assist so the user cannot leave setup. Uses
* the platform's setup API (StatusBarManager.setDisabledForSetup, a
* @SystemApi needing STATUS_BAR — which the wizard holds) via reflection so
* the module still builds under Gradle, where that API isn't on the SDK.
*/
fun disableStatusBarForSetup(context: Context) = setStatusBarDisabledForSetup(context, true)
/** Restore the status bar locked by [disableStatusBarForSetup]. */
fun enableStatusBar(context: Context) = setStatusBarDisabledForSetup(context, false)
private fun setStatusBarDisabledForSetup(context: Context, disabled: Boolean) {
try {
val sbm = context.getSystemService("statusbar") ?: return
Class.forName("android.app.StatusBarManager")
.getMethod("setDisabledForSetup", java.lang.Boolean.TYPE)
.invoke(sbm, disabled)
} catch (e: Exception) {
Log.w(TAG, "setDisabledForSetup($disabled) failed", e)
}
}
fun finishSetupWizard(context: Context) {
if (LOGV) {
Log.v(TAG, "finishSetupWizard")
@@ -566,6 +590,22 @@ class SetupUtils {
WallpaperManager.getInstance(context).forgetLoadedWallpaper()
disableHome(context)
// 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
// so they can apply their own customizations. Guarded by FINISH_SETUP.
runCatching {
context.sendBroadcast(
Intent("me.pawlet.setupwizard.SETUP_COMPLETE")
.setPackage(null)
.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES),
"me.pawlet.setupwizard.permission.FINISH_SETUP"
)
}
Log.i(TAG, "Setup complete!")
}
}
@@ -0,0 +1,235 @@
package me.pawlet.setupwizard.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Shared layout for a single setup-wizard step: a leading icon, a title and
* optional subtitle, a scrollable content slot, and a bottom action row with an
* optional Back button plus a primary Continue/Skip button.
*
* Every new wizard step (Date/Time, Location, Screen lock, microG, ...) uses
* this so the steps stay visually and behaviourally consistent. Screens persist
* their own choices and then invoke [onContinue]; [onBack] is null on the first
* page (the scaffold hides the Back button in that case).
*/
@Composable
fun WizardStepScaffold(
icon: ImageVector,
title: String,
subtitle: String? = null,
continueLabel: String = "Continue",
continueEnabled: Boolean = true,
onContinue: () -> Unit,
onBack: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.navigationBarsPadding()
) {
Spacer(modifier = Modifier.height(16.dp))
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(56.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
)
}
Spacer(modifier = Modifier.height(24.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
content = content
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (onBack != null) {
OutlinedButton(
onClick = onBack,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text("Back", fontSize = 16.sp)
}
}
Button(
onClick = onContinue,
enabled = continueEnabled,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text(continueLabel, fontSize = 16.sp)
}
}
}
}
}
/** Convenience wrapper for a full-width secondary/tertiary action inside a step. */
@Composable
fun WizardSecondaryButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxWidth()) {
OutlinedButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(label, fontSize = 16.sp)
}
}
}
/** A titled card with a trailing switch — used by the toggle-style steps. */
@Composable
fun WizardToggleCard(
title: String,
subtitle: String?,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
}
/** A selectable option row (radio) — used by the choice-style steps. */
@Composable
fun WizardRadioOption(
label: String,
subtitle: String? = null,
selected: Boolean,
onSelect: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onSelect)
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = selected, onClick = onSelect)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyLarge)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -42,8 +42,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import dev.oxmc.androiddeviceinfo.AndroidInfo
import me.pawlet.setupwizard.ui.theme.BrandColors
@@ -39,8 +39,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import dev.oxmc.androiddeviceinfo.AndroidInfo
import dev.oxmc.androiddeviceinfo.DeviceInfo
import me.pawlet.setupwizard.R
@@ -1,213 +0,0 @@
package me.pawlet.setupwizard.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.R
@Composable
fun LoginScreen(
onLoginAttempt: (String, String, String, (Boolean, String) -> Unit) -> Unit
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var storeId by remember { mutableStateOf("") }
var isLoggingIn by remember { mutableStateOf(false) }
var loginError by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(32.dp)
.fillMaxWidth()
.widthIn(max = 500.dp) // Limit width for landscape
) {
// Logo
Image(
painter = painterResource(R.drawable.pink_protogen),
contentDescription = "App Logo",
modifier = Modifier
.size(120.dp)
.padding(bottom = 8.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Manager on Duty",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "First Time Setup",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(24.dp))
OutlinedTextField(
value = storeId,
onValueChange = {
storeId = it
loginError = "" // Clear error on input
},
label = { Text("Store ID") },
placeholder = { Text("e.g., 8949") },
singleLine = true,
enabled = !isLoggingIn,
modifier = Modifier.fillMaxWidth(),
isError = loginError.isNotEmpty() && storeId.isBlank()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = email,
onValueChange = {
email = it
loginError = "" // Clear error on input
},
label = { Text("Email") },
singleLine = true,
enabled = !isLoggingIn,
modifier = Modifier.fillMaxWidth(),
isError = loginError.isNotEmpty() && email.isBlank()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = {
password = it
loginError = "" // Clear error on input
},
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !isLoggingIn,
modifier = Modifier.fillMaxWidth(),
isError = loginError.isNotEmpty() && password.isBlank()
)
Spacer(modifier = Modifier.height(20.dp))
if (loginError.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = loginError,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
}
Button(
onClick = {
// Validate fields
when {
storeId.isBlank() -> {
loginError = "Store ID is required"
return@Button
}
email.isBlank() -> {
loginError = "Email is required"
return@Button
}
password.isBlank() -> {
loginError = "Password is required"
return@Button
}
}
loginError = ""
isLoggingIn = true
// Call login with callback to reset button state
onLoginAttempt(email, password, storeId) { success, message ->
isLoggingIn = false
if (!success) {
loginError = message
}
}
},
enabled = !isLoggingIn,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
if (isLoggingIn) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(12.dp))
Text("Logging in...")
}
} else {
Text("Login")
}
}
// Add some bottom padding for landscape mode
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -1,348 +0,0 @@
package me.pawlet.setupwizard.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AddTask
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Store
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.lib.internal.PrefManager
import me.pawlet.setupwizard.lib.internal.TemperatureUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onNavigateToAbout: () -> Unit
) {
val context = LocalContext.current
val prefs = remember { PrefManager(context) }
// State for settings
var storeId by remember { mutableStateOf(prefs.getString("store_id", "")) }
var temperatureUnit by remember {
mutableStateOf(
TemperatureUnit.valueOf(
prefs.getString("temperature_unit", TemperatureUnit.FAHRENHEIT.name).toString()
)
)
}
var autoUpdate by remember { mutableStateOf(prefs.getBoolean("auto_update", true)) }
// Dialog states
var showStoreIdDialog by remember { mutableStateOf(false) }
var showTemperatureDialog by remember { mutableStateOf(false) }
var tempStoreId by remember { mutableStateOf(storeId) }
var tempTemperatureUnit by remember { mutableStateOf(temperatureUnit) }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
// Store Configuration Section
SettingsSection(title = "Store Configuration") {
SettingsItem(
icon = Icons.Default.Store,
title = "Store ID",
subtitle = storeId?.let { if (it.isEmpty()) "Not set" else storeId },
onClick = {
tempStoreId = storeId
showStoreIdDialog = true
}
)
}
// Display Preferences Section
SettingsSection(title = "Display Preferences") {
SettingsItem(
icon = Icons.Default.Thermostat,
title = "Temperature Unit",
subtitle = temperatureUnit.displayName,
onClick = {
tempTemperatureUnit = temperatureUnit
showTemperatureDialog = true
}
)
}
// Connection Settings Section
SettingsSection(title = "Connection Settings") {
var useRestOnly by remember { mutableStateOf(prefs.getBoolean("use_rest_only", false)) }
SettingsItem(
icon = Icons.Default.Cloud,
title = "Use REST API Only",
subtitle = if (useRestOnly) "WebSocket disabled - using REST polling" else "WebSocket enabled with REST fallback",
trailing = {
Switch(
checked = useRestOnly,
onCheckedChange = {
useRestOnly = it
prefs.saveBoolean("use_rest_only", it)
// You might want to show a restart message here
}
)
}
)
}
// Auto Update Section
SettingsSection(title = "Updates") {
SettingsItem(
icon = Icons.Default.AddTask,
title = "Auto Update",
subtitle = "Automatically check for updates",
trailing = {
Switch(
checked = autoUpdate,
onCheckedChange = {
autoUpdate = it
prefs.saveBoolean("auto_update", it)
}
)
}
)
}
// App Info Section
SettingsSection(title = "About") {
SettingsItem(
title = "About this app",
subtitle = "App information",
onClick = onNavigateToAbout
)
}
// Spacer at bottom
Spacer(modifier = Modifier.height(32.dp))
}
}
// Store ID Dialog
if (showStoreIdDialog) {
AlertDialog(
onDismissRequest = { showStoreIdDialog = false },
title = { Text("Set Store ID") },
text = {
Column {
Text(
text = "Enter your store ID to load manager information",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = tempStoreId.toString(),
onValueChange = { tempStoreId = it },
label = { Text("Store ID") },
placeholder = { Text("e.g., 12345") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
storeId = tempStoreId
prefs.saveString("store_id", tempStoreId.toString())
showStoreIdDialog = false
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showStoreIdDialog = false }) {
Text("Cancel")
}
}
)
}
// Temperature Unit Dialog
if (showTemperatureDialog) {
AlertDialog(
onDismissRequest = { showTemperatureDialog = false },
title = { Text("Temperature Unit") },
text = {
Column {
Text(
text = "Select your preferred temperature unit",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
TemperatureUnit.entries.forEach { unit ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = tempTemperatureUnit == unit,
onClick = { tempTemperatureUnit = unit }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = unit.displayName,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
temperatureUnit = tempTemperatureUnit
prefs.saveString("temperature_unit", tempTemperatureUnit.name)
showTemperatureDialog = false
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showTemperatureDialog = false }) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun SettingsSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp, 16.dp, 16.dp, 8.dp)
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
content()
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun SettingsItem(
icon: ImageVector? = null,
title: String,
subtitle: String? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Surface(
onClick = onClick ?: {},
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
icon?.let {
Icon(
imageVector = it,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
}
@@ -0,0 +1,50 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.hardware.biometrics.BiometricManager
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Biometric enrollment step. Gated on biometric hardware (hasBiometric), so it
* never shows on RPi. Launches the platform enrollment flow.
*/
@Composable
fun BiometricScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Fingerprint,
title = "Fingerprint & face",
subtitle = "Set up biometric unlock for a faster, secure way to sign in. Optional — you can add this later.",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Set up biometrics",
onClick = {
val enroll = Intent(Settings.ACTION_BIOMETRIC_ENROLL).putExtra(
Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(enroll) }.onFailure {
runCatching {
context.startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
)
}
}
@@ -0,0 +1,42 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Bluetooth step. Offers to pair accessories (keyboard, remote, controller) via
* the platform Bluetooth settings. Gated on FEATURE_BLUETOOTH; optional.
*/
@Composable
fun BluetoothScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Bluetooth,
title = "Bluetooth",
subtitle = "Pair a keyboard, remote, controller, or other accessory. You can skip this and pair devices later from Settings.",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Pair a device",
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,114 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import java.text.DateFormat
import java.util.Date
/**
* Date & time step. Raspberry Pi boards have no battery-backed RTC, so the
* default is automatic (network/NTP) time. The user may toggle it off and open
* the platform date/time settings to set it manually. The screen writes the
* AUTO_TIME / AUTO_TIME_ZONE global settings itself and then advances.
*/
@Composable
fun DateTimeScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
var autoTime by remember {
mutableStateOf(Settings.Global.getInt(cr, Settings.Global.AUTO_TIME, 1) == 1)
}
fun applyAutoTime(enabled: Boolean) {
autoTime = enabled
val value = if (enabled) 1 else 0
Settings.Global.putInt(cr, Settings.Global.AUTO_TIME, value)
Settings.Global.putInt(cr, Settings.Global.AUTO_TIME_ZONE, value)
}
val now = remember { DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.SHORT).format(Date()) }
WizardStepScaffold(
icon = Icons.Filled.Schedule,
title = "Date & time",
subtitle = "This device has no battery clock, so it keeps time from the network. You can set it manually if you prefer.",
onContinue = onContinue,
onBack = onBack
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Set automatically",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Use network-provided time and time zone",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = autoTime,
onCheckedChange = { applyAutoTime(it) }
)
}
}
Text(
text = "Current: $now",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 4.dp)
)
WizardSecondaryButton(
label = "Open date & time settings",
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_DATE_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,43 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Tune
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.lib.DeviceProfile
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Device-specific OEM hook. Only shown when some app on the device implements
* the ACTION_DEVICE_SPECIFIC intent (gated by profile.deviceSpecificAvailable);
* it hands off to that app for board-specific setup, then returns here.
*/
@Composable
fun DeviceSpecificScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Tune,
title = "Device setup",
subtitle = "There are a few extra options specific to this hardware.",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Open device setup",
onClick = {
runCatching {
context.startActivity(
Intent(DeviceProfile.ACTION_DEVICE_SPECIFIC)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,71 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.location.LocationManager
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import me.pawlet.setupwizard.ui.components.WizardToggleCard
/**
* Location step. Lets the user enable location services (backed by microG's
* UnifiedNlp on PawletOS) and jump to the platform location settings.
*/
@Composable
fun LocationScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
val lm = context.getSystemService(LocationManager::class.java)
var enabled by remember {
mutableStateOf(runCatching { lm.isLocationEnabled }.getOrDefault(false))
}
WizardStepScaffold(
icon = Icons.Filled.LocationOn,
title = "Location",
subtitle = "Allow apps that you permit to use this device's location. You can change this at any time in Settings.",
onContinue = onContinue,
onBack = onBack
) {
WizardToggleCard(
title = "Use location",
subtitle = "Location services (network/GPS)",
checked = enabled,
onCheckedChange = {
enabled = it
runCatching {
Settings.Secure.putInt(
cr,
Settings.Secure.LOCATION_MODE,
if (it) Settings.Secure.LOCATION_MODE_HIGH_ACCURACY
else Settings.Secure.LOCATION_MODE_OFF
)
}
}
)
WizardSecondaryButton(
label = "Open location settings",
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,67 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.accounts.AccountManager
import android.app.Activity
import android.content.Intent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
private const val GMS_PACKAGE = "com.google.android.gms"
private const val MICROG_SETTINGS = "org.microg.gms.ui.SettingsActivity"
private const val GOOGLE_ACCOUNT_TYPE = "com.google"
/**
* Google services (microG) step. PawletOS ships microG in place of GMS, so
* instead of handing off to Google's setup wizard this lets the user open
* microG's own settings/self-check and, optionally, sign in to a Google account
* (microG's authenticator handles the flow). Gated on microGAvailable
* (microG's GmsCore installed) — not hasGms, which also needs Google's SUW.
*/
@Composable
fun MicroGScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Cloud,
title = "Google services",
subtitle = "This device uses microG, an open-source Google services layer. Configure what it may do, and optionally sign in to a Google account.",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Open microG settings",
onClick = {
val direct = Intent().setClassName(GMS_PACKAGE, MICROG_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(direct) }.onFailure {
runCatching {
context.packageManager.getLaunchIntentForPackage(GMS_PACKAGE)
?.let { context.startActivity(it) }
}
}
}
)
WizardSecondaryButton(
label = "Sign in to Google",
onClick = {
val activity = context as? Activity ?: return@WizardSecondaryButton
runCatching {
AccountManager.get(context).addAccount(
GOOGLE_ACCOUNT_TYPE,
null, null, null,
activity,
null, null
)
}
}
)
}
}
@@ -0,0 +1,61 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardRadioOption
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
// AOSP navigation_mode: 0 = 3-button, 2 = gesture.
private const val NAV_MODE_3BUTTON = 0
private const val NAV_MODE_GESTURE = 2
/**
* System navigation step. Chooses between gesture and 3-button navigation and
* writes the AOSP "navigation_mode" secure setting (same key finishSetupWizard's
* navigation handling uses).
*/
@Composable
fun NavigationScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
var mode by remember {
mutableIntStateOf(Settings.Secure.getInt(cr, "navigation_mode", NAV_MODE_GESTURE))
}
fun apply(newMode: Int) {
mode = newMode
runCatching { Settings.Secure.putInt(cr, "navigation_mode", newMode) }
}
WizardStepScaffold(
icon = Icons.Filled.Navigation,
title = "System navigation",
subtitle = "Choose how you move around the system.",
onContinue = onContinue,
onBack = onBack
) {
WizardRadioOption(
label = "Gesture navigation",
subtitle = "Swipe from the edges to go home, back, and switch apps",
selected = mode == NAV_MODE_GESTURE,
onSelect = { apply(NAV_MODE_GESTURE) }
)
WizardRadioOption(
label = "3-button navigation",
subtitle = "Back, home, and recents buttons at the bottom",
selected = mode == NAV_MODE_3BUTTON,
onSelect = { apply(NAV_MODE_3BUTTON) }
)
}
}
@@ -44,8 +44,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import me.pawlet.setupwizard.lib.OTAInfoCard
import me.pawlet.setupwizard.lib.OTAUpdateInfo
@@ -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) }
}
@@ -0,0 +1,49 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import me.pawlet.setupwizard.ui.components.WizardToggleCard
/**
* Privacy / metrics step. Lets the user opt in to anonymous usage metrics.
* Writes the "send_metrics" secure setting the same way finishSetupWizard reads
* it, so the choice is honoured whether it's set here or at finish.
*/
@Composable
fun PrivacyScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
var sendMetrics by remember {
mutableStateOf(Settings.Secure.getInt(cr, "send_metrics", 0) == 1)
}
WizardStepScaffold(
icon = Icons.Filled.PrivacyTip,
title = "Help improve PawletOS",
subtitle = "Optionally send anonymous usage and diagnostic data. This never includes personal information, and you can turn it off anytime.",
onContinue = onContinue,
onBack = onBack
) {
WizardToggleCard(
title = "Send anonymous metrics",
subtitle = "Usage statistics and crash diagnostics",
checked = sendMetrics,
onCheckedChange = {
sendMetrics = it
runCatching { Settings.Secure.putInt(cr, "send_metrics", if (it) 1 else 0) }
}
)
}
}
@@ -0,0 +1,49 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import me.pawlet.setupwizard.ui.components.WizardToggleCard
/**
* Recovery-update step. Gated on hasRecoveryUpdater (presence of
* /vendor/bin/install-recovery.sh). Writes the "enable_recovery_update" secure
* setting that finishSetupWizard's recovery handling reads.
*/
@Composable
fun RecoveryUpdateScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
var enabled by remember {
mutableStateOf(Settings.Secure.getInt(cr, "enable_recovery_update", 1) == 1)
}
WizardStepScaffold(
icon = Icons.Filled.SystemUpdate,
title = "Recovery updates",
subtitle = "Keep the recovery partition up to date automatically when the system updates. Recommended.",
onContinue = onContinue,
onBack = onBack
) {
WizardToggleCard(
title = "Update recovery automatically",
subtitle = "Apply recovery patches during system updates",
checked = enabled,
onCheckedChange = {
enabled = it
runCatching { Settings.Secure.putInt(cr, "enable_recovery_update", if (it) 1 else 0) }
}
)
}
}
@@ -0,0 +1,44 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SettingsBackupRestore
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Restore-from-backup step. Gated on profile.backupAvailable, which stays false
* until a backup transport is configured, so today this auto-skips. When a
* transport exists it offers to open the platform restore/backup settings.
*/
@Composable
fun RestoreScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.SettingsBackupRestore,
title = "Restore your data",
subtitle = "Bring apps and settings back from a previous backup, or continue to set this device up as new.",
continueLabel = "Set up as new",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Restore from backup",
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_PRIVACY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,51 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.app.admin.DevicePolicyManager
import android.content.Intent
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Screen-lock step. Launches the platform "set new password" flow so the user
* can choose a PIN/pattern/password. Optional — the user may skip with Continue.
*/
@Composable
fun ScreenLockScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Lock,
title = "Screen lock",
subtitle = "Add a PIN, pattern, or password to help keep this device secure. You can set this up later if you prefer.",
continueLabel = "Continue",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Set up screen lock",
onClick = {
runCatching {
context.startActivity(
Intent(DevicePolicyManager.ACTION_SET_NEW_PASSWORD)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}.onFailure {
runCatching {
context.startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
)
}
}
@@ -87,7 +87,7 @@ fun SetupCompleteScreen(
)
)
}
clipPath(path) { drawContent() }
clipPath(path) { this@drawWithContent.drawContent() }
}
) {
Surface(
@@ -139,7 +139,7 @@ fun SetupCompleteScreen(
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = Color.White
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Finish Setup", fontSize = 18.sp)
@@ -0,0 +1,34 @@
package me.pawlet.setupwizard.ui.screens.wizard
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SimCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* SIM-missing notice. Only shown on devices with telephony hardware when no SIM
* is present (gated by hasTelephony && !simPresent). Raspberry Pi boards have no
* telephony, so this never appears there.
*/
@Composable
fun SimMissingScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
WizardStepScaffold(
icon = Icons.Filled.SimCard,
title = "No SIM card",
subtitle = "There's no SIM card in this device. You can still finish setup and use Wi-Fi. Insert a SIM later to enable mobile data and calls.",
continueLabel = "Continue",
onContinue = onContinue,
onBack = onBack
) {
Text(
text = "Mobile network features will be unavailable until a SIM is inserted.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
)
}
}
@@ -0,0 +1,63 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.app.UiModeManager
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Palette
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardRadioOption
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Theme step. Chooses light / dark / follow-system and applies it system-wide
* via UiModeManager (PawletOS ships no LineageSettings SDK, so this uses the
* plain AOSP API).
*/
@Composable
fun ThemeScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val uiModeManager = context.getSystemService(UiModeManager::class.java)
var nightMode by remember {
mutableIntStateOf(
runCatching { uiModeManager.nightMode }.getOrDefault(UiModeManager.MODE_NIGHT_AUTO)
)
}
fun apply(mode: Int) {
nightMode = mode
runCatching { uiModeManager.setNightMode(mode) }
}
WizardStepScaffold(
icon = Icons.Filled.Palette,
title = "Theme",
subtitle = "Pick a look for PawletOS. You can change it later in Settings.",
onContinue = onContinue,
onBack = onBack
) {
WizardRadioOption(
label = "System default",
subtitle = "Follow the device's day/night schedule",
selected = nightMode == UiModeManager.MODE_NIGHT_AUTO,
onSelect = { apply(UiModeManager.MODE_NIGHT_AUTO) }
)
WizardRadioOption(
label = "Light",
selected = nightMode == UiModeManager.MODE_NIGHT_NO,
onSelect = { apply(UiModeManager.MODE_NIGHT_NO) }
)
WizardRadioOption(
label = "Dark",
selected = nightMode == UiModeManager.MODE_NIGHT_YES,
onSelect = { apply(UiModeManager.MODE_NIGHT_YES) }
)
}
}
@@ -0,0 +1,119 @@
package me.pawlet.setupwizard.ui.screens.wizard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.lib.UpdateChecker
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import java.util.Locale
private sealed interface UpdateState {
data object Checking : UpdateState
data object UpToDate : UpdateState
data class Available(val update: UpdateChecker.AvailableUpdate) : UpdateState
}
/**
* Post-connectivity update check. On entry it runs [UpdateChecker.check]; if a
* newer compatible build exists it shows it with an "Update now" action that
* hands off to the Updater (update_engine). If the device is up to date, offline,
* or the check fails, the step advances itself so it stays invisible. The actual
* download/install is never done here.
*/
@Composable
fun UpdateCheckScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
var state by remember { mutableStateOf<UpdateState>(UpdateState.Checking) }
LaunchedEffect(Unit) {
val update = runCatching { UpdateChecker.check(context) }.getOrNull()
state = if (update != null) UpdateState.Available(update) else UpdateState.UpToDate
}
// No update / offline / error → don't show a step at all.
LaunchedEffect(state) {
if (state is UpdateState.UpToDate) onContinue()
}
when (val s = state) {
is UpdateState.Available -> {
val u = s.update
WizardStepScaffold(
icon = Icons.Filled.SystemUpdate,
title = "System update available",
subtitle = "Version ${u.version}${formatSize(u.sizeBytes)}",
continueLabel = "Skip for now",
onContinue = onContinue,
onBack = onBack
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(u.filename, style = MaterialTheme.typography.bodyLarge)
Text(
"Build type: ${u.romType}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
WizardSecondaryButton(
label = "Update now",
onClick = { UpdateChecker.launchUpdater(context) }
)
}
}
else -> {
// Checking (and the brief moment before UpToDate auto-advances).
WizardStepScaffold(
icon = Icons.Filled.SystemUpdate,
title = "Checking for updates",
subtitle = "Making sure this device has the latest software.",
continueLabel = "Skip",
onContinue = onContinue,
onBack = onBack
) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator()
}
}
}
}
}
private fun formatSize(bytes: Long): String {
if (bytes <= 0) return "unknown size"
val mb = bytes / (1024.0 * 1024.0)
return if (mb >= 1024) String.format(Locale.US, "%.1f GB", mb / 1024.0)
else String.format(Locale.US, "%.0f MB", mb)
}
@@ -86,7 +86,7 @@ fun WelcomeScreen(
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = Color.White
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Get Started", fontSize = 18.sp)
@@ -15,47 +15,58 @@ import androidx.compose.ui.unit.sp
import me.pawlet.setupwizard.R
// --- Pawlet Theme (Light) ---
// Violet primary (brand), warm-pink secondary, teal tertiary as deliberate
// accents, on near-neutral surfaces so the UI reads clean instead of "all
// purple". Contrast pairs follow Material 3 tonal guidance.
private val PawletLightColors = lightColorScheme(
primary = Color(0xFFB388FF),
onPrimary = Color.White,
primaryContainer = Color(0xFFEAD7FF),
onPrimaryContainer = Color(0xFF2A003F),
secondary = Color(0xFF7C4DFF),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD1B3FF),
onSecondaryContainer = Color(0xFF2A003F),
tertiary = Color(0xFF9F6BFF),
onTertiary = Color.White,
background = Color(0xFFD2AEDC),
onBackground = Color(0xFF2A003F),
surface = Color.White,
onSurface = Color(0xFF2A003F),
surfaceVariant = Color(0xFFF0E6F6),
onSurfaceVariant = Color(0xFF5A4066),
error = Color(0xFFB00020),
onError = Color.White
primary = Color(0xFF7A4FD1),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFEBDCFF),
onPrimaryContainer = Color(0xFF25005A),
secondary = Color(0xFFC24C97),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFD8EC),
onSecondaryContainer = Color(0xFF3D0026),
tertiary = Color(0xFF2E9C93),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFB9F0E9),
onTertiaryContainer = Color(0xFF00201D),
background = Color(0xFFFDF7FF),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFDF7FF),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE9E0EC),
onSurfaceVariant = Color(0xFF4A454E),
outline = Color(0xFF7B757F),
outlineVariant = Color(0xFFCCC4CF),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF)
)
// --- Pawlet Theme (Dark) ---
private val PawletDarkColors = darkColorScheme(
primary = Color(0xFFD1B3FF),
onPrimary = Color.Black,
primaryContainer = Color(0xFF9F6BFF),
onPrimaryContainer = Color.White,
secondary = Color(0xFFB388FF),
onSecondary = Color.Black,
secondaryContainer = Color(0xFF7C4DFF),
onSecondaryContainer = Color.White,
tertiary = Color(0xFFEAD7FF),
onTertiary = Color.Black,
background = Color(0xFF1B0030),
onBackground = Color.White,
surface = Color(0xFF2A003F),
onSurface = Color.White,
surfaceVariant = Color(0xFF3A1A4F),
onSurfaceVariant = Color(0xFFD1B3FF),
error = Color(0xFFCF6679),
onError = Color.Black
primary = Color(0xFFD3BBFF),
onPrimary = Color(0xFF3E1080),
primaryContainer = Color(0xFF573BA6),
onPrimaryContainer = Color(0xFFEBDCFF),
secondary = Color(0xFFFFAFD6),
onSecondary = Color(0xFF5E1140),
secondaryContainer = Color(0xFF7B2E5B),
onSecondaryContainer = Color(0xFFFFD8EC),
tertiary = Color(0xFF7FD5CC),
onTertiary = Color(0xFF003733),
tertiaryContainer = Color(0xFF134E49),
onTertiaryContainer = Color(0xFFB9F0E9),
background = Color(0xFF141218),
onBackground = Color(0xFFE7E0E8),
surface = Color(0xFF141218),
onSurface = Color(0xFFE7E0E8),
surfaceVariant = Color(0xFF49454E),
onSurfaceVariant = Color(0xFFCBC4CF),
outline = Color(0xFF948F99),
outlineVariant = Color(0xFF49454E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005)
)
// --- Brand Colors ---
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SetupWizard" parent="Theme.Material3.Dark.NoActionBar">
<item name="android:statusBarColor">?attr/colorPrimary</item>
</style>
</resources>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SetupWizard" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Enable light icons if status bar is dark -->
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SetupWizard.Splash" parent="Theme.Material3.DayNight">
<item name="android:windowSplashScreenBackground">@color/lavender_500</item>
<item name="android:windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/lavender_secondary</item>
<item name="postSplashScreenTheme">@style/Theme.SetupWizard</item>
</style>
</resources>
-5
View File
@@ -8,11 +8,6 @@
<string name="developer_name">oxmc</string>
<string name="email">contact@oxmc.me</string>
<!-- API Urls -->
<string name="oxmc_api_url">https://cdn.oxmc.me/applebees/api/v2</string>
<string name="weather_api_url">https://api.weatherapi.com/v1/current.json?key={}&amp;q={}&amp;aqi=no</string>
<string name="weather_api_key">72d96a584a454382af104944260401</string>
<!-- API Urls -->
<string name="api_url_base">https://cdn.oxmc.me/api</string>
+11 -19
View File
@@ -1,22 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark Theme -->
<style name="Theme.SetupWizard" parent="Theme.Material3.Dark.NoActionBar">
<item name="colorPrimary">@color/lavender_200</item>
<item name="colorOnPrimary">@color/lavender_on_primary_dark</item>
<item name="colorSecondary">@color/lavender_secondary_dark</item>
<item name="colorOnSecondary">@color/lavender_on_secondary_dark</item>
<item name="android:colorBackground">@color/lavender_background_dark</item>
<item name="colorOnBackground">@color/lavender_on_background_dark</item>
<!--
Window container theme for the Compose-based setup wizard.
All Material3 colour/typography tokens are applied in Compose code;
this XML theme only controls platform window properties.
-->
<style name="Theme.SetupWizard" parent="android:Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">@color/lavender_background_dark</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
<!-- Light Theme -->
<style name="Theme.SetupWizard.Light" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/lavender_500</item>
<item name="colorOnPrimary">@color/lavender_on_primary</item>
<item name="colorSecondary">@color/lavender_secondary</item>
<item name="colorOnSecondary">@color/lavender_on_secondary</item>
<item name="android:colorBackground">@color/lavender_background</item>
<item name="colorOnBackground">@color/lavender_on_background</item>
</style>
</resources>
</resources>
+4 -3
View File
@@ -5,7 +5,7 @@ coreKtx = "1.17.0"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
coil = "2.7.0"
coil = "3.5.0"
okhttp = "4.12.0"
splashscreen = "1.0.1"
gson = "2.11.0"
@@ -26,8 +26,9 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
+1
View File
@@ -8,6 +8,7 @@
<permission name="android.permission.BACKUP" />
<permission name="android.permission.CHANGE_COMPONENT_ENABLED_STATE" />
<permission name="android.permission.CHANGE_CONFIGURATION" />
<permission name="android.permission.DOMAIN_VERIFICATION_AGENT" />
<permission name="android.permission.GET_ACCOUNTS_PRIVILEGED" />
<permission name="android.permission.INTERACT_ACROSS_USERS" />
<permission name="android.permission.MANAGE_USERS" />
@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: The PawletOS Project
// SPDX-License-Identifier: Apache-2.0
//
// Soong-only stand-in for the Gradle-generated BuildConfig. This directory is
// listed in Android.bp srcs but is NOT part of the Gradle source set, so the
// two build systems never both define the class.
package me.pawlet.setupwizard
object BuildConfig {
const val DEBUG = false
const val APPLICATION_ID = "me.pawlet.setupwizard"
const val BUILD_TYPE = "release"
const val VERSION_CODE = 1
const val VERSION_NAME = "1.0.0"
}