SetupWizard: full step set, cleanup, theme, kiosk/deeplink off, OTA check

Steps: add Bluetooth, SIM-missing, Date/Time, Restore (auto-skip),
Location, microG, Screen-lock, Biometric, Navigation, Theme,
Privacy/Metrics, Device-specific, Recovery-update, and an OTA
update-check step, all on a shared WizardStepScaffold with DeviceProfile
gating. PartnerReceiver + SETUP_COMPLETE broadcast added.

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

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

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

Cleanup: purge Applebee's-mockup code (login/AuthManager, oxmcservers,
ManagerInfo/UserInfo, TemperatureUnit, SettingsScreen, weather + oxmc API
strings). Rework theme off the all-purple palette to neutral surfaces with
violet/pink/teal accents; fix white-on-primary button contrast.
This commit is contained in:
oxmc
2026-07-17 19:43:27 -07:00
parent ec943e82b9
commit 0453c772a7
40 changed files with 1604 additions and 1891 deletions
+15 -27
View File
@@ -89,29 +89,10 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- oxmc domains: config APK downloads, OTA payloads, provisioning manifests -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="cdn.oxmc.me" />
<data android:scheme="https" android:host="cdn.oxmc.me" />
<data android:scheme="http" android:host="oxmc.me" />
<data android:scheme="https" android:host="oxmc.me" />
<data android:scheme="http" android:host="pawlet.oxmc.me" />
<data android:scheme="https" android:host="pawlet.oxmc.me" />
</intent-filter>
<!-- oxn:// internal scheme for system service → wizard IPC
oxn://apk?url=...&silent=true silent APK install during setup
oxn://page?id=... jump to a registered wizard page
oxn://mng management/provisioning commands -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="oxn" />
</intent-filter>
<!-- Deep links disabled for now (WizardFlags.DEEP_LINKS). The oxmc
http(s) VIEW filters and the oxn:// IPC scheme were removed so the
wizard is only launched as the SETUP_WIZARD HOME app. Re-add these
and flip WizardFlags.DEEP_LINKS to restore provisioning deep links. -->
</activity>
<activity
@@ -120,10 +101,6 @@
android:immersive="true"
android:excludeFromRecents="true" />
<activity
android:name=".activities.PermissionsActivity"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="me.pawlet.setupwizard.fileprovider"
@@ -133,5 +110,16 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Partner/provisioning hook: a trusted app holding FINISH_SETUP may
broadcast SETUP_FINISHED to complete setup without user input. -->
<receiver
android:name=".PartnerReceiver"
android:exported="true"
android:permission="me.pawlet.setupwizard.permission.FINISH_SETUP">
<intent-filter>
<action android:name="me.pawlet.setupwizard.SETUP_FINISHED" />
</intent-filter>
</receiver>
</application>
</manifest>
+3 -28
View File
@@ -50,29 +50,9 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- oxmc domains — config APK downloads, OTA payloads, provisioning manifests -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="cdn.oxmc.me" />
<data android:scheme="https" android:host="cdn.oxmc.me" />
<data android:scheme="http" android:host="oxmc.me" />
<data android:scheme="https" android:host="oxmc.me" />
<data android:scheme="http" android:host="pawlet.oxmc.me" />
<data android:scheme="https" android:host="pawlet.oxmc.me" />
</intent-filter>
<!-- oxn:// — internal scheme for system service→wizard IPC
oxn://apk?url=...&silent=true — silent APK install during setup
oxn://page?id=... — jump to a specific wizard page
oxn://mng — management/provisioning commands -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="oxn" />
</intent-filter>
<!-- Deep links disabled for now (WizardFlags.DEEP_LINKS); see the
Soong AndroidManifest.xml. Re-add the oxmc http(s) VIEW filters
and the oxn:// scheme here to restore them. -->
</activity>
<!-- Main activity -->
@@ -80,11 +60,6 @@
android:exported="true">
</activity>
<!-- Permissions activity -->
<activity android:name="dev.oxmc.setupwizard.activities.PermissionsActivity"
android:exported="true">
</activity>
<!-- File provider -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -0,0 +1,39 @@
package me.pawlet.setupwizard
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import me.pawlet.setupwizard.lib.utils.SetupUtils
/**
* Partner customization / remote-finish hook.
*
* A trusted system app that holds me.pawlet.setupwizard.permission.FINISH_SETUP
* (e.g. ConfigProvisioner during zero-touch provisioning) can broadcast
* [ACTION_SETUP_FINISHED] to complete setup without user interaction. When setup
* completes, [SetupUtils.finishSetupWizard] fires [ACTION_SETUP_COMPLETE] so
* partner apps can apply their own customizations.
*/
class PartnerReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_SETUP_FINISHED -> {
Log.i(TAG, "Remote SETUP_FINISHED received; completing setup")
runCatching { SetupUtils().finishSetupWizard(context) }
.onFailure { Log.e(TAG, "finishSetupWizard failed", it) }
}
}
}
companion object {
private const val TAG = "PartnerReceiver"
/** Command: a trusted app asks the wizard to finish now. */
const val ACTION_SETUP_FINISHED = "me.pawlet.setupwizard.SETUP_FINISHED"
/** Notification: setup has completed; partners may customize. */
const val ACTION_SETUP_COMPLETE = "me.pawlet.setupwizard.SETUP_COMPLETE"
}
}
@@ -1,37 +0,0 @@
package me.pawlet.setupwizard
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import me.pawlet.setupwizard.lib.internal.PermissionManager
@SuppressLint("ObsoleteSdkInt")
fun Perms(context: Context): PermissionManager {
return PermissionManager.create(context) {
// Storage (Android 1112L, API 3032)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
addPermissions(Manifest.permission.READ_EXTERNAL_STORAGE)
setPermissionName(Manifest.permission.READ_EXTERNAL_STORAGE, "Read Storage")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Storage (Android 13+, API 33+)
addPermissions(Manifest.permission.READ_MEDIA_AUDIO)
setPermissionName(Manifest.permission.READ_MEDIA_AUDIO, "Read Audio")
}
}
// Notifications (Android 13+, API 33+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
addPermissions(Manifest.permission.POST_NOTIFICATIONS)
setPermissionName(Manifest.permission.POST_NOTIFICATIONS, "Show Notifications")
}
// Phone state (All versions)
addPermissions(Manifest.permission.READ_PHONE_STATE)
setPermissionName(Manifest.permission.READ_PHONE_STATE, "Read Phone State")
// Install packages (All versions)
includeInstallPermission(true)
}
}
@@ -1,55 +0,0 @@
package me.pawlet.setupwizard.activities
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import me.pawlet.setupwizard.ui.screens.LoginScreen
import me.pawlet.setupwizard.lib.internal.AuthManager
import me.pawlet.setupwizard.lib.internal.Helpers
import me.pawlet.setupwizard.lib.internal.PrefManager
import me.pawlet.setupwizard.ui.theme.MainTheme
import kotlinx.coroutines.launch
class LoginActivity : ComponentActivity() {
private lateinit var prefs: PrefManager
private lateinit var authManager: AuthManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = PrefManager(this)
authManager = AuthManager(this)
setContent {
MainTheme {
LoginScreen(
onLoginAttempt = { username, password, storeId, onComplete ->
handleLogin(username, password, storeId, onComplete)
}
)
}
}
}
private fun handleLogin(username: String, password: String, storeId: String, onComplete: (Boolean, String) -> Unit) {
lifecycleScope.launch {
val result = authManager.login(username, password, storeId)
if (result.success) {
onComplete(true, "Login successful")
prefs.saveBoolean("LOGIN_COMPLETE", true)
prefs.saveString("STORE_ID", storeId)
// Navigate to MainActivity
startActivity(Intent(this@LoginActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
})
finish()
} else {
onComplete(false, result.message)
Helpers.Notify.toast(this@LoginActivity, result.message)
}
}
}
}
@@ -13,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) {
@@ -1,221 +0,0 @@
package me.pawlet.setupwizard.activities
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.lib.internal.PermissionManager
import me.pawlet.setupwizard.lib.internal.PrefManager
import me.pawlet.setupwizard.ui.theme.MainTheme
import androidx.core.net.toUri
import me.pawlet.setupwizard.EntryPoint
import me.pawlet.setupwizard.Perms
import kotlin.text.get
class PermissionsActivity : ComponentActivity() {
private lateinit var prefs: PrefManager
private lateinit var permissionManager: PermissionManager
// Launchers for permission requests
private lateinit var singlePermissionLauncher: ActivityResultLauncher<String>
private lateinit var multiplePermissionLauncher: ActivityResultLauncher<Array<String>>
private lateinit var installPermissionLauncher: ActivityResultLauncher<Intent>
// Track which permission is being requested (for single permission launcher)
private var currentRequestedPermission: String? = null
// Track permission states
private val permissionStates = mutableStateMapOf<String, Boolean>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = PrefManager(this)
// Create permission manager with the permissions you need
permissionManager = Perms(this)
// Initialize permission states
permissionManager.allPermissions.forEach { permission ->
permissionStates[permission] = permissionManager.isPermissionGranted(permission)
}
// Setup launchers
singlePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
// Update the specific permission that was requested
currentRequestedPermission?.let { permission ->
// Update the state with the actual permission check
permissionStates[permission] = permissionManager.isPermissionGranted(permission)
currentRequestedPermission = null
}
}
multiplePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
// Update ALL permission states with fresh checks
permissionManager.allPermissions.forEach { permission ->
permissionStates[permission] = permissionManager.isPermissionGranted(permission)
}
}
installPermissionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
// Update install permission state with fresh check
permissionStates[PermissionManager.INSTALL_PERMISSION_KEY] =
permissionManager.isPermissionGranted(PermissionManager.INSTALL_PERMISSION_KEY)
}
// REMOVED the auto-continue check - user must press continue button
setContent {
MainTheme {
PermissionsScreen(
permissionManager = permissionManager,
permissionStates = permissionStates,
onRequestPermission = ::requestPermission,
onRequestAllPermissions = ::requestAllPermissions,
onContinue = ::finishWithSuccess
)
}
}
}
private fun requestPermission(permission: String) {
if (permission == PermissionManager.INSTALL_PERMISSION_KEY) {
// Handle install permission separately
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:$packageName".toUri()
}
installPermissionLauncher.launch(intent)
} else {
// Store which permission we're requesting and launch the request
currentRequestedPermission = permission
singlePermissionLauncher.launch(permission)
}
}
private fun requestAllPermissions() {
permissionManager.requestAllMissingPermissions(multiplePermissionLauncher)
// Also request install permission if needed
if (permissionManager.config.includeInstallPermission &&
!permissionManager.isPermissionGranted(PermissionManager.INSTALL_PERMISSION_KEY)) {
requestPermission(PermissionManager.INSTALL_PERMISSION_KEY)
}
}
private fun finishWithSuccess() {
prefs.hasCompletedInitialPermissions = true
startActivity(Intent(this, EntryPoint::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
})
finish()
}
@Composable
fun PermissionsScreen(
permissionManager: PermissionManager,
permissionStates: Map<String, Boolean>,
onRequestPermission: (String) -> Unit,
onRequestAllPermissions: () -> Unit,
onContinue: () -> Unit
) {
// Calculate all granted state based on current permission states
val allGranted = permissionManager.allPermissions.all { permission ->
permissionStates[permission] == true
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Required Permissions",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(32.dp))
// Display each permission
permissionManager.allPermissions.forEach { permission ->
val displayName = permissionManager.permissionDisplayNames[permission] ?: permission
val granted = permissionStates[permission] ?: false
PermissionRequestItem(
name = displayName,
granted = granted,
onRequest = { onRequestPermission(permission) }
)
Spacer(Modifier.height(16.dp))
}
// Request all button
if (!allGranted) {
Button(
onClick = onRequestAllPermissions,
modifier = Modifier.fillMaxWidth()
) {
Text("Allow All Permissions")
}
Spacer(Modifier.height(16.dp))
}
// Continue button
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
enabled = allGranted
) {
Text(if (allGranted) "Continue to App" else "Complete all permissions to continue")
}
}
}
}
@Composable
private fun PermissionRequestItem(
name: String,
granted: Boolean,
onRequest: () -> Unit
) {
Button(
onClick = onRequest,
enabled = !granted,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (granted) "$name - Granted" else "Allow $name",
maxLines = 1
)
}
}
}
@@ -10,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()
@@ -50,47 +50,6 @@ data class OTAInfoCard(
val onClick: () -> Unit = {}
)
// User data classes
data class UserInfo(
val username: String,
val permissions: List<String>,
val token: String? = null,
val expiration: Long? = null
)
// Unified Manager data class for both UI and API
data class ManagerInfo(
val name: String,
val pronouns: String,
val picture: String,
val storeId: String,
val storeName: String,
val setAt: String = "",
val setBy: String = ""
)
// UI-specific version of ManagerInfo
data class ManagerDetail(
val name: String,
val pronouns: String,
val picture: String,
val storeId: String = "",
val storeName: String = ""
) {
// Convert from ManagerInfo to ManagerDetail
companion object {
fun fromManagerInfo(info: ManagerInfo): ManagerDetail {
return ManagerDetail(
name = info.name,
pronouns = info.pronouns,
picture = info.picture,
storeId = info.storeId,
storeName = info.storeName
)
}
}
}
// Data class for update information
data class UpdateInfo(
val latestVersion: String,
@@ -1,7 +1,10 @@
package me.pawlet.setupwizard.lib
package me.pawlet.setupwizard.lib
import android.app.backup.BackupManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import me.pawlet.setupwizard.lib.utils.SetupUtils
enum class FormFactor { PHONE, TABLET, EMBEDDED }
@@ -10,6 +13,15 @@ data class DeviceProfile(
val hasWifi: Boolean,
val hasTelephony: Boolean,
val hasLeanback: Boolean,
val hasBluetooth: Boolean,
val hasLocation: Boolean,
val hasBiometric: Boolean,
val hasGms: Boolean,
val microGAvailable: Boolean,
val simPresent: Boolean,
val hasRecoveryUpdater: Boolean,
val deviceSpecificAvailable: Boolean,
val backupAvailable: Boolean,
val vendorId: String
) {
val isEmbedded: Boolean get() = formFactor == FormFactor.EMBEDDED
@@ -17,11 +29,40 @@ data class DeviceProfile(
val isTablet: Boolean get() = formFactor == FormFactor.TABLET
companion object {
/** OEM/device-specific setup hook other apps may implement. */
const val ACTION_DEVICE_SPECIFIC = "me.pawlet.setupwizard.DEVICE_SPECIFIC"
fun detect(context: Context): DeviceProfile {
val pm = context.packageManager
val utils = SetupUtils()
val hasWifi = pm.hasSystemFeature(PackageManager.FEATURE_WIFI)
val hasTelephony = pm.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
val hasLeanback = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
val hasBluetooth = pm.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)
val hasLocation = pm.hasSystemFeature(PackageManager.FEATURE_LOCATION)
val hasBiometric = runCatching { utils.hasBiometric(context) }.getOrDefault(false)
// hasGms is true only when the *Google* setup wizard is present too;
// microGAvailable is true whenever microG's GmsCore is installed,
// which is what gates the in-wizard microG step on a Pawlet build.
val hasGms = runCatching { utils.hasGMS(context) }.getOrDefault(false)
val microGAvailable = runCatching {
utils.isPackageInstalled(context, SetupUtils.GMS_PACKAGE)
}.getOrDefault(false)
val simPresent =
hasTelephony && runCatching { !utils.simMissing(context) }.getOrDefault(false)
val hasRecoveryUpdater =
runCatching { utils.hasRecoveryUpdater(context) }.getOrDefault(false)
val deviceSpecificAvailable = runCatching {
pm.resolveActivity(Intent(ACTION_DEVICE_SPECIFIC), 0) != null
}.getOrDefault(false)
// No backup transport ships today, so this stays false and the
// Restore step auto-skips until one is configured.
val backupAvailable =
runCatching { BackupManager(context).isBackupEnabled }.getOrDefault(false)
val formFactor = when {
hasLeanback -> FormFactor.EMBEDDED
@@ -39,6 +80,15 @@ data class DeviceProfile(
hasWifi = hasWifi,
hasTelephony = hasTelephony,
hasLeanback = hasLeanback,
hasBluetooth = hasBluetooth,
hasLocation = hasLocation,
hasBiometric = hasBiometric,
hasGms = hasGms,
microGAvailable = microGAvailable,
simPresent = simPresent,
hasRecoveryUpdater = hasRecoveryUpdater,
deviceSpecificAvailable = deviceSpecificAvailable,
backupAvailable = backupAvailable,
vendorId = vendorId
)
}
@@ -6,11 +6,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)
}
}
@@ -0,0 +1,172 @@
package me.pawlet.setupwizard.lib
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
/**
* Lightweight OTA update check for the setup wizard.
*
* This replicates ONLY the read-only "is there a newer build?" logic of the
* Updater app (me.pawlet.updater): same server URL, same property substitution,
* and the same [isCompatible] rules (version / timestamp / release-type). The
* actual download + A/B install is NOT done here — it is delegated to the
* Updater via [launchUpdater] so update_engine owns the install. Keeping the
* server URL sourced from the Updater's own resources avoids format drift.
*
* Property access goes through reflection (not android.os.SystemProperties)
* because this module is also compiled by the Gradle build, where the platform
* SystemProperties class is not on the SDK classpath.
*/
object UpdateChecker {
private const val UPDATER_PACKAGE = "me.pawlet.updater"
private const val UPDATER_SERVER_URL_RES = "updater_server_url"
// Mirrors me.pawlet.updater.misc.Constants — keep in sync with the Updater.
private const val PROP_BUILD_VERSION = "ro.pawlet.build.version"
private const val PROP_BUILD_DATE = "ro.build.date.utc"
private const val PROP_RELEASE_TYPE = "ro.pawlet.releasetype"
private const val PROP_DEVICE = "ro.product.device"
private const val PROP_NEXT_DEVICE = "ro.updater.next_device"
private const val PROP_BUILD_VERSION_INCREMENTAL = "ro.build.version.incremental"
private const val PROP_UPDATER_URI = "pawlet.updater.uri"
private const val PROP_ALLOW_DOWNGRADING = "pawlet.updater.allow_downgrading"
// Fallback only; the real value is read from the Updater's resources.
private const val DEFAULT_SERVER_URL =
"https://oxmc.me/apis/aosp/ota.php?mode=sysup&device={device}&type={type}&sn={sn}"
data class AvailableUpdate(
val downloadId: String,
val version: String,
val filename: String,
val romType: String,
val sizeBytes: Long,
val downloadUrl: String,
val timestamp: Long
)
/**
* Returns the newest compatible update, or null if the device is up to date,
* offline, or the check fails. Safe to call from the UI via a coroutine.
*/
suspend fun check(context: Context): AvailableUpdate? = withContext(Dispatchers.IO) {
if (!hasValidatedInternet(context)) return@withContext null
val url = buildServerUrl(context) ?: return@withContext null
val body = runCatching { httpGet(url) }.getOrNull() ?: return@withContext null
val candidates = runCatching { parseCompatible(body) }.getOrDefault(emptyList())
candidates.maxByOrNull { it.timestamp }
}
/** Hand off to the Updater app for the actual download + A/B install. */
fun launchUpdater(context: Context) {
val settings = Intent("android.settings.SYSTEM_UPDATE_SETTINGS")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(settings) }.onFailure {
runCatching {
context.packageManager.getLaunchIntentForPackage(UPDATER_PACKAGE)
?.let { context.startActivity(it) }
}
}
}
// --- internals ---
private fun hasValidatedInternet(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
private fun buildServerUrl(context: Context): String? {
val device = prop(PROP_NEXT_DEVICE).ifBlank { prop(PROP_DEVICE) }
if (device.isBlank()) return null
val type = prop(PROP_RELEASE_TYPE).lowercase()
val incr = prop(PROP_BUILD_VERSION_INCREMENTAL)
val serial = runCatching { Build.getSerial() }.getOrDefault(Build.UNKNOWN)
.let { if (it == Build.UNKNOWN) "" else it }
val template = prop(PROP_UPDATER_URI).ifBlank { updaterServerUrlFromResources(context) }
return template
.replace("{device}", device)
.replace("{type}", type)
.replace("{incr}", incr)
.replace("{sn}", serial)
}
private fun updaterServerUrlFromResources(context: Context): String = runCatching {
val res = context.packageManager.getResourcesForApplication(UPDATER_PACKAGE)
val id = res.getIdentifier(UPDATER_SERVER_URL_RES, "string", UPDATER_PACKAGE)
if (id != 0) res.getString(id) else DEFAULT_SERVER_URL
}.getOrDefault(DEFAULT_SERVER_URL)
private fun httpGet(url: String): String {
val conn = URL(url).openConnection() as HttpURLConnection
return conn.run {
requestMethod = "GET"
connectTimeout = 10_000
readTimeout = 10_000
try {
if (responseCode != HttpURLConnection.HTTP_OK) error("HTTP $responseCode")
inputStream.bufferedReader().use { it.readText() }
} finally {
disconnect()
}
}
}
/** Parse the "response" array and keep only compatible, newer builds. */
private fun parseCompatible(json: String): List<AvailableUpdate> {
val curVersion = prop(PROP_BUILD_VERSION)
val curDate = propLong(PROP_BUILD_DATE)
val curType = prop(PROP_RELEASE_TYPE)
val allowDowngrade = propBool(PROP_ALLOW_DOWNGRADING)
val arr = JSONObject(json).optJSONArray("response") ?: return emptyList()
val out = ArrayList<AvailableUpdate>()
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val version = o.optString("version")
val romType = o.optString("romtype")
val timestamp = o.optLong("datetime")
// Same rules as Updater Utils.isCompatible():
if (version.compareTo(curVersion) < 0) continue
if (!allowDowngrade && timestamp <= curDate) continue
if (!romType.equals(curType, ignoreCase = true)) continue
out += AvailableUpdate(
downloadId = o.optString("id"),
version = version,
filename = o.optString("filename"),
romType = romType,
sizeBytes = o.optLong("size"),
downloadUrl = o.optString("url"),
timestamp = timestamp
)
}
return out
}
private fun prop(key: String, def: String = ""): String = try {
@Suppress("DiscouragedPrivateApi")
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java, String::class.java)
.invoke(null, key, def) as String
} catch (_: Exception) { def }
private fun propLong(key: String, def: Long = 0L): Long = prop(key).toLongOrNull() ?: def
private fun propBool(key: String): Boolean = prop(key).let { it == "true" || it == "1" }
}
@@ -0,0 +1,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
}
@@ -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,
@@ -1,260 +0,0 @@
package me.pawlet.setupwizard.lib.internal
import android.content.Context
import android.util.Log
import me.pawlet.setupwizard.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONException
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
data class LoginResult(
val success: Boolean,
val message: String,
val token: String? = null
)
class AuthManager(private val context: Context) {
private val prefs = PrefManager(context)
private val baseUrl = context.getString(R.string.oxmc_api_url)
companion object {
private const val PREF_STORE_ID = "store_id"
private const val PREF_LOGIN_COMPLETE = "login_complete"
}
/**
* Performs login with email and password, saves token and store ID
*/
suspend fun login(email: String, password: String, storeId: String): LoginResult {
return withContext(Dispatchers.IO) {
try {
val url = URL("$baseUrl/login")
val connection = url.openConnection() as HttpURLConnection
connection.apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
connectTimeout = 10000
readTimeout = 10000
}
// Send login credentials
val jsonBody = JSONObject().apply {
put("email", email)
put("password", password)
}
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(jsonBody.toString())
writer.flush()
}
val responseCode = connection.responseCode
val response = if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader(InputStreamReader(connection.inputStream)).use { it.readText() }
} else {
BufferedReader(InputStreamReader(connection.errorStream)).use { it.readText() }
}
connection.disconnect()
// Try to parse as JSON, handle non-JSON responses
val jsonResponse = try {
JSONObject(response)
} catch (e: JSONException) {
// If response is not JSON, create a JSON object with the raw response as message
JSONObject().apply {
put("success", false)
put("message", response.takeIf { it.isNotBlank() } ?: "Unknown error")
}
}
if (responseCode == HttpURLConnection.HTTP_OK && jsonResponse.optBoolean("success", false)) {
val token = jsonResponse.optString("token")
// Save credentials and store ID
prefs.saveString("auth_token", token)
prefs.saveString("store_id", storeId)
prefs.saveString("email", email)
prefs.saveBoolean("is_logged_in", true)
Log.i("AuthManager", "Login successful for user: $email, store: $storeId")
LoginResult(
success = true,
message = "Login successful",
token = token
)
} else {
val errorMessage = jsonResponse.optString("message", "Login failed")
Log.e("AuthManager", "Login failed: $errorMessage")
LoginResult(success = false, message = errorMessage)
}
} catch (e: Exception) {
Log.e("AuthManager", "Login error: ${e.message}", e)
LoginResult(
success = false,
message = "Connection error: ${e.message ?: "Unknown error"}"
)
}
}
}
// Call this when login is successful
fun saveLoginState(storeId: String) {
prefs.saveBoolean(PREF_LOGIN_COMPLETE, true)
prefs.saveString(PREF_STORE_ID, storeId)
}
// Check if login is complete
fun isLoginComplete(): Boolean {
return prefs.getBoolean(PREF_LOGIN_COMPLETE, false)
}
// Clear login state (for logout)
fun clearLoginState() {
prefs.saveBoolean(PREF_LOGIN_COMPLETE, false)
prefs.saveString(PREF_STORE_ID, "")
}
/**
* Validates the current token with the server and handles automatic renewal
*/
suspend fun validateToken(): Boolean {
return withContext(Dispatchers.IO) {
try {
val token = prefs.getString("auth_token", null) ?: return@withContext false
val url = URL("$baseUrl/check-token")
val connection = url.openConnection() as HttpURLConnection
connection.apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
connectTimeout = 10000
readTimeout = 10000
}
val jsonBody = JSONObject().apply {
put("token", token)
}
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(jsonBody.toString())
writer.flush()
}
val responseCode = connection.responseCode
val response = BufferedReader(InputStreamReader(
if (responseCode == HttpURLConnection.HTTP_OK) connection.inputStream
else connection.errorStream
)).use { it.readText() }
connection.disconnect()
val jsonResponse = JSONObject(response)
val isValid = responseCode == HttpURLConnection.HTTP_OK &&
jsonResponse.optBoolean("success", false)
if (isValid) {
// Check if server provided a renewed token
val newToken = jsonResponse.optString("token", null)
val renewed = jsonResponse.optBoolean("renewed", false)
if (!newToken.isNullOrEmpty() && renewed) {
prefs.saveString("auth_token", newToken)
Log.i("AuthManager", "Token automatically renewed")
}
} else {
Log.w("AuthManager", "Token validation failed, clearing auth data")
clearAuthData()
}
isValid
} catch (e: Exception) {
Log.e("AuthManager", "Token validation error: ${e.message}", e)
false
}
}
}
/**
* Checks for token renewal in response headers and updates if present
* Call this after every authenticated API request
*/
fun checkAndUpdateTokenFromHeaders(connection: HttpURLConnection) {
try {
val newToken = connection.getHeaderField("X-New-Token")
if (!newToken.isNullOrEmpty()) {
prefs.saveString("auth_token", newToken)
Log.i("AuthManager", "Token automatically renewed from header")
}
} catch (e: Exception) {
Log.e("AuthManager", "Error checking token renewal: ${e.message}", e)
}
}
/**
* Gets the current auth token
*/
fun getToken(): String? {
return prefs.getString("auth_token", null)
}
/**
* Gets the current store ID
*/
fun getStoreId(): String? {
return prefs.getString("store_id", null)
}
/**
* Gets the current username
*/
fun getUsername(): String? {
return prefs.getString("username", null)
}
/**
* Checks if user is logged in
*/
fun isLoggedIn(): Boolean {
return prefs.getBoolean("is_logged_in", false) &&
getToken() != null &&
getStoreId() != null
}
/**
* Logs out the user and clears all auth data
*/
fun logout() {
clearAuthData()
Log.i("AuthManager", "User logged out")
}
/**
* Clears all authentication data
*/
private fun clearAuthData() {
prefs.remove("auth_token")
prefs.remove("store_id")
prefs.remove("username")
prefs.saveBoolean("is_logged_in", false)
}
/**
* Creates authorization header for API requests
*/
fun getAuthHeader(): String {
val token = getToken() ?: ""
return "Bearer $token"
}
}
@@ -35,20 +35,9 @@ import kotlin.coroutines.resume
import me.pawlet.setupwizard.lib.Region
import java.util.Currency
enum class TemperatureUnit(val displayName: String) {
FAHRENHEIT("Fahrenheit (°F)"),
CELSIUS("Celsius (°C)")
}
class Helpers {
companion object {
val arch: String get() = Build.SUPPORTED_ABIS.first().replace("-v", "_v")
fun convertTemperature(fahrenheit: Double, unit: TemperatureUnit): String {
return when (unit) {
TemperatureUnit.FAHRENHEIT -> "${fahrenheit.toInt()}°F"
TemperatureUnit.CELSIUS -> "${((fahrenheit - 32) * 5 / 9).toInt()}°C"
}
}
fun getSystemLocales(): List<Locale> {
return Locale.getAvailableLocales()
.distinctBy { it.toLanguageTag() }
@@ -1,283 +0,0 @@
package me.pawlet.setupwizard.lib.internal
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
class PermissionManager private constructor(
private val context: Context,
val config: Config
) {
data class Config(
val permissions: Set<String> = emptySet(),
val includeInstallPermission: Boolean = false,
val autoIncludeDependencies: Boolean = true,
val customPermissionNames: Map<String, String> = emptyMap()
)
class Builder(private val context: Context) {
private val permissions = mutableSetOf<String>()
private var includeInstallPermission = false
private var autoIncludeDependencies = true
private val customPermissionNames = mutableMapOf<String, String>()
/**
* Add a single permission to request
*/
fun addPermission(permission: String) = apply {
permissions.add(permission)
}
/**
* Add multiple permissions to request
*/
fun addPermissions(vararg permissions: String) = apply {
this.permissions.addAll(permissions)
}
/**
* Add permissions from a collection
*/
fun addPermissions(permissions: Collection<String>) = apply {
this.permissions.addAll(permissions)
}
/**
* Include the "Install Unknown Apps" permission (Android O+)
*/
fun includeInstallPermission(include: Boolean = true) = apply {
this.includeInstallPermission = include
}
/**
* Automatically include permission dependencies (e.g., location for Bluetooth)
*/
fun autoIncludeDependencies(autoInclude: Boolean = true) = apply {
this.autoIncludeDependencies = autoInclude
}
/**
* Set a custom display name for a permission
*/
fun setPermissionName(permission: String, displayName: String) = apply {
customPermissionNames[permission] = displayName
}
/**
* Set custom display names for multiple permissions
*/
fun setPermissionNames(names: Map<String, String>) = apply {
customPermissionNames.putAll(names)
}
fun build(): PermissionManager {
return PermissionManager(context, Config(
permissions = buildFinalPermissionSet(),
includeInstallPermission = includeInstallPermission,
autoIncludeDependencies = autoIncludeDependencies,
customPermissionNames = customPermissionNames
))
}
private fun buildFinalPermissionSet(): Set<String> {
val finalPermissions = permissions.toMutableSet()
if (autoIncludeDependencies) {
// Auto-add dependencies based on the permissions requested
if (finalPermissions.any { it.startsWith("android.permission.BLUETOOTH") }) {
finalPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (finalPermissions.contains(Manifest.permission.ACCESS_COARSE_LOCATION) &&
!finalPermissions.contains(Manifest.permission.ACCESS_FINE_LOCATION)) {
finalPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
return finalPermissions.filter { shouldRequestPermission(it) }.toSet()
}
/**
* Check if a permission should be requested based on Android version
*/
private fun shouldRequestPermission(permission: String): Boolean {
return when {
// Storage permissions are not needed on Android 11+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
(permission == Manifest.permission.READ_EXTERNAL_STORAGE ||
permission == Manifest.permission.WRITE_EXTERNAL_STORAGE) -> false
// Skip legacy Bluetooth permissions on Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(permission == Manifest.permission.BLUETOOTH ||
permission == Manifest.permission.BLUETOOTH_ADMIN) -> false
else -> true
}
}
}
// Runtime permissions that need to be requested
val runtimePermissions: Set<String> by lazy {
config.permissions.filter { shouldRequestPermission(it) }.toSet()
}
// All permissions including special ones like install permission
val allPermissions: Set<String> by lazy {
runtimePermissions + if (config.includeInstallPermission && shouldRequestInstallPermission()) {
setOf(INSTALL_PERMISSION_KEY)
} else {
emptySet()
}
}
val permissionDisplayNames: Map<String, String> by lazy {
allPermissions.associateWith { permission ->
config.customPermissionNames[permission] ?: when (permission) {
INSTALL_PERMISSION_KEY -> "Install Apps"
Manifest.permission.CAMERA -> "Camera"
Manifest.permission.READ_EXTERNAL_STORAGE -> "Storage Access"
Manifest.permission.WRITE_EXTERNAL_STORAGE -> "Storage Access"
Manifest.permission.POST_NOTIFICATIONS -> "Notifications"
Manifest.permission.ACCESS_FINE_LOCATION -> "Precise Location"
Manifest.permission.ACCESS_COARSE_LOCATION -> "Approximate Location"
Manifest.permission.BLUETOOTH -> "Bluetooth"
Manifest.permission.BLUETOOTH_ADMIN -> "Bluetooth Admin"
Manifest.permission.BLUETOOTH_SCAN -> "Bluetooth Scan"
Manifest.permission.BLUETOOTH_CONNECT -> "Bluetooth Connect"
Manifest.permission.BLUETOOTH_ADVERTISE -> "Bluetooth Advertise"
Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.READ_CONTACTS -> "Contacts"
Manifest.permission.WRITE_CONTACTS -> "Contacts"
Manifest.permission.READ_CALENDAR -> "Calendar"
Manifest.permission.WRITE_CALENDAR -> "Calendar"
Manifest.permission.READ_PHONE_STATE -> "Phone"
Manifest.permission.CALL_PHONE -> "Phone Calls"
Manifest.permission.READ_SMS -> "SMS"
Manifest.permission.RECEIVE_SMS -> "SMS"
Manifest.permission.SEND_SMS -> "SMS"
Manifest.permission.BODY_SENSORS -> "Body Sensors"
Manifest.permission.ACTIVITY_RECOGNITION -> "Physical Activity"
else -> permission.substringAfterLast('.').replace("_", " ")
}
}
}
/**
* Check if a specific permission is granted
*/
fun isPermissionGranted(permission: String): Boolean {
return when {
permission == INSTALL_PERMISSION_KEY -> isInstallPermissionGranted()
else -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
}
/**
* Check if all permissions are granted
*/
fun areAllPermissionsGranted(): Boolean {
return allPermissions.all { isPermissionGranted(it) }
}
/**
* Check if all runtime permissions (excluding install permission) are granted
*/
fun areRuntimePermissionsGranted(): Boolean {
return runtimePermissions.all { isPermissionGranted(it) }
}
/**
* Request a single permission
*/
fun requestPermission(
permission: String,
launcher: ActivityResultLauncher<String>
) {
if (permission == INSTALL_PERMISSION_KEY) {
requestInstallPermission()
} else {
launcher.launch(permission)
}
}
/**
* Request all missing permissions at once
*/
fun requestAllMissingPermissions(launcher: ActivityResultLauncher<Array<String>>) {
val missingPermissions = runtimePermissions.filter { !isPermissionGranted(it) }.toTypedArray()
if (missingPermissions.isNotEmpty()) {
launcher.launch(missingPermissions)
}
}
/**
* Get the list of granted permissions
*/
fun getGrantedPermissions(): Set<String> {
return allPermissions.filter { isPermissionGranted(it) }.toSet()
}
/**
* Get the list of missing permissions
*/
fun getMissingPermissions(): Set<String> {
return allPermissions.filter { !isPermissionGranted(it) }.toSet()
}
/**
* Check if a permission should be requested based on Android version
*/
fun shouldRequestPermission(permission: String): Boolean {
return when {
// Storage permissions are not needed on Android 11+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
(permission == Manifest.permission.READ_EXTERNAL_STORAGE ||
permission == Manifest.permission.WRITE_EXTERNAL_STORAGE) -> false
// Skip legacy Bluetooth permissions on Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(permission == Manifest.permission.BLUETOOTH ||
permission == Manifest.permission.BLUETOOTH_ADMIN) -> false
else -> true
}
}
private fun shouldRequestInstallPermission(): Boolean {
return config.includeInstallPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}
private fun isInstallPermissionGranted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true
}
}
@SuppressLint("InlinedApi")
private fun requestInstallPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
}
context.startActivity(intent)
}
}
companion object {
const val INSTALL_PERMISSION_KEY = "INSTALL_UNKNOWN_APPS"
fun create(context: Context, block: Builder.() -> Unit = {}): PermissionManager {
return Builder(context).apply(block).build()
}
}
}
@@ -1,305 +0,0 @@
package me.pawlet.setupwizard.lib.internal
import android.content.Context
import android.util.Log
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.ManagerInfo
import me.pawlet.setupwizard.lib.UpdateInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
sealed class ServerResult<out T> {
data class Success<T>(val data: T) : ServerResult<T>()
data class Error(val message: String) : ServerResult<Nothing>()
}
class OxmcServers(private val context: Context) {
private val authManager = AuthManager(context)
private val baseUrl = context.getString(R.string.oxmc_api_url)
/**
* Helper function to make authenticated requests and handle token renewal
*/
private suspend fun makeAuthenticatedRequest(
urlPath: String,
method: String,
body: JSONObject? = null,
requireAuth: Boolean = false
): Pair<Int, String> {
return withContext(Dispatchers.IO) {
val url = URL("$urlPath")
val connection = url.openConnection() as HttpURLConnection
try {
connection.apply {
requestMethod = method
setRequestProperty("Content-Type", "application/json")
if (requireAuth) {
val token = authManager.getToken()
if (token != null) {
setRequestProperty("Authorization", "Bearer $token")
}
}
if (body != null) {
doOutput = true
}
connectTimeout = 10000
readTimeout = 10000
}
// Send body if present
if (body != null) {
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(body.toString())
writer.flush()
}
}
val responseCode = connection.responseCode
val response = BufferedReader(InputStreamReader(
if (responseCode in 200..299) connection.inputStream
else connection.errorStream
)).use { it.readText() }
// Check for token renewal in response headers
if (requireAuth) {
authManager.checkAndUpdateTokenFromHeaders(connection)
}
Pair(responseCode, response)
} finally {
connection.disconnect()
}
}
}
/**
* Check for app updates
*/
suspend fun checkForUpdates(appId: String, currentVersion: String): ServerResult<UpdateInfo> {
return try {
val jsonBody = JSONObject().apply {
put("app", JSONObject().apply {
put("id", appId)
put("version", currentVersion)
})
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/updateCheck",
"POST",
jsonBody
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
ServerResult.Success(
UpdateInfo(
latestVersion = jsonResponse.getString("latestVersion"),
updateAvailable = jsonResponse.getBoolean("updateAvailable"),
downloadUrl = jsonResponse.getString("downloadUrl"),
releaseNotes = jsonResponse.optString("releaseNotes", null)
)
)
} else {
val errorMsg = try {
JSONObject(response).optString("error", "Update check failed")
} catch (e: Exception) {
"Update check failed"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Update check error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Get the current manager on duty for a store
*/
suspend fun getManagerOnDuty(storeId: String? = null): ServerResult<ManagerInfo> {
return try {
val store = storeId ?: authManager.getStoreId()
if (store == null) {
return ServerResult.Error("No store ID available")
}
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/manager/$store",
"GET",
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val manager = jsonResponse.getJSONObject("manager")
ServerResult.Success(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = manager.getString("storeId"),
storeName = manager.getString("storeName"),
setAt = manager.getString("setAt"),
setBy = manager.getString("setBy")
)
)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to get manager"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to get manager")
} catch (e: Exception) {
"Failed to get manager"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Get manager error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Set the manager on duty for a store
*/
suspend fun setManagerOnDuty(
managerId: String? = null,
name: String? = null,
pronouns: String? = null,
picture: String? = null,
storeId: String? = null
): ServerResult<ManagerInfo> {
return try {
val store = storeId ?: authManager.getStoreId()
if (store == null) {
return ServerResult.Error("No store ID available")
}
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val jsonBody = JSONObject().apply {
put("storeId", store)
if (managerId != null) {
put("managerId", managerId)
} else {
put("name", name)
put("pronouns", pronouns)
put("picture", picture)
}
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/manager",
"POST",
jsonBody,
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val manager = jsonResponse.getJSONObject("manager")
ServerResult.Success(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = manager.getString("storeId"),
storeName = manager.getString("storeName"),
setAt = manager.getString("setAt"),
setBy = manager.getString("setBy")
)
)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to set manager"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to set manager")
} catch (e: Exception) {
"Failed to set manager"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Set manager error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Get list of all available managers
*/
suspend fun getAvailableManagers(): ServerResult<List<ManagerInfo>> {
return try {
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/managers",
"GET",
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val managersArray = jsonResponse.getJSONArray("managers")
val managers = mutableListOf<ManagerInfo>()
for (i in 0 until managersArray.length()) {
val manager = managersArray.getJSONObject(i)
managers.add(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = "",
storeName = "",
setAt = manager.optString("created_at", ""),
setBy = ""
)
)
}
ServerResult.Success(managers)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to get managers"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to get managers")
} catch (e: Exception) {
"Failed to get managers"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Get managers error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
}
@@ -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!")
}
}
@@ -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
)
}
}
}
}
@@ -1,213 +0,0 @@
package me.pawlet.setupwizard.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.R
@Composable
fun LoginScreen(
onLoginAttempt: (String, String, String, (Boolean, String) -> Unit) -> Unit
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var storeId by remember { mutableStateOf("") }
var isLoggingIn by remember { mutableStateOf(false) }
var loginError by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(32.dp)
.fillMaxWidth()
.widthIn(max = 500.dp) // Limit width for landscape
) {
// Logo
Image(
painter = painterResource(R.drawable.pink_protogen),
contentDescription = "App Logo",
modifier = Modifier
.size(120.dp)
.padding(bottom = 8.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Manager on Duty",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "First Time Setup",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(24.dp))
OutlinedTextField(
value = storeId,
onValueChange = {
storeId = it
loginError = "" // Clear error on input
},
label = { Text("Store ID") },
placeholder = { Text("e.g., 8949") },
singleLine = true,
enabled = !isLoggingIn,
modifier = Modifier.fillMaxWidth(),
isError = loginError.isNotEmpty() && storeId.isBlank()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = email,
onValueChange = {
email = it
loginError = "" // Clear error on input
},
label = { Text("Email") },
singleLine = true,
enabled = !isLoggingIn,
modifier = Modifier.fillMaxWidth(),
isError = loginError.isNotEmpty() && email.isBlank()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = {
password = it
loginError = "" // Clear error on input
},
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !isLoggingIn,
modifier = Modifier.fillMaxWidth(),
isError = loginError.isNotEmpty() && password.isBlank()
)
Spacer(modifier = Modifier.height(20.dp))
if (loginError.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = loginError,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
}
Button(
onClick = {
// Validate fields
when {
storeId.isBlank() -> {
loginError = "Store ID is required"
return@Button
}
email.isBlank() -> {
loginError = "Email is required"
return@Button
}
password.isBlank() -> {
loginError = "Password is required"
return@Button
}
}
loginError = ""
isLoggingIn = true
// Call login with callback to reset button state
onLoginAttempt(email, password, storeId) { success, message ->
isLoggingIn = false
if (!success) {
loginError = message
}
}
},
enabled = !isLoggingIn,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
if (isLoggingIn) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(12.dp))
Text("Logging in...")
}
} else {
Text("Login")
}
}
// Add some bottom padding for landscape mode
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -1,348 +0,0 @@
package me.pawlet.setupwizard.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AddTask
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Store
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.lib.internal.PrefManager
import me.pawlet.setupwizard.lib.internal.TemperatureUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onNavigateToAbout: () -> Unit
) {
val context = LocalContext.current
val prefs = remember { PrefManager(context) }
// State for settings
var storeId by remember { mutableStateOf(prefs.getString("store_id", "")) }
var temperatureUnit by remember {
mutableStateOf(
TemperatureUnit.valueOf(
prefs.getString("temperature_unit", TemperatureUnit.FAHRENHEIT.name).toString()
)
)
}
var autoUpdate by remember { mutableStateOf(prefs.getBoolean("auto_update", true)) }
// Dialog states
var showStoreIdDialog by remember { mutableStateOf(false) }
var showTemperatureDialog by remember { mutableStateOf(false) }
var tempStoreId by remember { mutableStateOf(storeId) }
var tempTemperatureUnit by remember { mutableStateOf(temperatureUnit) }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
// Store Configuration Section
SettingsSection(title = "Store Configuration") {
SettingsItem(
icon = Icons.Default.Store,
title = "Store ID",
subtitle = storeId?.let { if (it.isEmpty()) "Not set" else storeId },
onClick = {
tempStoreId = storeId
showStoreIdDialog = true
}
)
}
// Display Preferences Section
SettingsSection(title = "Display Preferences") {
SettingsItem(
icon = Icons.Default.Thermostat,
title = "Temperature Unit",
subtitle = temperatureUnit.displayName,
onClick = {
tempTemperatureUnit = temperatureUnit
showTemperatureDialog = true
}
)
}
// Connection Settings Section
SettingsSection(title = "Connection Settings") {
var useRestOnly by remember { mutableStateOf(prefs.getBoolean("use_rest_only", false)) }
SettingsItem(
icon = Icons.Default.Cloud,
title = "Use REST API Only",
subtitle = if (useRestOnly) "WebSocket disabled - using REST polling" else "WebSocket enabled with REST fallback",
trailing = {
Switch(
checked = useRestOnly,
onCheckedChange = {
useRestOnly = it
prefs.saveBoolean("use_rest_only", it)
// You might want to show a restart message here
}
)
}
)
}
// Auto Update Section
SettingsSection(title = "Updates") {
SettingsItem(
icon = Icons.Default.AddTask,
title = "Auto Update",
subtitle = "Automatically check for updates",
trailing = {
Switch(
checked = autoUpdate,
onCheckedChange = {
autoUpdate = it
prefs.saveBoolean("auto_update", it)
}
)
}
)
}
// App Info Section
SettingsSection(title = "About") {
SettingsItem(
title = "About this app",
subtitle = "App information",
onClick = onNavigateToAbout
)
}
// Spacer at bottom
Spacer(modifier = Modifier.height(32.dp))
}
}
// Store ID Dialog
if (showStoreIdDialog) {
AlertDialog(
onDismissRequest = { showStoreIdDialog = false },
title = { Text("Set Store ID") },
text = {
Column {
Text(
text = "Enter your store ID to load manager information",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = tempStoreId.toString(),
onValueChange = { tempStoreId = it },
label = { Text("Store ID") },
placeholder = { Text("e.g., 12345") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
storeId = tempStoreId
prefs.saveString("store_id", tempStoreId.toString())
showStoreIdDialog = false
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showStoreIdDialog = false }) {
Text("Cancel")
}
}
)
}
// Temperature Unit Dialog
if (showTemperatureDialog) {
AlertDialog(
onDismissRequest = { showTemperatureDialog = false },
title = { Text("Temperature Unit") },
text = {
Column {
Text(
text = "Select your preferred temperature unit",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
TemperatureUnit.entries.forEach { unit ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = tempTemperatureUnit == unit,
onClick = { tempTemperatureUnit = unit }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = unit.displayName,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
temperatureUnit = tempTemperatureUnit
prefs.saveString("temperature_unit", tempTemperatureUnit.name)
showTemperatureDialog = false
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showTemperatureDialog = false }) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun SettingsSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp, 16.dp, 16.dp, 8.dp)
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
content()
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun SettingsItem(
icon: ImageVector? = null,
title: String,
subtitle: String? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Surface(
onClick = onClick ?: {},
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
icon?.let {
Icon(
imageVector = it,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
}
@@ -0,0 +1,50 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.hardware.biometrics.BiometricManager
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Biometric enrollment step. Gated on biometric hardware (hasBiometric), so it
* never shows on RPi. Launches the platform enrollment flow.
*/
@Composable
fun BiometricScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Fingerprint,
title = "Fingerprint & face",
subtitle = "Set up biometric unlock for a faster, secure way to sign in. Optional — you can add this later.",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Set up biometrics",
onClick = {
val enroll = Intent(Settings.ACTION_BIOMETRIC_ENROLL).putExtra(
Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(enroll) }.onFailure {
runCatching {
context.startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
)
}
}
@@ -0,0 +1,42 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Bluetooth step. Offers to pair accessories (keyboard, remote, controller) via
* the platform Bluetooth settings. Gated on FEATURE_BLUETOOTH; optional.
*/
@Composable
fun BluetoothScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Bluetooth,
title = "Bluetooth",
subtitle = "Pair a keyboard, remote, controller, or other accessory. You can skip this and pair devices later from Settings.",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Pair a device",
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,114 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import java.text.DateFormat
import java.util.Date
/**
* Date & time step. Raspberry Pi boards have no battery-backed RTC, so the
* default is automatic (network/NTP) time. The user may toggle it off and open
* the platform date/time settings to set it manually. The screen writes the
* AUTO_TIME / AUTO_TIME_ZONE global settings itself and then advances.
*/
@Composable
fun DateTimeScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
var autoTime by remember {
mutableStateOf(Settings.Global.getInt(cr, Settings.Global.AUTO_TIME, 1) == 1)
}
fun applyAutoTime(enabled: Boolean) {
autoTime = enabled
val value = if (enabled) 1 else 0
Settings.Global.putInt(cr, Settings.Global.AUTO_TIME, value)
Settings.Global.putInt(cr, Settings.Global.AUTO_TIME_ZONE, value)
}
val now = remember { DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.SHORT).format(Date()) }
WizardStepScaffold(
icon = Icons.Filled.Schedule,
title = "Date & time",
subtitle = "This device has no battery clock, so it keeps time from the network. You can set it manually if you prefer.",
onContinue = onContinue,
onBack = onBack
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Set automatically",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Use network-provided time and time zone",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = autoTime,
onCheckedChange = { applyAutoTime(it) }
)
}
}
Text(
text = "Current: $now",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 4.dp)
)
WizardSecondaryButton(
label = "Open date & time settings",
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_DATE_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,43 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Tune
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.lib.DeviceProfile
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Device-specific OEM hook. Only shown when some app on the device implements
* the ACTION_DEVICE_SPECIFIC intent (gated by profile.deviceSpecificAvailable);
* it hands off to that app for board-specific setup, then returns here.
*/
@Composable
fun DeviceSpecificScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Tune,
title = "Device setup",
subtitle = "There are a few extra options specific to this hardware.",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Open device setup",
onClick = {
runCatching {
context.startActivity(
Intent(DeviceProfile.ACTION_DEVICE_SPECIFIC)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,71 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.location.LocationManager
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import me.pawlet.setupwizard.ui.components.WizardToggleCard
/**
* Location step. Lets the user enable location services (backed by microG's
* UnifiedNlp on PawletOS) and jump to the platform location settings.
*/
@Composable
fun LocationScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
val lm = context.getSystemService(LocationManager::class.java)
var enabled by remember {
mutableStateOf(runCatching { lm.isLocationEnabled }.getOrDefault(false))
}
WizardStepScaffold(
icon = Icons.Filled.LocationOn,
title = "Location",
subtitle = "Allow apps that you permit to use this device's location. You can change this at any time in Settings.",
onContinue = onContinue,
onBack = onBack
) {
WizardToggleCard(
title = "Use location",
subtitle = "Location services (network/GPS)",
checked = enabled,
onCheckedChange = {
enabled = it
runCatching {
Settings.Secure.putInt(
cr,
Settings.Secure.LOCATION_MODE,
if (it) Settings.Secure.LOCATION_MODE_HIGH_ACCURACY
else Settings.Secure.LOCATION_MODE_OFF
)
}
}
)
WizardSecondaryButton(
label = "Open location settings",
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,67 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.accounts.AccountManager
import android.app.Activity
import android.content.Intent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
private const val GMS_PACKAGE = "com.google.android.gms"
private const val MICROG_SETTINGS = "org.microg.gms.ui.SettingsActivity"
private const val GOOGLE_ACCOUNT_TYPE = "com.google"
/**
* Google services (microG) step. PawletOS ships microG in place of GMS, so
* instead of handing off to Google's setup wizard this lets the user open
* microG's own settings/self-check and, optionally, sign in to a Google account
* (microG's authenticator handles the flow). Gated on microGAvailable
* (microG's GmsCore installed) — not hasGms, which also needs Google's SUW.
*/
@Composable
fun MicroGScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Cloud,
title = "Google services",
subtitle = "This device uses microG, an open-source Google services layer. Configure what it may do, and optionally sign in to a Google account.",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Open microG settings",
onClick = {
val direct = Intent().setClassName(GMS_PACKAGE, MICROG_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(direct) }.onFailure {
runCatching {
context.packageManager.getLaunchIntentForPackage(GMS_PACKAGE)
?.let { context.startActivity(it) }
}
}
}
)
WizardSecondaryButton(
label = "Sign in to Google",
onClick = {
val activity = context as? Activity ?: return@WizardSecondaryButton
runCatching {
AccountManager.get(context).addAccount(
GOOGLE_ACCOUNT_TYPE,
null, null, null,
activity,
null, null
)
}
}
)
}
}
@@ -0,0 +1,61 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardRadioOption
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
// AOSP navigation_mode: 0 = 3-button, 2 = gesture.
private const val NAV_MODE_3BUTTON = 0
private const val NAV_MODE_GESTURE = 2
/**
* System navigation step. Chooses between gesture and 3-button navigation and
* writes the AOSP "navigation_mode" secure setting (same key finishSetupWizard's
* navigation handling uses).
*/
@Composable
fun NavigationScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
var mode by remember {
mutableIntStateOf(Settings.Secure.getInt(cr, "navigation_mode", NAV_MODE_GESTURE))
}
fun apply(newMode: Int) {
mode = newMode
runCatching { Settings.Secure.putInt(cr, "navigation_mode", newMode) }
}
WizardStepScaffold(
icon = Icons.Filled.Navigation,
title = "System navigation",
subtitle = "Choose how you move around the system.",
onContinue = onContinue,
onBack = onBack
) {
WizardRadioOption(
label = "Gesture navigation",
subtitle = "Swipe from the edges to go home, back, and switch apps",
selected = mode == NAV_MODE_GESTURE,
onSelect = { apply(NAV_MODE_GESTURE) }
)
WizardRadioOption(
label = "3-button navigation",
subtitle = "Back, home, and recents buttons at the bottom",
selected = mode == NAV_MODE_3BUTTON,
onSelect = { apply(NAV_MODE_3BUTTON) }
)
}
}
@@ -0,0 +1,49 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import me.pawlet.setupwizard.ui.components.WizardToggleCard
/**
* Privacy / metrics step. Lets the user opt in to anonymous usage metrics.
* Writes the "send_metrics" secure setting the same way finishSetupWizard reads
* it, so the choice is honoured whether it's set here or at finish.
*/
@Composable
fun PrivacyScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
var sendMetrics by remember {
mutableStateOf(Settings.Secure.getInt(cr, "send_metrics", 0) == 1)
}
WizardStepScaffold(
icon = Icons.Filled.PrivacyTip,
title = "Help improve PawletOS",
subtitle = "Optionally send anonymous usage and diagnostic data. This never includes personal information, and you can turn it off anytime.",
onContinue = onContinue,
onBack = onBack
) {
WizardToggleCard(
title = "Send anonymous metrics",
subtitle = "Usage statistics and crash diagnostics",
checked = sendMetrics,
onCheckedChange = {
sendMetrics = it
runCatching { Settings.Secure.putInt(cr, "send_metrics", if (it) 1 else 0) }
}
)
}
}
@@ -0,0 +1,49 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import me.pawlet.setupwizard.ui.components.WizardToggleCard
/**
* Recovery-update step. Gated on hasRecoveryUpdater (presence of
* /vendor/bin/install-recovery.sh). Writes the "enable_recovery_update" secure
* setting that finishSetupWizard's recovery handling reads.
*/
@Composable
fun RecoveryUpdateScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val cr = context.contentResolver
var enabled by remember {
mutableStateOf(Settings.Secure.getInt(cr, "enable_recovery_update", 1) == 1)
}
WizardStepScaffold(
icon = Icons.Filled.SystemUpdate,
title = "Recovery updates",
subtitle = "Keep the recovery partition up to date automatically when the system updates. Recommended.",
onContinue = onContinue,
onBack = onBack
) {
WizardToggleCard(
title = "Update recovery automatically",
subtitle = "Apply recovery patches during system updates",
checked = enabled,
onCheckedChange = {
enabled = it
runCatching { Settings.Secure.putInt(cr, "enable_recovery_update", if (it) 1 else 0) }
}
)
}
}
@@ -0,0 +1,44 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.content.Intent
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SettingsBackupRestore
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Restore-from-backup step. Gated on profile.backupAvailable, which stays false
* until a backup transport is configured, so today this auto-skips. When a
* transport exists it offers to open the platform restore/backup settings.
*/
@Composable
fun RestoreScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.SettingsBackupRestore,
title = "Restore your data",
subtitle = "Bring apps and settings back from a previous backup, or continue to set this device up as new.",
continueLabel = "Set up as new",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Restore from backup",
onClick = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_PRIVACY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
)
}
}
@@ -0,0 +1,51 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.app.admin.DevicePolicyManager
import android.content.Intent
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Screen-lock step. Launches the platform "set new password" flow so the user
* can choose a PIN/pattern/password. Optional — the user may skip with Continue.
*/
@Composable
fun ScreenLockScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
WizardStepScaffold(
icon = Icons.Filled.Lock,
title = "Screen lock",
subtitle = "Add a PIN, pattern, or password to help keep this device secure. You can set this up later if you prefer.",
continueLabel = "Continue",
onContinue = onContinue,
onBack = onBack
) {
WizardSecondaryButton(
label = "Set up screen lock",
onClick = {
runCatching {
context.startActivity(
Intent(DevicePolicyManager.ACTION_SET_NEW_PASSWORD)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}.onFailure {
runCatching {
context.startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
)
}
}
@@ -139,7 +139,7 @@ fun SetupCompleteScreen(
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = Color.White
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Finish Setup", fontSize = 18.sp)
@@ -0,0 +1,34 @@
package me.pawlet.setupwizard.ui.screens.wizard
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SimCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* SIM-missing notice. Only shown on devices with telephony hardware when no SIM
* is present (gated by hasTelephony && !simPresent). Raspberry Pi boards have no
* telephony, so this never appears there.
*/
@Composable
fun SimMissingScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
WizardStepScaffold(
icon = Icons.Filled.SimCard,
title = "No SIM card",
subtitle = "There's no SIM card in this device. You can still finish setup and use Wi-Fi. Insert a SIM later to enable mobile data and calls.",
continueLabel = "Continue",
onContinue = onContinue,
onBack = onBack
) {
Text(
text = "Mobile network features will be unavailable until a SIM is inserted.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
)
}
}
@@ -0,0 +1,63 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.app.UiModeManager
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Palette
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import me.pawlet.setupwizard.ui.components.WizardRadioOption
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
/**
* Theme step. Chooses light / dark / follow-system and applies it system-wide
* via UiModeManager (PawletOS ships no LineageSettings SDK, so this uses the
* plain AOSP API).
*/
@Composable
fun ThemeScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val uiModeManager = context.getSystemService(UiModeManager::class.java)
var nightMode by remember {
mutableIntStateOf(
runCatching { uiModeManager.nightMode }.getOrDefault(UiModeManager.MODE_NIGHT_AUTO)
)
}
fun apply(mode: Int) {
nightMode = mode
runCatching { uiModeManager.setNightMode(mode) }
}
WizardStepScaffold(
icon = Icons.Filled.Palette,
title = "Theme",
subtitle = "Pick a look for PawletOS. You can change it later in Settings.",
onContinue = onContinue,
onBack = onBack
) {
WizardRadioOption(
label = "System default",
subtitle = "Follow the device's day/night schedule",
selected = nightMode == UiModeManager.MODE_NIGHT_AUTO,
onSelect = { apply(UiModeManager.MODE_NIGHT_AUTO) }
)
WizardRadioOption(
label = "Light",
selected = nightMode == UiModeManager.MODE_NIGHT_NO,
onSelect = { apply(UiModeManager.MODE_NIGHT_NO) }
)
WizardRadioOption(
label = "Dark",
selected = nightMode == UiModeManager.MODE_NIGHT_YES,
onSelect = { apply(UiModeManager.MODE_NIGHT_YES) }
)
}
}
@@ -0,0 +1,119 @@
package me.pawlet.setupwizard.ui.screens.wizard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.lib.UpdateChecker
import me.pawlet.setupwizard.ui.components.WizardSecondaryButton
import me.pawlet.setupwizard.ui.components.WizardStepScaffold
import java.util.Locale
private sealed interface UpdateState {
data object Checking : UpdateState
data object UpToDate : UpdateState
data class Available(val update: UpdateChecker.AvailableUpdate) : UpdateState
}
/**
* Post-connectivity update check. On entry it runs [UpdateChecker.check]; if a
* newer compatible build exists it shows it with an "Update now" action that
* hands off to the Updater (update_engine). If the device is up to date, offline,
* or the check fails, the step advances itself so it stays invisible. The actual
* download/install is never done here.
*/
@Composable
fun UpdateCheckScreen(
onContinue: () -> Unit,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
var state by remember { mutableStateOf<UpdateState>(UpdateState.Checking) }
LaunchedEffect(Unit) {
val update = runCatching { UpdateChecker.check(context) }.getOrNull()
state = if (update != null) UpdateState.Available(update) else UpdateState.UpToDate
}
// No update / offline / error → don't show a step at all.
LaunchedEffect(state) {
if (state is UpdateState.UpToDate) onContinue()
}
when (val s = state) {
is UpdateState.Available -> {
val u = s.update
WizardStepScaffold(
icon = Icons.Filled.SystemUpdate,
title = "System update available",
subtitle = "Version ${u.version}${formatSize(u.sizeBytes)}",
continueLabel = "Skip for now",
onContinue = onContinue,
onBack = onBack
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(u.filename, style = MaterialTheme.typography.bodyLarge)
Text(
"Build type: ${u.romType}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
WizardSecondaryButton(
label = "Update now",
onClick = { UpdateChecker.launchUpdater(context) }
)
}
}
else -> {
// Checking (and the brief moment before UpToDate auto-advances).
WizardStepScaffold(
icon = Icons.Filled.SystemUpdate,
title = "Checking for updates",
subtitle = "Making sure this device has the latest software.",
continueLabel = "Skip",
onContinue = onContinue,
onBack = onBack
) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator()
}
}
}
}
}
private fun formatSize(bytes: Long): String {
if (bytes <= 0) return "unknown size"
val mb = bytes / (1024.0 * 1024.0)
return if (mb >= 1024) String.format(Locale.US, "%.1f GB", mb / 1024.0)
else String.format(Locale.US, "%.0f MB", mb)
}
@@ -86,7 +86,7 @@ fun WelcomeScreen(
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = Color.White
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Get Started", fontSize = 18.sp)
@@ -15,47 +15,58 @@ import androidx.compose.ui.unit.sp
import me.pawlet.setupwizard.R
// --- Pawlet Theme (Light) ---
// Violet primary (brand), warm-pink secondary, teal tertiary as deliberate
// accents, on near-neutral surfaces so the UI reads clean instead of "all
// purple". Contrast pairs follow Material 3 tonal guidance.
private val PawletLightColors = lightColorScheme(
primary = Color(0xFFB388FF),
onPrimary = Color.White,
primaryContainer = Color(0xFFEAD7FF),
onPrimaryContainer = Color(0xFF2A003F),
secondary = Color(0xFF7C4DFF),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD1B3FF),
onSecondaryContainer = Color(0xFF2A003F),
tertiary = Color(0xFF9F6BFF),
onTertiary = Color.White,
background = Color(0xFFD2AEDC),
onBackground = Color(0xFF2A003F),
surface = Color.White,
onSurface = Color(0xFF2A003F),
surfaceVariant = Color(0xFFF0E6F6),
onSurfaceVariant = Color(0xFF5A4066),
error = Color(0xFFB00020),
onError = Color.White
primary = Color(0xFF7A4FD1),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFEBDCFF),
onPrimaryContainer = Color(0xFF25005A),
secondary = Color(0xFFC24C97),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFD8EC),
onSecondaryContainer = Color(0xFF3D0026),
tertiary = Color(0xFF2E9C93),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFB9F0E9),
onTertiaryContainer = Color(0xFF00201D),
background = Color(0xFFFDF7FF),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFDF7FF),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE9E0EC),
onSurfaceVariant = Color(0xFF4A454E),
outline = Color(0xFF7B757F),
outlineVariant = Color(0xFFCCC4CF),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF)
)
// --- Pawlet Theme (Dark) ---
private val PawletDarkColors = darkColorScheme(
primary = Color(0xFFD1B3FF),
onPrimary = Color.Black,
primaryContainer = Color(0xFF9F6BFF),
onPrimaryContainer = Color.White,
secondary = Color(0xFFB388FF),
onSecondary = Color.Black,
secondaryContainer = Color(0xFF7C4DFF),
onSecondaryContainer = Color.White,
tertiary = Color(0xFFEAD7FF),
onTertiary = Color.Black,
background = Color(0xFF1B0030),
onBackground = Color.White,
surface = Color(0xFF2A003F),
onSurface = Color.White,
surfaceVariant = Color(0xFF3A1A4F),
onSurfaceVariant = Color(0xFFD1B3FF),
error = Color(0xFFCF6679),
onError = Color.Black
primary = Color(0xFFD3BBFF),
onPrimary = Color(0xFF3E1080),
primaryContainer = Color(0xFF573BA6),
onPrimaryContainer = Color(0xFFEBDCFF),
secondary = Color(0xFFFFAFD6),
onSecondary = Color(0xFF5E1140),
secondaryContainer = Color(0xFF7B2E5B),
onSecondaryContainer = Color(0xFFFFD8EC),
tertiary = Color(0xFF7FD5CC),
onTertiary = Color(0xFF003733),
tertiaryContainer = Color(0xFF134E49),
onTertiaryContainer = Color(0xFFB9F0E9),
background = Color(0xFF141218),
onBackground = Color(0xFFE7E0E8),
surface = Color(0xFF141218),
onSurface = Color(0xFFE7E0E8),
surfaceVariant = Color(0xFF49454E),
onSurfaceVariant = Color(0xFFCBC4CF),
outline = Color(0xFF948F99),
outlineVariant = Color(0xFF49454E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005)
)
// --- Brand Colors ---
-5
View File
@@ -8,11 +8,6 @@
<string name="developer_name">oxmc</string>
<string name="email">contact@oxmc.me</string>
<!-- API Urls -->
<string name="oxmc_api_url">https://cdn.oxmc.me/applebees/api/v2</string>
<string name="weather_api_url">https://api.weatherapi.com/v1/current.json?key={}&amp;q={}&amp;aqi=no</string>
<string name="weather_api_key">72d96a584a454382af104944260401</string>
<!-- API Urls -->
<string name="api_url_base">https://cdn.oxmc.me/api</string>