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