diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 2f44946..3edbd9d 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -89,29 +89,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3098f6e..0810020 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -50,29 +50,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -80,11 +60,6 @@
android:exported="true">
-
-
-
-
{
+ 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"
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/Permissions.kt b/app/src/main/java/dev/oxmc/setupwizard/Permissions.kt
deleted file mode 100644
index 953735d..0000000
--- a/app/src/main/java/dev/oxmc/setupwizard/Permissions.kt
+++ /dev/null
@@ -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 11–12L, API 30–32)
- 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)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/activities/LoginActivity.kt b/app/src/main/java/dev/oxmc/setupwizard/activities/LoginActivity.kt
deleted file mode 100644
index 9a44eab..0000000
--- a/app/src/main/java/dev/oxmc/setupwizard/activities/LoginActivity.kt
+++ /dev/null
@@ -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)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/activities/MainActivity.kt b/app/src/main/java/dev/oxmc/setupwizard/activities/MainActivity.kt
index 0f8751d..1692627 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/activities/MainActivity.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/activities/MainActivity.kt
@@ -13,6 +13,7 @@ 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.buildBuiltinPages
import me.pawlet.setupwizard.ui.screens.AboutDeviceScreen
import me.pawlet.setupwizard.ui.screens.AndroidVersionScreen
@@ -22,8 +23,10 @@ class MainActivity : BaseKioskActivity() {
private lateinit var wizardManager: SetupWizardManager
override fun onCreate(savedInstanceState: Bundle?) {
- FullScreenHelper.prepareKioskWindow(this)
- FullScreenHelper.enableKioskMode(this)
+ if (WizardFlags.KIOSK) {
+ FullScreenHelper.prepareKioskWindow(this)
+ FullScreenHelper.enableKioskMode(this)
+ }
super.onCreate(savedInstanceState)
wizardManager = SetupWizardManager.getInstance(this)
@@ -43,7 +46,7 @@ class MainActivity : BaseKioskActivity() {
val deviceInfo by wizardManager.deviceInfo.collectAsState()
LaunchedEffect(index) {
- FullScreenHelper.enableKioskMode(this@MainActivity)
+ if (WizardFlags.KIOSK) FullScreenHelper.enableKioskMode(this@MainActivity)
}
when (overlay) {
diff --git a/app/src/main/java/dev/oxmc/setupwizard/activities/PermissionsActivity.kt b/app/src/main/java/dev/oxmc/setupwizard/activities/PermissionsActivity.kt
deleted file mode 100644
index da9a2bc..0000000
--- a/app/src/main/java/dev/oxmc/setupwizard/activities/PermissionsActivity.kt
+++ /dev/null
@@ -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
- private lateinit var multiplePermissionLauncher: ActivityResultLauncher>
- private lateinit var installPermissionLauncher: ActivityResultLauncher
-
- // Track which permission is being requested (for single permission launcher)
- private var currentRequestedPermission: String? = null
-
- // Track permission states
- private val permissionStates = mutableStateMapOf()
-
- 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,
- 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
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/entryPoint.kt b/app/src/main/java/dev/oxmc/setupwizard/entryPoint.kt
index 6a4a597..2178a66 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/entryPoint.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/entryPoint.kt
@@ -10,10 +10,9 @@ 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.ui.screens.SplashScreen
@@ -24,7 +23,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")
@@ -47,7 +45,6 @@ class EntryPoint : BaseKioskActivity() {
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 +52,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
}
@@ -78,15 +75,15 @@ class EntryPoint : BaseKioskActivity() {
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()
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/DataClasses.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/DataClasses.kt
index 08e4e07..df05c3a 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/DataClasses.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/lib/DataClasses.kt
@@ -50,47 +50,6 @@ data class OTAInfoCard(
val onClick: () -> Unit = {}
)
-// User data classes
-data class UserInfo(
- val username: String,
- val permissions: List,
- 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,
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/DeviceProfile.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/DeviceProfile.kt
index c1227ae..aa0cf09 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/DeviceProfile.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/lib/DeviceProfile.kt
@@ -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
)
}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/KioskBaseActivity.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/KioskBaseActivity.kt
index 72b2500..0768aa7 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/KioskBaseActivity.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/lib/KioskBaseActivity.kt
@@ -6,11 +6,14 @@ import me.pawlet.setupwizard.lib.FullScreenHelper
open class BaseKioskActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
- FullScreenHelper.onResume(this, true)
+ // When kiosk is off we intentionally do NOT touch FullScreenHelper:
+ // onResume(_, false) still applies immersive mode (hides the bars), so
+ // the only way to show normal system bars is to skip the call entirely.
+ if (WizardFlags.KIOSK) FullScreenHelper.onResume(this, true)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
- FullScreenHelper.onWindowFocusChanged(this, hasFocus, true)
+ if (WizardFlags.KIOSK) FullScreenHelper.onWindowFocusChanged(this, hasFocus, true)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/UpdateChecker.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/UpdateChecker.kt
new file mode 100644
index 0000000..0851571
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/lib/UpdateChecker.kt
@@ -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 {
+ 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()
+ 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" }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/WizardFlags.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/WizardFlags.kt
new file mode 100644
index 0000000..672332a
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/lib/WizardFlags.kt
@@ -0,0 +1,20 @@
+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 / immersive lock-down (hides system bars, re-asserts on focus).
+ * Intended for managed/enrolled devices; disabled for the normal first-run
+ * experience so users can use the status bar and back gesture.
+ */
+ const val KIOSK = false
+
+ /**
+ * 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
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/WizardRegistry.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/WizardRegistry.kt
index db00ae0..7eae692 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/WizardRegistry.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/lib/WizardRegistry.kt
@@ -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,6 +37,14 @@ 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,
@@ -32,6 +60,15 @@ fun buildBuiltinPages(
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(
id = "connection",
order = 30,
@@ -41,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,
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/AuthManager.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/AuthManager.kt
deleted file mode 100644
index 52c4e3a..0000000
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/AuthManager.kt
+++ /dev/null
@@ -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"
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/Helpers.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/Helpers.kt
index 49e2a9a..097ab78 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/Helpers.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/Helpers.kt
@@ -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 {
return Locale.getAvailableLocales()
.distinctBy { it.toLanguageTag() }
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/PermissionManager.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/PermissionManager.kt
deleted file mode 100644
index bc29f47..0000000
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/PermissionManager.kt
+++ /dev/null
@@ -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 = emptySet(),
- val includeInstallPermission: Boolean = false,
- val autoIncludeDependencies: Boolean = true,
- val customPermissionNames: Map = emptyMap()
- )
-
- class Builder(private val context: Context) {
- private val permissions = mutableSetOf()
- private var includeInstallPermission = false
- private var autoIncludeDependencies = true
- private val customPermissionNames = mutableMapOf()
-
- /**
- * 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) = 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) = apply {
- customPermissionNames.putAll(names)
- }
-
- fun build(): PermissionManager {
- return PermissionManager(context, Config(
- permissions = buildFinalPermissionSet(),
- includeInstallPermission = includeInstallPermission,
- autoIncludeDependencies = autoIncludeDependencies,
- customPermissionNames = customPermissionNames
- ))
- }
-
- private fun buildFinalPermissionSet(): Set {
- 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 by lazy {
- config.permissions.filter { shouldRequestPermission(it) }.toSet()
- }
-
- // All permissions including special ones like install permission
- val allPermissions: Set by lazy {
- runtimePermissions + if (config.includeInstallPermission && shouldRequestInstallPermission()) {
- setOf(INSTALL_PERMISSION_KEY)
- } else {
- emptySet()
- }
- }
-
- val permissionDisplayNames: Map 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
- ) {
- if (permission == INSTALL_PERMISSION_KEY) {
- requestInstallPermission()
- } else {
- launcher.launch(permission)
- }
- }
-
- /**
- * Request all missing permissions at once
- */
- fun requestAllMissingPermissions(launcher: ActivityResultLauncher>) {
- val missingPermissions = runtimePermissions.filter { !isPermissionGranted(it) }.toTypedArray()
- if (missingPermissions.isNotEmpty()) {
- launcher.launch(missingPermissions)
- }
- }
-
- /**
- * Get the list of granted permissions
- */
- fun getGrantedPermissions(): Set {
- return allPermissions.filter { isPermissionGranted(it) }.toSet()
- }
-
- /**
- * Get the list of missing permissions
- */
- fun getMissingPermissions(): Set {
- 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()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/oxmcservers.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/oxmcservers.kt
deleted file mode 100644
index ae343ca..0000000
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/oxmcservers.kt
+++ /dev/null
@@ -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 {
- data class Success(val data: T) : ServerResult()
- data class Error(val message: String) : ServerResult()
-}
-
-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 {
- 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 {
- 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 {
- 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 {
- 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> {
- 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()
-
- 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}")
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/utils/SetupUtils.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/utils/SetupUtils.kt
index 39d83e3..feb836e 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/lib/utils/SetupUtils.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/lib/utils/SetupUtils.kt
@@ -566,6 +566,17 @@ class SetupUtils {
WallpaperManager.getInstance(context).forgetLoadedWallpaper()
disableHome(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!")
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/components/WizardStepScaffold.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/components/WizardStepScaffold.kt
new file mode 100644
index 0000000..f12b164
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/components/WizardStepScaffold.kt
@@ -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
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/LoginScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/LoginScreen.kt
deleted file mode 100644
index 901ae6b..0000000
--- a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/LoginScreen.kt
+++ /dev/null
@@ -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))
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SettingsScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SettingsScreen.kt
deleted file mode 100644
index b07863f..0000000
--- a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SettingsScreen.kt
+++ /dev/null
@@ -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()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/BiometricScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/BiometricScreen.kt
new file mode 100644
index 0000000..bd43361
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/BiometricScreen.kt
@@ -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)
+ )
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/BluetoothScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/BluetoothScreen.kt
new file mode 100644
index 0000000..73c0fb4
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/BluetoothScreen.kt
@@ -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)
+ )
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/DateTimeScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/DateTimeScreen.kt
new file mode 100644
index 0000000..3deb86c
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/DateTimeScreen.kt
@@ -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)
+ )
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/DeviceSpecificScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/DeviceSpecificScreen.kt
new file mode 100644
index 0000000..5d7ffc7
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/DeviceSpecificScreen.kt
@@ -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)
+ )
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/LocationScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/LocationScreen.kt
new file mode 100644
index 0000000..bcfc130
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/LocationScreen.kt
@@ -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)
+ )
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/MicroGScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/MicroGScreen.kt
new file mode 100644
index 0000000..e69a887
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/MicroGScreen.kt
@@ -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
+ )
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/NavigationScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/NavigationScreen.kt
new file mode 100644
index 0000000..690c474
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/NavigationScreen.kt
@@ -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) }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/PrivacyScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/PrivacyScreen.kt
new file mode 100644
index 0000000..7c7c9df
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/PrivacyScreen.kt
@@ -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) }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RecoveryUpdateScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RecoveryUpdateScreen.kt
new file mode 100644
index 0000000..cbbba5c
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RecoveryUpdateScreen.kt
@@ -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) }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RestoreScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RestoreScreen.kt
new file mode 100644
index 0000000..be9a696
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RestoreScreen.kt
@@ -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)
+ )
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ScreenLockScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ScreenLockScreen.kt
new file mode 100644
index 0000000..20f0620
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ScreenLockScreen.kt
@@ -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)
+ )
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SetupCompleteScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SetupCompleteScreen.kt
index 7aa128c..486e38d 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SetupCompleteScreen.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SetupCompleteScreen.kt
@@ -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)
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SimMissingScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SimMissingScreen.kt
new file mode 100644
index 0000000..3859348
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SimMissingScreen.kt
@@ -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)
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ThemeScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ThemeScreen.kt
new file mode 100644
index 0000000..994e214
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ThemeScreen.kt
@@ -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) }
+ )
+ }
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/UpdateCheckScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/UpdateCheckScreen.kt
new file mode 100644
index 0000000..dcbce2b
--- /dev/null
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/UpdateCheckScreen.kt
@@ -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.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)
+}
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/WelcomeScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/WelcomeScreen.kt
index e082eaf..764fa95 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/WelcomeScreen.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/WelcomeScreen.kt
@@ -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)
diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/theme/Theme.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/theme/Theme.kt
index 6011762..5e9ae3b 100644
--- a/app/src/main/java/dev/oxmc/setupwizard/ui/theme/Theme.kt
+++ b/app/src/main/java/dev/oxmc/setupwizard/ui/theme/Theme.kt
@@ -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 ---
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index bc2623b..0d4ac94 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,11 +8,6 @@
oxmc
contact@oxmc.me
-
- https://cdn.oxmc.me/applebees/api/v2
-
- https://api.weatherapi.com/v1/current.json?key={}&q={}&aqi=no
- 72d96a584a454382af104944260401
https://cdn.oxmc.me/api