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