setupwizard: replace with new Kotlin/Compose implementation

Replaces the LineageOS-based Java wizard with a new Kotlin/Jetpack
Compose wizard built on the PawletOS design language.

Key changes:
- Extensible WizardPage registry with DeviceProfile-based gating
  (phone/tablet/embedded form factors, hasWifi, hasTelephony)
- SetupWizardManager drives navigation via StateFlow<Int> over
  a runtime-built page list; supports oxn://page?id= deep link jumps
- Circular-reveal collapse animation on finish (900ms, matches
  LineageOS FinishActivity behaviour) via Compose drawWithContent/clipPath
- finishSetupWizard() sets DEVICE_PROVISIONED + USER_SETUP_COMPLETE
  and disables the HOME component on completion
- EntryPoint registered with CATEGORY_HOME + CATEGORY_SETUP_WIZARD
  so AOSP launches the wizard on first boot
- Deep link handling: cdn.oxmc.me / oxmc.me / pawlet.oxmc.me (http/https)
  and oxn:// scheme (apk install, page navigation, management)
- Full package rename: dev.oxmc.setupwizard -> me.pawlet.setupwizard
- Dual build system: Gradle (app/build.gradle.kts) for Studio development,
  Android.bp + root AndroidManifest.xml for AOSP/Soong ROM build
- Signed with platform certificate, system_ext, sharedUserId=android.uid.system
This commit is contained in:
oxmc
2026-06-16 22:30:40 -07:00
commit c85367f3d4
89 changed files with 9698 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
*.iml
.gradle
/local.properties
/app/build/
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+72
View File
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: The PawletOS Project
// SPDX-License-Identifier: Apache-2.0
android_library_import {
name: "android-device-info",
aars: ["app/libs/android-device-info.aar"],
sdk_version: "current",
}
android_app {
name: "PawletSetupWizard",
srcs: ["app/src/main/java/**/*.kt"],
resource_dirs: ["app/src/main/res"],
manifest: "AndroidManifest.xml",
kotlincflags: ["-Xjvm-default=all"],
certificate: "platform",
privileged: true,
privapp_allowlist: "me.pawlet.setupwizard.xml",
system_ext_specific: true,
platform_apis: true,
overrides: ["Provision"],
optimize: {
proguard_flags_files: ["proguard.flags"],
},
static_libs: [
// AndroidX core
"androidx.core_core-ktx",
"androidx.appcompat_appcompat",
"androidx.activity_activity",
"androidx.activity_activity-compose",
"androidx.lifecycle_lifecycle-runtime-ktx",
"androidx.core_core-splashscreen",
// Compose runtime + UI
"androidx.compose.runtime_runtime",
"androidx.compose.ui_ui",
"androidx.compose.ui_ui-graphics",
"androidx.compose.foundation_foundation",
// Material3 + icons
"androidx.compose.material3_material3",
"androidx.compose.material_material-icons-extended",
// Compose animation
"androidx.compose.animation_animation",
// JSON
"gson",
// PawletOS platform APIs
"pawlet-system",
// Telephony helpers
"telephony-common",
// Device info library (local prebuilt)
"android-device-info",
],
// OkHttp and Coil are not available as AOSP static_libs.
// Add them as android_library_import entries pointing to AARs in app/libs/
// once prebuilts are sourced from the Gradle dependency cache.
// See: https://git.oxmc.me/PawletOS/android_packages_apps_SetupWizard
}
+137
View File
@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Root-level manifest used by the AOSP/Soong build (Android.bp).
app/src/main/AndroidManifest.xml is used by the Gradle/Studio build. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="me.pawlet.setupwizard"
android:versionCode="1"
android:versionName="1.0.0"
android:sharedUserId="android.uid.system">
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.NETWORK_SETTINGS" />
<!-- System settings -->
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_GLOBAL_SETTINGS" />
<uses-permission android:name="android.permission.CHANGE_CONFIGURATION" />
<uses-permission android:name="android.permission.SET_TIME_ZONE" />
<uses-permission android:name="android.permission.SET_TIME" />
<!-- Telephony -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
<!-- Accounts / Users -->
<uses-permission android:name="android.permission.GET_ACCOUNTS_PRIVILEGED" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS" />
<uses-permission android:name="android.permission.MANAGE_USERS" />
<!-- Components -->
<uses-permission android:name="android.permission.CHANGE_COMPONENT_ENABLED_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- UI -->
<uses-permission android:name="android.permission.STATUS_BAR" />
<uses-permission android:name="android.permission.EXPAND_STATUS_BAR" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Storage -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Backup -->
<uses-permission android:name="android.permission.BACKUP" />
<!-- App links verification -->
<uses-permission android:name="android.permission.DOMAIN_VERIFICATION_AGENT" />
<!-- Custom permission so other trusted system apps can signal finish -->
<permission
android:name="me.pawlet.setupwizard.permission.FINISH_SETUP"
android:protectionLevel="signatureOrSystem" />
<uses-permission android:name="me.pawlet.setupwizard.permission.FINISH_SETUP" />
<protected-broadcast
android:name="me.pawlet.setupwizard.SETUP_COMPLETE"
android:permission="me.pawlet.setupwizard.permission.FINISH_SETUP" />
<protected-broadcast
android:name="me.pawlet.setupwizard.SETUP_FINISHED"
android:permission="me.pawlet.setupwizard.permission.FINISH_SETUP" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.SetupWizard"
android:supportsRtl="true"
android:taskAffinity="com.android.wizard">
<!-- Entry point — HOME + SETUP_WIZARD so AOSP launches us on first boot
and the user cannot navigate away mid-setup. -->
<activity
android:name=".EntryPoint"
android:exported="true"
android:excludeFromRecents="true"
android:immersive="true">
<intent-filter android:priority="1">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.SETUP_WIZARD" />
<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>
</activity>
<activity
android:name=".activities.MainActivity"
android:exported="false"
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"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
+1
View File
@@ -0,0 +1 @@
/build
+75
View File
@@ -0,0 +1,75 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "me.pawlet.setupwizard"
compileSdk = 36
defaultConfig {
applicationId = "me.pawlet.setupwizard"
minSdk = 31
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
// Core + Compose
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat)
// Coil (images)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
// OkHttp (networking)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// SplashScreen
implementation(libs.androidx.splashscreen)
// Gson (JSON)
implementation(libs.gson)
// Android Device Info library
implementation(files("libs/android-device-info.aar"))
implementation(libs.androidx.compose.animation)
}
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
+99
View File
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.WRITE_GLOBAL_SETTINGS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SetupWizard">
<!-- Entry point — registered as HOME so the user cannot navigate away during setup.
SETUP_WIZARD category causes AOSP to launch this on first boot.
Deep link filters let system services (ConfigProvisioner, OTA, etc.) drive the wizard. -->
<activity android:name="dev.oxmc.setupwizard.EntryPoint"
android:exported="true"
android:excludeFromRecents="true"
android:immersive="true">
<intent-filter android:priority="1">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.SETUP_WIZARD" />
<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>
</activity>
<!-- Main activity -->
<activity android:name="dev.oxmc.setupwizard.activities.MainActivity"
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"
android:authorities="me.pawlet.setupwizard.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,37 @@
package me.pawlet.setupwizard
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import me.pawlet.setupwizard.lib.internal.PermissionManager
@SuppressLint("ObsoleteSdkInt")
fun Perms(context: Context): PermissionManager {
return PermissionManager.create(context) {
// Storage (Android 1112L, API 3032)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
addPermissions(Manifest.permission.READ_EXTERNAL_STORAGE)
setPermissionName(Manifest.permission.READ_EXTERNAL_STORAGE, "Read Storage")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Storage (Android 13+, API 33+)
addPermissions(Manifest.permission.READ_MEDIA_AUDIO)
setPermissionName(Manifest.permission.READ_MEDIA_AUDIO, "Read Audio")
}
}
// Notifications (Android 13+, API 33+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
addPermissions(Manifest.permission.POST_NOTIFICATIONS)
setPermissionName(Manifest.permission.POST_NOTIFICATIONS, "Show Notifications")
}
// Phone state (All versions)
addPermissions(Manifest.permission.READ_PHONE_STATE)
setPermissionName(Manifest.permission.READ_PHONE_STATE, "Read Phone State")
// Install packages (All versions)
includeInstallPermission(true)
}
}
@@ -0,0 +1,55 @@
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)
}
}
}
}
@@ -0,0 +1,85 @@
package me.pawlet.setupwizard.activities
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.buildBuiltinPages
import me.pawlet.setupwizard.ui.screens.AboutDeviceScreen
import me.pawlet.setupwizard.ui.screens.AndroidVersionScreen
import me.pawlet.setupwizard.ui.theme.MainTheme
class MainActivity : BaseKioskActivity() {
private lateinit var wizardManager: SetupWizardManager
override fun onCreate(savedInstanceState: Bundle?) {
FullScreenHelper.prepareKioskWindow(this)
FullScreenHelper.enableKioskMode(this)
super.onCreate(savedInstanceState)
wizardManager = SetupWizardManager.getInstance(this)
wizardManager.registerPages(
buildBuiltinPages(
onFinish = { finish() },
onSecretUnlocked = {
wizardManager.showOverlay(SetupWizardManager.Overlay.AboutDevice)
}
)
)
setContent {
MainTheme {
val index by wizardManager.currentIndex.collectAsState()
val overlay by wizardManager.overlay.collectAsState()
val deviceInfo by wizardManager.deviceInfo.collectAsState()
LaunchedEffect(index) {
FullScreenHelper.enableKioskMode(this@MainActivity)
}
when (overlay) {
is SetupWizardManager.Overlay.AboutDevice -> AboutDeviceScreen(
deviceInfo = deviceInfo,
onNavigateToAndroidVersion = {
wizardManager.showOverlay(SetupWizardManager.Overlay.AndroidVersion)
},
onNavigateBack = { wizardManager.dismissOverlay() }
)
is SetupWizardManager.Overlay.AndroidVersion -> AndroidVersionScreen(
onNavigateBack = {
wizardManager.showOverlay(SetupWizardManager.Overlay.AboutDevice)
}
)
is SetupWizardManager.Overlay.None -> {
AnimatedContent(
targetState = index,
transitionSpec = {
fadeIn(animationSpec = tween(300)).togetherWith(
fadeOut(animationSpec = tween(300))
)
},
label = "wizardPage"
) { _ ->
val page = wizardManager.currentPage()
if (page != null) {
page.content(
{ wizardManager.nextPage() },
{ wizardManager.previousPage() }
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,221 @@
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
)
}
}
}
@@ -0,0 +1,94 @@
package me.pawlet.setupwizard
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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.internal.PrefManager
import me.pawlet.setupwizard.lib.internal.UriHandler
import me.pawlet.setupwizard.ui.screens.SplashScreen
import me.pawlet.setupwizard.ui.theme.MainTheme
import kotlinx.coroutines.runBlocking
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")
override fun onCreate(savedInstanceState: Bundle?) {
val isModernSplash = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val enableModernSplash = false
// Enable Edge-To-Edge
enableEdgeToEdge()
// Optional system splash for modern Android
if (isModernSplash && enableModernSplash) {
installSplashScreen()
}
super.onCreate(savedInstanceState)
// Initialize managers
prefs = PrefManager(this)
uriHandler = UriHandler(this)
apkManager = ApkManager(this)
permissionManager = Perms(this)
wizardManager = SetupWizardManager.getInstance(this)
// Preload device info synchronously before deciding which splash to show
runBlocking {
wizardManager.loadDeviceInfo()
}
// Handle deep link routing first
if (uriHandler.handleInitialIntent(intent)) {
finish()
return
}
// Handle splashscreen
if (isModernSplash && enableModernSplash) {
proceed()
return
} else {
// Pre-31 (or if enableModernSplash == false) → show custom splash screen
setContent {
MainTheme {
// deviceInfo is already fully loaded, safe to show splash
SplashScreen(onSplashFinished = { proceed() })
}
}
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
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 {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
})
finish()
}
}
@@ -0,0 +1,151 @@
package me.pawlet.setupwizard.lib
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import androidx.core.net.toUri
class ApkManager(private val context: Context) {
/**
* Downloads an APK file from the given URL and installs it.
* If the download fails with DownloadManager, it falls back to manual download.
*
* @param url The URL of the APK file to download.
* @param silent If true, installs the APK silently without user interaction.
*/
suspend fun downloadAndInstall(url: String, silent: Boolean) {
val apkFile = withContext(Dispatchers.IO) {
tryDownloadWithManager(url)
?: downloadManually(url)
}
apkFile?.let {
val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it)
installApk(apkUri, silent)
} ?: Log.e("ApkManager", "Failed to download APK from $url")
}
/**
* Downloads an APK file from the given URL using the DownloadManager.
* If the DownloadManager fails, it falls back to manual download.
*
* @param url The URL of the APK file to download.
* @return The downloaded APK file, or null if the download failed.
*/
suspend fun downloadApkOnly(url: String): File? = withContext(Dispatchers.IO) {
tryDownloadWithManager(url) ?: downloadManually(url)
}
/**
* Downloads an APK file manually from the given URL.
* This method is used as a fallback if DownloadManager fails.
*
* @param url The URL of the APK file to download.
* @return The downloaded APK file, or null if the download failed.
*/
private suspend fun downloadManually(url: String): File? = withContext(Dispatchers.IO) {
try {
val apkName = url.substringAfterLast("/")
val file = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), apkName)
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 20_000
connection.requestMethod = "GET"
connection.doInput = true
connection.connect()
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
Log.e("ApkManager", "HTTP error ${connection.responseCode}")
return@withContext null
}
FileOutputStream(file).use { output ->
connection.inputStream.use { input ->
val buffer = ByteArray(4096)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
}
}
}
Log.d("ApkManager", "Manual download successful: ${file.absolutePath}")
file
} catch (e: Exception) {
Log.e("ApkManager", "Manual download failed: ${e.message}")
null
}
}
/**
* Attempts to download an APK using the DownloadManager.
* If it fails, it returns null.
*
* @param url The URL of the APK file to download.
* @return The downloaded APK file, or null if the download failed.
*/
private fun tryDownloadWithManager(url: String): File? {
return try {
val apkName = url.substringAfterLast("/")
val uri = url.toUri()
val request = DownloadManager.Request(uri)
.setTitle("Downloading APK")
.setDescription("APK Download")
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, apkName)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setMimeType("application/vnd.android.package-archive")
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
val downloadManager = ContextCompat.getSystemService(context, DownloadManager::class.java)
?: return null
downloadManager.enqueue(request)
// You cant get immediate download status here, so fallback
// This function just starts the download and returns null (non-blocking)
null
} catch (e: Exception) {
Log.e("ApkManager", "DownloadManager failed: ${e.message}")
null
}
}
/**
* Installs the APK from the given URI.
* If silent is true, it attempts to install without user interaction.
*
* @param apkUri The URI of the APK file to install.
* @param silent If true, installs the APK silently without user interaction.
*/
fun installApk(apkUri: Uri, silent: Boolean) {
if (silent) {
try {
val installProcess = Runtime.getRuntime().exec("pm install -r \"$apkUri\"")
installProcess.waitFor()
if (installProcess.exitValue() == 0) {
Log.d("ApkManager", "Silent install succeeded")
} else {
Log.e("ApkManager", "Silent install failed with code ${installProcess.exitValue()}")
}
} catch (e: Exception) {
Log.e("ApkManager", "Silent install error: ${e.message}")
}
} else {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
context.startActivity(intent)
}
}
}
@@ -0,0 +1,272 @@
package me.pawlet.setupwizard.lib
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.admin.DevicePolicyManager
import android.content.*
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import androidx.core.net.toUri
data class AppInstallEntry(
val appPackage: String,
val apkUri: String? = null // remote URL, content://, or file://
)
interface InstallCallback {
fun onInstalledViaStore(appPackage: String, storePackage: String)
fun onInstalledViaApkPrompt(appPackage: String, apkUri: Uri)
fun onFailed(appPackage: String, reason: String)
fun onDownloadProgress(appPackage: String, progress: Int) {} // 0..100
}
class InstallManager(
private val context: Context,
private val deviceAdmin: ComponentName? = null,
private val retryEnabled: Boolean = true,
private val maxRetries: Int = 2,
private val downloadTimeoutMillis: Int = 30_000,
private val cacheFolder: File? = null,
private val autoDeleteApk: Boolean = true,
private val maxParallelDownloads: Int = 3,
private val httpHeaders: Map<String, String>? = null,
private val logger: ((String) -> Unit)? = null
) {
private val knownStores = listOf(
"com.android.vending",
"com.sec.android.app.samsungapps",
"com.amazon.venezia",
"com.huawei.appmarket",
"com.xiaomi.market"
)
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val downloadSemaphore = Semaphore(maxParallelDownloads)
fun installApps(entries: List<AppInstallEntry>, callback: InstallCallback? = null) {
entries.forEach { entry ->
coroutineScope.launch {
processEntry(entry, callback)
}
}
}
fun cancelAll() {
coroutineScope.cancel("InstallManager canceled")
}
private suspend fun processEntry(entry: AppInstallEntry, callback: InstallCallback?) {
if (isAppInstalled(entry.appPackage)) {
callback?.onFailed(entry.appPackage, "Already installed")
return
}
try {
when {
// URL → Download → Install
entry.apkUri?.startsWith("http") == true -> {
downloadSemaphore.withPermit {
downloadWithRetries(entry.appPackage, entry.apkUri.toUri(), callback)
}
}
// File or content → Install directly
entry.apkUri?.startsWith("file://") == true || entry.apkUri?.startsWith("content://") == true -> {
installWithPackageInstaller(entry.apkUri.toUri())
callback?.onInstalledViaApkPrompt(entry.appPackage, entry.apkUri.toUri())
}
// Otherwise try store
openInAnyStore(entry.appPackage, callback) -> {
// handled inside
}
else -> {
callback?.onFailed(entry.appPackage, "No valid APK URI or store available")
}
}
} catch (e: Exception) {
callback?.onFailed(entry.appPackage, e.message ?: "Unknown error")
}
}
private fun isAppInstalled(packageName: String): Boolean {
return try {
context.packageManager.getPackageInfo(packageName, 0)
true
} catch (_: PackageManager.NameNotFoundException) {
false
}
}
private fun isDeviceOwner(): Boolean {
val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
return deviceAdmin != null && dpm.isDeviceOwnerApp(context.packageName)
}
/**
* Installs an APK via PackageInstaller (Android 12+).
*/
@SuppressLint("RequestInstallPackagesPolicy")
private fun installWithPackageInstaller(apkUri: Uri) {
val resolver = context.contentResolver
val input = resolver.openInputStream(apkUri) ?: throw IllegalStateException("Cannot open $apkUri")
val packageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params)
val session = packageInstaller.openSession(sessionId)
input.use { apkStream ->
val out: OutputStream = session.openWrite("base.apk", 0, -1)
apkStream.copyTo(out)
session.fsync(out)
out.close()
}
val intent = Intent(context, javaClass)
val pi = PendingIntent.getBroadcast(
context,
sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
session.commit(pi.intentSender)
session.close()
}
private suspend fun installApkUri(
appPackage: String,
apkUriStr: String,
callback: InstallCallback?
) {
val uri = apkUriStr.toUri()
when {
apkUriStr.startsWith("http://") || apkUriStr.startsWith("https://") -> {
downloadSemaphore.withPermit {
downloadWithRetries(appPackage, uri, callback)
}
}
uri.scheme == "file" || uri.scheme == "content" -> {
installWithPackageInstaller(uri)
callback?.onInstalledViaApkPrompt(appPackage, uri)
}
else -> {
callback?.onFailed(appPackage, "Unsupported URI scheme: ${uri.scheme}")
}
}
}
private suspend fun downloadWithRetries(
appPackage: String,
uri: Uri,
callback: InstallCallback?
) {
var attempt = 0
val folder = cacheFolder ?: context.cacheDir
while (attempt <= maxRetries) {
try {
val url = URL(uri.toString())
val connection = url.openConnection() as HttpURLConnection
httpHeaders?.forEach { (key, value) -> connection.setRequestProperty(key, value) }
connection.connectTimeout = downloadTimeoutMillis
connection.readTimeout = downloadTimeoutMillis
connection.connect()
val file = File(folder, "downloaded_${System.currentTimeMillis()}.apk")
val total = connection.contentLength
var downloaded = 0L
FileOutputStream(file).use { fos ->
connection.inputStream.use { input ->
val buffer = ByteArray(4096)
var read: Int
while (input.read(buffer).also { read = it } != -1) {
fos.write(buffer, 0, read)
downloaded += read
val progress = if (total > 0) (downloaded * 100 / total).toInt() else -1
withContext(Dispatchers.Main) {
if (progress >= 0) callback?.onDownloadProgress(appPackage, progress)
}
}
}
}
withContext(Dispatchers.Main) {
val apkUri = Uri.fromFile(file)
installWithPackageInstaller(apkUri)
callback?.onInstalledViaApkPrompt(appPackage, apkUri)
if (autoDeleteApk) file.delete()
}
logger?.invoke("Downloaded and installed $appPackage successfully.")
return
} catch (e: Exception) {
attempt++
logger?.invoke("Failed to download $appPackage (attempt $attempt): ${e.message}")
if (attempt > maxRetries || !retryEnabled) {
withContext(Dispatchers.Main) {
callback?.onFailed(appPackage, e.message ?: "Download failed after $attempt attempts")
}
return
}
}
}
}
private fun isStoreAvailable(storePackage: String): Boolean {
return try {
context.packageManager.getPackageInfo(storePackage, 0)
true
} catch (_: PackageManager.NameNotFoundException) {
false
}
}
private fun openInAnyStore(appPackage: String, callback: InstallCallback?): Boolean {
for (store in knownStores) {
if (isStoreAvailable(store)) {
openStore(store, appPackage)
callback?.onInstalledViaStore(appPackage, store)
return true
}
}
return false
}
private fun openStore(storePackage: String, appPackage: String) {
val intent = when (storePackage) {
"com.android.vending" -> Intent(Intent.ACTION_VIEW,
"market://details?id=$appPackage".toUri())
"com.sec.android.app.samsungapps" -> Intent(Intent.ACTION_VIEW,
"samsungapps://ProductDetail/$appPackage".toUri())
"com.amazon.venezia" -> Intent(Intent.ACTION_VIEW,
"amzn://apps/android?p=$appPackage".toUri())
"com.huawei.appmarket" -> Intent(Intent.ACTION_VIEW,
"appmarket://details?id=$appPackage".toUri())
"com.xiaomi.market" -> Intent(Intent.ACTION_VIEW,
"mimarket://details?appid=$appPackage".toUri())
else -> Intent(Intent.ACTION_VIEW,
"https://play.google.com/store/apps/details?id=$appPackage".toUri())
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
context.startActivity(
Intent(Intent.ACTION_VIEW,
"https://play.google.com/store/apps/details?id=$appPackage".toUri())
.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
)
}
}
}
@@ -0,0 +1,66 @@
package me.pawlet.setupwizard.lib.utils
import android.content.Context
import me.pawlet.setupwizard.lib.ConnectionState
import me.pawlet.setupwizard.lib.ConnectionStatus
import kotlinx.coroutines.delay
class ConnectionManager(
private val context: Context,
private val setupUtils: SetupUtils = SetupUtils()
) {
suspend fun checkConnection(): ConnectionState {
delay(1000)
if (setupUtils.isNetworkConnectedToInternetViaEthernet(context)) {
return ConnectionState(
status = if (setupUtils.hasInternetAccess(context))
ConnectionStatus.CONNECTED
else
ConnectionStatus.NO_INTERNET,
type = "Ethernet",
isChecking = false
)
}
if (setupUtils.hasTelephony(context) &&
!setupUtils.simMissing(context) &&
setupUtils.isConnectedViaCellular(context)
) {
return ConnectionState(
status = if (setupUtils.hasInternetAccess(context))
ConnectionStatus.CONNECTED
else
ConnectionStatus.NO_INTERNET,
type = "Cellular",
isChecking = false
)
}
if (setupUtils.isConnectedViaWifi(context)) {
val wifiInfo = setupUtils.getWifiInfo(context)
val ssid = wifiInfo?.ssid ?: ""
val status = when {
setupUtils.isCaptivePortal(context) -> ConnectionStatus.NEEDS_LOGIN
!setupUtils.isValidNetwork(ssid) -> ConnectionStatus.NEEDS_SETUP
!setupUtils.hasInternetAccess(context) -> ConnectionStatus.NO_INTERNET
else -> ConnectionStatus.CONNECTED
}
return ConnectionState(status, "WiFi", false)
}
if (setupUtils.hasWifi(context)) {
return ConnectionState(ConnectionStatus.NEEDS_SETUP, "", false)
}
if (setupUtils.hasTelephony(context) && !setupUtils.simMissing(context)) {
return ConnectionState(ConnectionStatus.NEEDS_SETUP, "Cellular", false)
}
return ConnectionState(ConnectionStatus.NO_CONNECTION, "Unknown", false)
}
}
@@ -0,0 +1,100 @@
package me.pawlet.setupwizard.lib
import androidx.compose.ui.graphics.vector.ImageVector
data class ConnectionState(
val status: ConnectionStatus,
val type: String,
val isChecking: Boolean
)
enum class ConnectionStatus(val displayText: String) {
CONNECTED("Connected"),
NEEDS_SETUP("Needs Setup"),
NEEDS_LOGIN("Needs Login"),
NO_INTERNET("No Internet"),
NO_CONNECTION("No Connection"),
CHECKING("Checking"),
DISCONNECTED("Disconnected"),
UNKNOWN("Unknown")
}
data class Region(
val id: String, // unique id, e.g., "us"
val name: String, // display name, e.g., "United States"
val code: String, // ISO country code, e.g., "US"
val countryCode: String, // same as code, or for clarity
val timeZone: String, // e.g., "America/New_York"
val currencyCode: String, // e.g., "USD"
val language: String, // display language, e.g., "English"
val flagResId: Int? = null // optional flag resource ID
)
data class OTAUpdateInfo(
val versionName: String,
val versionCode: String,
val buildDate: String,
val buildType: String,
val androidVersion: String,
val securityPatch: String,
val fileSize: String,
val downloadUrl: String? = null,
val changelog: List<String> = emptyList(),
val isAvailable: Boolean = true,
val isInstalling: Boolean = false
)
data class OTAInfoCard(
val title: String,
val value: String,
val icon: ImageVector,
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,
val updateAvailable: Boolean,
val downloadUrl: String,
val releaseNotes: String?
)
@@ -0,0 +1,53 @@
package me.pawlet.setupwizard.lib
import android.content.Context
import android.content.pm.PackageManager
enum class FormFactor { PHONE, TABLET, EMBEDDED }
data class DeviceProfile(
val formFactor: FormFactor,
val hasWifi: Boolean,
val hasTelephony: Boolean,
val hasLeanback: Boolean,
val vendorId: String
) {
val isEmbedded: Boolean get() = formFactor == FormFactor.EMBEDDED
val isPhone: Boolean get() = formFactor == FormFactor.PHONE
val isTablet: Boolean get() = formFactor == FormFactor.TABLET
companion object {
fun detect(context: Context): DeviceProfile {
val pm = context.packageManager
val hasWifi = pm.hasSystemFeature(PackageManager.FEATURE_WIFI)
val hasTelephony = pm.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
val hasLeanback = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
val formFactor = when {
hasLeanback -> FormFactor.EMBEDDED
hasTelephony -> {
val dm = context.resources.displayMetrics
if (dm.widthPixels / dm.density >= 600) FormFactor.TABLET else FormFactor.PHONE
}
else -> FormFactor.EMBEDDED // RPi: no telephony, no leanback
}
val vendorId = systemProperty("ro.pawlet.vendor_id", "pawlet")
return DeviceProfile(
formFactor = formFactor,
hasWifi = hasWifi,
hasTelephony = hasTelephony,
hasLeanback = hasLeanback,
vendorId = vendorId
)
}
private fun systemProperty(key: String, default: String): String = try {
@Suppress("DiscouragedPrivateApi")
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java, String::class.java)
.invoke(null, key, default) as String
} catch (_: Exception) { default }
}
}
@@ -0,0 +1,16 @@
package me.pawlet.setupwizard.lib
import androidx.activity.ComponentActivity
import me.pawlet.setupwizard.lib.FullScreenHelper
open class BaseKioskActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
FullScreenHelper.onResume(this, true)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
FullScreenHelper.onWindowFocusChanged(this, hasFocus, true)
}
}
@@ -0,0 +1,77 @@
package me.pawlet.setupwizard.lib
import android.content.Context
import dev.oxmc.androiddeviceinfo.DeviceInfo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class SetupWizardManager private constructor(private val context: Context) {
companion object {
@Volatile
private var INSTANCE: SetupWizardManager? = null
fun getInstance(context: Context): SetupWizardManager =
INSTANCE ?: synchronized(this) {
INSTANCE ?: SetupWizardManager(context.applicationContext).also { INSTANCE = it }
}
}
// Detected once at startup; drives shouldShow() for every page
val profile: DeviceProfile = DeviceProfile.detect(context)
private var pages: List<WizardPage> = emptyList()
// Overlay screens shown on top of the wizard flow (debug / info)
sealed class Overlay {
object None : Overlay()
object AboutDevice : Overlay()
object AndroidVersion : Overlay()
}
private val _currentIndex = MutableStateFlow(0)
val currentIndex: StateFlow<Int> = _currentIndex.asStateFlow()
private val _overlay = MutableStateFlow<Overlay>(Overlay.None)
val overlay: StateFlow<Overlay> = _overlay.asStateFlow()
private val _deviceInfo = MutableStateFlow<Array<String?>>(emptyArray())
val deviceInfo: StateFlow<Array<String?>> = _deviceInfo.asStateFlow()
fun registerPages(allPages: List<WizardPage>) {
pages = allPages
.filter { it.shouldShow(profile) }
.sortedBy { it.order }
_currentIndex.value = 0
}
fun currentPage(): WizardPage? = pages.getOrNull(_currentIndex.value)
fun isFirstPage(): Boolean = _currentIndex.value == 0
fun isLastPage(): Boolean = _currentIndex.value >= pages.size - 1
fun nextPage() {
val next = _currentIndex.value + 1
if (next < pages.size) _currentIndex.value = next
}
fun previousPage() {
val prev = _currentIndex.value - 1
if (prev >= 0) _currentIndex.value = prev
}
/** Jump to a page by id (used by oxn://page?id=... deep links). Returns false if not found. */
fun jumpToPage(id: String): Boolean {
val idx = pages.indexOfFirst { it.id == id }
if (idx < 0) return false
_currentIndex.value = idx
return true
}
fun showOverlay(overlay: Overlay) { _overlay.value = overlay }
fun dismissOverlay() { _overlay.value = Overlay.None }
suspend fun loadDeviceInfo() {
_deviceInfo.value = DeviceInfo.getDeviceInfo(context)
}
}
@@ -0,0 +1,10 @@
package me.pawlet.setupwizard.lib
import androidx.compose.runtime.Composable
class WizardPage(
val id: String,
val order: Int,
val shouldShow: (DeviceProfile) -> Boolean = { true },
val content: @Composable (onNext: () -> Unit, onBack: () -> Unit) -> Unit
)
@@ -0,0 +1,50 @@
package me.pawlet.setupwizard.lib
import me.pawlet.setupwizard.ui.screens.wizard.ConnectionSetupScreen
import me.pawlet.setupwizard.ui.screens.wizard.LocaleSelectionScreen
import me.pawlet.setupwizard.ui.screens.wizard.RegionSelectionScreen
import me.pawlet.setupwizard.ui.screens.wizard.SetupCompleteScreen
import me.pawlet.setupwizard.ui.screens.wizard.WelcomeScreen
fun buildBuiltinPages(
onFinish: () -> Unit,
onSecretUnlocked: () -> Unit
): List<WizardPage> = listOf(
WizardPage(
id = "welcome",
order = 0,
content = { onNext, _ ->
WelcomeScreen(onGetStartedClick = onNext, onSecretUnlocked = onSecretUnlocked)
}
),
WizardPage(
id = "region",
order = 10,
content = { onNext, onBack ->
RegionSelectionScreen(onBack = onBack, onContinue = onNext)
}
),
WizardPage(
id = "locale",
order = 20,
content = { onNext, onBack ->
LocaleSelectionScreen(onBack = onBack, onContinue = onNext)
}
),
WizardPage(
id = "connection",
order = 30,
// Only show network page on devices that have connectivity hardware
shouldShow = { profile -> profile.hasWifi || profile.hasTelephony },
content = { onNext, onBack ->
ConnectionSetupScreen(onBack = onBack, onContinue = onNext)
}
),
WizardPage(
id = "complete",
order = 100,
content = { _, _ ->
SetupCompleteScreen(onFinish = onFinish, onSecretUnlocked = onSecretUnlocked)
}
)
)
@@ -0,0 +1,245 @@
package me.pawlet.setupwizard.lib
import android.annotation.SuppressLint
import android.app.Activity
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.View.OnSystemUiVisibilityChangeListener
import android.view.WindowManager
/**
* FullScreenHelper - A utility library for permanently hiding navigation bar in Android activities
* Provides both aggressive (kiosk mode) and non-aggressive (immersive mode) approaches
*
* Usage:
* - Non-aggressive: FullScreenHelper.enableImmersiveMode(activity)
* - Aggressive: FullScreenHelper.enableKioskMode(activity)
*/
object FullScreenHelper {
private val handler = Handler(Looper.getMainLooper())
/**
* NON-AGGRESSIVE APPROACH - Immersive Mode
* Hides navigation bar but allows temporary access with edge swipes
* Better for regular apps where users might occasionally need navigation
*/
@JvmOverloads
fun enableImmersiveMode(activity: Activity?, callback: FullScreenCallback? = null) {
if (activity == null || activity.isFinishing()) return
val decorView = activity.window.decorView
val uiOptions = immersiveFlags
applyImmersiveSettings(activity, decorView, uiOptions, callback)
callback?.onFullScreenEnabled()
}
/**
* AGGRESSIVE APPROACH - Kiosk Mode
* Permanently hides navigation bar without swipe-to-access
* Suitable for setup apps, kiosks, or demo modes
*/
@SuppressLint("WrongConstant")
fun enableKioskMode(activity: Activity?) {
enableKioskMode(activity, null)
}
@SuppressLint("WrongConstant")
fun enableKioskMode(activity: Activity?, callback: FullScreenCallback?) {
if (activity == null || activity.isFinishing()) return
// Set window flags for fullscreen
activity.window.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
val decorView = activity.window.decorView
val uiOptions = kioskFlags
applyKioskSettings(activity, decorView, uiOptions, callback)
callback?.onFullScreenEnabled()
}
/**
* Call this BEFORE setContent/setContentView in onCreate
* This sets up the window type which cannot be changed after window creation
*/
@SuppressLint("WrongConstant")
fun prepareKioskWindow(activity: Activity?) {
if (activity == null || activity.isFinishing()) return
// This MUST be called before setContent/setContentView
// Window type can only be set during window creation
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.window.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
} else {
@Suppress("DEPRECATION")
activity.window.setType(WindowManager.LayoutParams.TYPE_SYSTEM_ERROR)
}
}
/**
* EXIT fullscreen mode and show navigation bar
*/
@JvmOverloads
fun exitFullScreen(activity: Activity?, callback: FullScreenCallback? = null) {
if (activity == null || activity.isFinishing()) return
// Clear all flags
activity.window.clearFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
// Note: We cannot change window type back to TYPE_APPLICATION
// on Android 8+ after window creation, so we skip that
val decorView = activity.window.decorView
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
// Remove any pending hide tasks
handler.removeCallbacksAndMessages(null)
callback?.onFullScreenExited()
}
/**
* Call this in your activity's onWindowFocusChanged to maintain fullscreen
*/
fun onWindowFocusChanged(activity: Activity?, hasFocus: Boolean, isKioskMode: Boolean) {
if (hasFocus && activity != null && !activity.isFinishing) {
if (isKioskMode) {
enableKioskMode(activity)
} else {
enableImmersiveMode(activity)
}
}
}
/**
* Call this in your activity's onResume to maintain fullscreen
*/
fun onResume(activity: Activity?, isKioskMode: Boolean) {
if (activity != null && !activity.isFinishing) {
// Delay slightly to ensure window is ready
handler.postDelayed({
if (activity != null && !activity.isFinishing) {
if (isKioskMode) {
enableKioskMode(activity)
} else {
enableImmersiveMode(activity)
}
}
}, 100)
}
}
private val immersiveFlags: Int
// PRIVATE HELPER METHODS
get() {
var flags = (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
flags = flags or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
return flags
}
private val kioskFlags: Int
get() {
var flags = (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
// No IMMERSIVE_STICKY for kiosk mode - we don't want any swipe-to-show behavior
flags = flags or View.SYSTEM_UI_FLAG_IMMERSIVE
}
return flags
}
private fun applyImmersiveSettings(
activity: Activity?,
decorView: View,
uiOptions: Int,
callback: FullScreenCallback?
) {
decorView.systemUiVisibility = uiOptions
// Set up listener to re-hide navigation bar when it becomes visible
decorView.setOnSystemUiVisibilityChangeListener { visibility ->
if ((visibility and View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) {
// Navigation bar became visible, hide it again after a short delay
handler.postDelayed({
if (activity != null && !activity.isFinishing) {
decorView.systemUiVisibility = uiOptions
}
}, 2000) // Longer delay for immersive mode
}
}
}
private fun applyKioskSettings(
activity: Activity?,
decorView: View,
uiOptions: Int,
callback: FullScreenCallback?
) {
decorView.systemUiVisibility = uiOptions
// More aggressive approach for kiosk mode - immediate re-hide
decorView.setOnSystemUiVisibilityChangeListener { visibility ->
if ((visibility and View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) {
// Navigation bar became visible, hide it again immediately
handler.post {
if (activity != null && !activity.isFinishing) {
decorView.systemUiVisibility = uiOptions
}
}
}
}
// Additional aggressive measure - periodically re-apply fullscreen
handler.postDelayed(object : Runnable {
override fun run() {
if (activity != null && !activity.isFinishing) {
decorView.systemUiVisibility = uiOptions
handler.postDelayed(this, 1000) // Check every second
}
}
}, 1000)
}
/**
* Check if the device is in fullscreen mode
*/
fun isFullScreen(activity: Activity?): Boolean {
if (activity == null) return false
val decorView = activity.window.decorView
val visibility = decorView.systemUiVisibility
return (visibility and View.SYSTEM_UI_FLAG_FULLSCREEN) != 0
}
/**
* Clean up resources - call this in activity onDestroy
*/
fun cleanup() {
handler.removeCallbacksAndMessages(null)
}
// Callback interface for fullscreen state changes
interface FullScreenCallback {
fun onFullScreenEnabled()
fun onFullScreenExited()
}
}
@@ -0,0 +1,260 @@
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"
}
}
@@ -0,0 +1,429 @@
package me.pawlet.setupwizard.lib.internal
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.CancellationSignal
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import dev.oxmc.androiddeviceinfo.DeviceInfo
import me.pawlet.setupwizard.lib.ApkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
import java.util.TimeZone
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() }
.sortedBy { it.displayName }
}
fun getSystemRegions(): List<Region> {
val locales = Locale.getAvailableLocales()
.filter { it.country.isNotEmpty() && it.displayCountry.isNotBlank() }
.distinctBy { it.country }
return locales.map { locale ->
val timeZoneId = TimeZone.getDefault().id
val currencyCode = try { Currency.getInstance(locale).currencyCode } catch (e: Exception) { "N/A" }
Region(
id = locale.country.lowercase(),
name = locale.displayCountry,
code = locale.country,
countryCode = locale.country,
timeZone = timeZoneId,
currencyCode = currencyCode,
language = locale.displayLanguage
)
}.sortedBy { it.name }
}
fun autoDetectRegion(regions: List<Region>): Region {
val locale = Locale.getDefault()
val timeZone = TimeZone.getDefault().id
return regions.find { it.countryCode.equals(locale.country, ignoreCase = true) }
?: regions.find { it.timeZone == timeZone }
?: regions.first() // fallback if no match
}
}
object Notify {
/**
* Shows a simple AlertDialog with a message
* @param context Context to show the dialog in
* @param message Message to display in the dialog
*/
fun alertBox(context: Context, message: String) {
// Ensure that the AlertBox is shown on the main thread
if (Looper.myLooper() == Looper.getMainLooper()) {
AlertDialog.Builder(context)
.setTitle("Attention")
.setMessage(message)
.setPositiveButton("OK", null)
.show()
} else {
// Use a Handler to post to the main thread
Handler(Looper.getMainLooper()).post {
AlertDialog.Builder(context)
.setTitle("Attention")
.setMessage(message)
.setPositiveButton("OK", null)
.show()
}
}
}
/**
* Shows a customizable alert dialog with positive and negative actions
* @param context Context to show the dialog in
* @param title Title of the dialog
* @param message Message to display in the dialog
* @param positiveButtonText Text for the positive button (default: "Allow") - set to null to hide
* @param negativeButtonText Text for the negative button (default: "Cancel") - set to null to hide
* @param positiveAction Action to execute when the positive button is clicked
* @param negativeAction Action to execute when the negative button is clicked
* @param cancellable Whether the dialog can be cancelled by clicking outside (default: false)
* @param positiveButtonEnabled Whether the positive button is enabled (default: true)
* @param negativeButtonEnabled Whether the negative button is enabled (default: true)
*/
fun alertBoxTemplate(
context: Context,
title: String,
message: String,
positiveButtonText: String? = "Allow",
negativeButtonText: String? = "Cancel",
positiveAction: (() -> Unit)? = null,
negativeAction: (() -> Unit)? = null,
cancellable: Boolean = false,
positiveButtonEnabled: Boolean = true,
negativeButtonEnabled: Boolean = true
) {
val dialogRunnable = Runnable {
val builder = AlertDialog.Builder(context)
.setTitle(title)
.setMessage(message)
.setCancelable(cancellable)
// Only add positive button if text is provided
positiveButtonText?.let { text ->
builder.setPositiveButton(text) { _, _ ->
positiveAction?.invoke()
}
}
// Only add negative button if text is provided
negativeButtonText?.let { text ->
builder.setNegativeButton(text) { dialog, _ ->
negativeAction?.invoke()
dialog.dismiss()
}
}
val dialog = builder.create()
dialog.setOnShowListener {
// Enable/disable buttons after dialog is shown (if they exist)
positiveButtonText?.let {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = positiveButtonEnabled
}
negativeButtonText?.let {
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = negativeButtonEnabled
}
}
dialog.show()
}
if (Looper.myLooper() == Looper.getMainLooper()) {
dialogRunnable.run()
} else {
// Use a Handler to post to the main thread
Handler(Looper.getMainLooper()).post(dialogRunnable)
}
}
/**
* Shows a Toast message on the main thread
* @param context Context to show the Toast in
* @param message The message to display
* @param toastLength Duration of the Toast (Toast.LENGTH_SHORT or Toast.LENGTH_LONG)
*/
fun toast(context: Context, message: String, toastLength: Int = Toast.LENGTH_SHORT) {
// Ensure that the Toast is shown on the main thread
if (Looper.myLooper() == Looper.getMainLooper()) {
Toast.makeText(context, message, toastLength).show()
} else {
// Use a Handler to post to the main thread
Handler(Looper.getMainLooper()).post {
Toast.makeText(context, message, toastLength).show()
}
}
}
}
object Packages {
// Method to check if a package is installed
fun isPackageInstalled(context: Context, packageName: String): Boolean {
return try {
context.packageManager.getPackageInfo(packageName, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
// Method to install an APK using APKManager
fun installPackage(context: Context, uri: Uri, silent: Boolean = false): Boolean {
return try {
val apkManager = ApkManager(context)
apkManager.installApk(uri, silent)
true
} catch (e: Exception) {
e.printStackTrace() // Log the exception for debugging purposes
false
}
}
// Method to download an APK using APKManager
suspend fun downloadPackage(context: Context, url: String): Boolean {
return try {
val apkManager = ApkManager(context)
apkManager.downloadApkOnly(url)
true
} catch (e: Exception) {
e.printStackTrace() // Log the exception for debugging purposes
false
}
}
// Check if the app has permission to install packages
@SuppressLint("ObsoleteSdkInt")
fun isInstallPermissionGranted(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true // Permission is always granted for versions below Oreo
}
}
// Request permission to install APKs
@SuppressLint("ObsoleteSdkInt")
fun requestInstallPermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
}
Notify.alertBoxTemplate(
context,
"Permission Required",
"Please allow this app to install from unknown sources to auto update the app.",
"Allow",
"Deny",
positiveAction = {
context.startActivity(intent) // Open settings for the user to grant permission
}
)
}
}
}
object GitHubUtils {
data class GitHubRelease(
val tagName: String,
val name: String,
val body: String,
val publishedAt: String,
val assets: List<GitHubAsset>
)
data class GitHubAsset(
val name: String,
val downloadUrl: String,
val size: Long
)
private const val GITHUB_API_BASE = "https://api.github.com"
private const val ACCEPT_HEADER = "application/vnd.github.v3+json"
suspend fun fetchLatestRelease(owner: String, repo: String): GitHubRelease? {
return withContext(Dispatchers.IO) {
try {
val url = "$GITHUB_API_BASE/repos/$owner/$repo/releases/latest"
val connection = URL(url).openConnection() as HttpURLConnection
connection.apply {
requestMethod = "GET"
setRequestProperty("Accept", ACCEPT_HEADER)
connectTimeout = 10000
readTimeout = 10000
}
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader(InputStreamReader(connection.inputStream)).use { reader ->
parseReleaseResponse(reader.readText())
}
} else {
null
}
} catch (e: Exception) {
null
}
}
}
private fun parseReleaseResponse(jsonString: String): GitHubRelease {
val json = JSONObject(jsonString)
return GitHubRelease(
tagName = json.getString("tag_name"),
name = json.optString("name"),
body = json.optString("body"),
publishedAt = json.getString("published_at"),
assets = json.getJSONArray("assets").let { assets ->
(0 until assets.length()).map { i ->
assets.getJSONObject(i).let { asset ->
GitHubAsset(
name = asset.getString("name"),
downloadUrl = asset.getString("browser_download_url"),
size = asset.getLong("size")
)
}
}
}
)
}
}
object Other {
fun isMetaQuest(context: Context): Boolean {
var result = false // Default value if coroutine fails or returns null
// Use runBlocking to create a coroutine scope that blocks the current thread
// until the coroutine completes. This is generally discouraged for UI threads
// but acceptable in this specific scenario where you need a synchronous result.
runBlocking {
result = withContext(Dispatchers.IO) { // Use IO dispatcher for background work
try {
val deviceInfo = DeviceInfo.getDeviceInfo(context)
val deviceManufacturer = deviceInfo[1] ?: Build.MANUFACTURER
val deviceModel = deviceInfo[2] ?: Build.MODEL
val deviceProduct = deviceInfo[3] ?: Build.PRODUCT
val deviceBoard = Build.BOARD
deviceManufacturer.contains("Oculus", ignoreCase = true) &&
(deviceBoard.equals("hollywood") && deviceProduct.contains("hollywood")) &&
deviceModel.contains("Quest")
} catch (e: Exception) {
// Handle potential exceptions from getDeviceInfo (e.g., if it throws)
e.printStackTrace() // Log the error for debugging
false // Return false in case of error
}
}
}
return result
}
@SuppressLint("ObsoleteSdkInt")
fun isInternetAvailable(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> checkModernNetwork(connectivityManager)
else -> checkLegacyNetwork(connectivityManager)
}
}
@SuppressLint("ObsoleteSdkInt")
@RequiresApi(Build.VERSION_CODES.M)
private fun checkModernNetwork(connectivityManager: ConnectivityManager): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
@Suppress("DEPRECATION")
private fun checkLegacyNetwork(connectivityManager: ConnectivityManager): Boolean {
val networkInfo = connectivityManager.activeNetworkInfo
return networkInfo != null && networkInfo.isConnected
}
suspend fun getDeviceLocation(locationManager: LocationManager, context: Context): Location? =
suspendCancellableCoroutine { continuation ->
try {
val lastKnownLocation = try {
locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
?: locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
} catch (_: SecurityException) {
null
}
if (lastKnownLocation != null) {
continuation.resume(lastKnownLocation)
return@suspendCancellableCoroutine
}
try {
val cancellationSignal = CancellationSignal()
locationManager.getCurrentLocation(
LocationManager.NETWORK_PROVIDER,
cancellationSignal,
context.mainExecutor
) { location ->
continuation.resume(location)
}
continuation.invokeOnCancellation {
cancellationSignal.cancel()
}
} catch (_: SecurityException) {
continuation.resume(null)
}
} catch (_: Exception) {
continuation.resume(null)
}
}
fun getAppVersion(context: Context): String {
return try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
packageInfo.versionName ?: "Unknown"
} catch (e: Exception) {
"Unknown"
}
}
fun getAppName(context: Context): String {
return try {
val applicationInfo = context.packageManager.getApplicationInfo(context.packageName, 0)
context.packageManager.getApplicationLabel(applicationInfo).toString()
} catch (e: Exception) {
"Unknown"
}
}
fun packageName(context: Context): String {
return context.packageName
}
}
}
@@ -0,0 +1,283 @@
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()
}
}
}
@@ -0,0 +1,57 @@
package me.pawlet.setupwizard.lib.internal
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
data class User(val username: String, val token: String)
class PrefManager(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("manageronduty_prefs", Context.MODE_PRIVATE)
fun clear() {
prefs.edit { clear() }
}
fun remove(key: String) {
prefs.edit { remove(key) }
}
fun saveBoolean(key: String, value: Boolean) {
prefs.edit { putBoolean(key, value) }
}
fun getBoolean(key: String, defaultValue: Boolean): Boolean {
return prefs.getBoolean(key, defaultValue)
}
fun saveString(key: String, value: String) {
prefs.edit { putString(key, value) }
}
fun getString(key: String, defaultValue: String?): String? {
return prefs.getString(key, defaultValue)
}
fun saveFloat(key: String, value: Float) {
prefs.edit { putFloat(key, value) }
}
fun getFloat(key: String, defaultValue: Float): Float {
return prefs.getFloat(key, defaultValue)
}
fun saveDouble(key: String, value: Double) {
prefs.edit { putString(key, value.toString()) }
}
fun getDouble(key: String, defaultValue: Double?): Double? {
val value = prefs.getString(key, null)
return value?.toDoubleOrNull() ?: defaultValue
}
var hasCompletedInitialPermissions: Boolean
get() = prefs.getBoolean("has_completed_permissions", false)
set(value) = prefs.edit { putBoolean("has_completed_permissions", value) }
}
@@ -0,0 +1,91 @@
package me.pawlet.setupwizard.lib.internal
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import me.pawlet.setupwizard.lib.ApkManager
import me.pawlet.setupwizard.lib.SetupWizardManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class UriHandler(private val context: Context) {
private val apkManager = ApkManager(context)
/**
* @return true if the intent was consumed
*/
fun handleInitialIntent(intent: Intent?): Boolean {
if (intent == null || intent.action != Intent.ACTION_VIEW) return false
val data = intent.data ?: return false
return when (data.scheme) {
"oxn" -> handleOxnUri(data)
"http", "https" -> handleHttpUri(data)
else -> false
}
}
fun handleIntent(intent: Intent?) {
handleInitialIntent(intent)
}
// -------------------------------------------------------------------------
private fun handleOxnUri(uri: Uri): Boolean {
return when (uri.host) {
"apk" -> handleApkUri(uri)
"page" -> handlePageUri(uri)
"mng" -> handleManagementUri(uri)
else -> { Log.w(TAG, "Unknown oxn host: ${uri.host}"); false }
}
}
private fun handleHttpUri(uri: Uri): Boolean {
// Any oxmc.me domain (cdn, apex, pawlet subdomain) — treat .apk paths as installs
val host = uri.host ?: return false
val isOxmcDomain = host == "oxmc.me" || host.endsWith(".oxmc.me")
return if (isOxmcDomain && uri.path?.endsWith(".apk") == true) {
handleApkUri(uri, explicitUrl = uri.toString())
} else {
false
}
}
/** oxn://apk?url=<encoded-url>&silent=<bool> — download and install an APK silently */
private fun handleApkUri(uri: Uri, explicitUrl: String? = null): Boolean {
val apkUrl = explicitUrl
?: uri.getQueryParameter("url")
?: run { Log.w(TAG, "oxn://apk missing url param"); return false }
val silent = uri.getQueryParameter("silent")?.toBoolean() ?: false
CoroutineScope(Dispatchers.Main).launch {
apkManager.downloadAndInstall(apkUrl, silent)
}
return true
}
/**
* oxn://page?id=<page-id> — jump to a specific wizard page by its registered id.
* Used by system services (e.g. ConfigProvisioner, OTA) to surface a page mid-flow.
*/
private fun handlePageUri(uri: Uri): Boolean {
val pageId = uri.getQueryParameter("id")
?: run { Log.w(TAG, "oxn://page missing id param"); return false }
val manager = SetupWizardManager.getInstance(context)
val jumped = manager.jumpToPage(pageId)
if (!jumped) Log.w(TAG, "oxn://page?id=$pageId — page not found in registry")
return jumped
}
private fun handleManagementUri(uri: Uri): Boolean {
Log.w(TAG, "Unsupported management path: ${uri.path}")
return false
}
companion object {
private const val TAG = "UriHandler"
}
}
@@ -0,0 +1,305 @@
package me.pawlet.setupwizard.lib.internal
import android.content.Context
import android.util.Log
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.ManagerInfo
import me.pawlet.setupwizard.lib.UpdateInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
sealed class ServerResult<out T> {
data class Success<T>(val data: T) : ServerResult<T>()
data class Error(val message: String) : ServerResult<Nothing>()
}
class OxmcServers(private val context: Context) {
private val authManager = AuthManager(context)
private val baseUrl = context.getString(R.string.oxmc_api_url)
/**
* Helper function to make authenticated requests and handle token renewal
*/
private suspend fun makeAuthenticatedRequest(
urlPath: String,
method: String,
body: JSONObject? = null,
requireAuth: Boolean = false
): Pair<Int, String> {
return withContext(Dispatchers.IO) {
val url = URL("$urlPath")
val connection = url.openConnection() as HttpURLConnection
try {
connection.apply {
requestMethod = method
setRequestProperty("Content-Type", "application/json")
if (requireAuth) {
val token = authManager.getToken()
if (token != null) {
setRequestProperty("Authorization", "Bearer $token")
}
}
if (body != null) {
doOutput = true
}
connectTimeout = 10000
readTimeout = 10000
}
// Send body if present
if (body != null) {
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(body.toString())
writer.flush()
}
}
val responseCode = connection.responseCode
val response = BufferedReader(InputStreamReader(
if (responseCode in 200..299) connection.inputStream
else connection.errorStream
)).use { it.readText() }
// Check for token renewal in response headers
if (requireAuth) {
authManager.checkAndUpdateTokenFromHeaders(connection)
}
Pair(responseCode, response)
} finally {
connection.disconnect()
}
}
}
/**
* Check for app updates
*/
suspend fun checkForUpdates(appId: String, currentVersion: String): ServerResult<UpdateInfo> {
return try {
val jsonBody = JSONObject().apply {
put("app", JSONObject().apply {
put("id", appId)
put("version", currentVersion)
})
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/updateCheck",
"POST",
jsonBody
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
ServerResult.Success(
UpdateInfo(
latestVersion = jsonResponse.getString("latestVersion"),
updateAvailable = jsonResponse.getBoolean("updateAvailable"),
downloadUrl = jsonResponse.getString("downloadUrl"),
releaseNotes = jsonResponse.optString("releaseNotes", null)
)
)
} else {
val errorMsg = try {
JSONObject(response).optString("error", "Update check failed")
} catch (e: Exception) {
"Update check failed"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Update check error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Get the current manager on duty for a store
*/
suspend fun getManagerOnDuty(storeId: String? = null): ServerResult<ManagerInfo> {
return try {
val store = storeId ?: authManager.getStoreId()
if (store == null) {
return ServerResult.Error("No store ID available")
}
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/manager/$store",
"GET",
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val manager = jsonResponse.getJSONObject("manager")
ServerResult.Success(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = manager.getString("storeId"),
storeName = manager.getString("storeName"),
setAt = manager.getString("setAt"),
setBy = manager.getString("setBy")
)
)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to get manager"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to get manager")
} catch (e: Exception) {
"Failed to get manager"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Get manager error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Set the manager on duty for a store
*/
suspend fun setManagerOnDuty(
managerId: String? = null,
name: String? = null,
pronouns: String? = null,
picture: String? = null,
storeId: String? = null
): ServerResult<ManagerInfo> {
return try {
val store = storeId ?: authManager.getStoreId()
if (store == null) {
return ServerResult.Error("No store ID available")
}
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val jsonBody = JSONObject().apply {
put("storeId", store)
if (managerId != null) {
put("managerId", managerId)
} else {
put("name", name)
put("pronouns", pronouns)
put("picture", picture)
}
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/manager",
"POST",
jsonBody,
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val manager = jsonResponse.getJSONObject("manager")
ServerResult.Success(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = manager.getString("storeId"),
storeName = manager.getString("storeName"),
setAt = manager.getString("setAt"),
setBy = manager.getString("setBy")
)
)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to set manager"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to set manager")
} catch (e: Exception) {
"Failed to set manager"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Set manager error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
/**
* Get list of all available managers
*/
suspend fun getAvailableManagers(): ServerResult<List<ManagerInfo>> {
return try {
val token = authManager.getToken()
if (token == null) {
return ServerResult.Error("Not authenticated")
}
val (responseCode, response) = makeAuthenticatedRequest(
"$baseUrl/managers",
"GET",
requireAuth = true
)
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonResponse = JSONObject(response)
if (jsonResponse.getBoolean("success")) {
val managersArray = jsonResponse.getJSONArray("managers")
val managers = mutableListOf<ManagerInfo>()
for (i in 0 until managersArray.length()) {
val manager = managersArray.getJSONObject(i)
managers.add(
ManagerInfo(
name = manager.getString("name"),
pronouns = manager.getString("pronouns"),
picture = manager.getString("picture"),
storeId = "",
storeName = "",
setAt = manager.optString("created_at", ""),
setBy = ""
)
)
}
ServerResult.Success(managers)
} else {
ServerResult.Error(jsonResponse.optString("message", "Failed to get managers"))
}
} else {
val errorMsg = try {
JSONObject(response).optString("message", "Failed to get managers")
} catch (e: Exception) {
"Failed to get managers"
}
ServerResult.Error(errorMsg)
}
} catch (e: Exception) {
Log.e("OxmcServers", "Get managers error: ${e.message}", e)
ServerResult.Error("Network error: ${e.message}")
}
}
}
@@ -0,0 +1,571 @@
package me.pawlet.setupwizard.lib.utils
import android.Manifest
import android.annotation.SuppressLint
import android.app.WallpaperManager
import android.content.ComponentName
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Resources
import android.hardware.biometrics.BiometricManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle
import android.os.UserManager
import android.provider.Settings
import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
class SetupUtils {
companion object {
private const val TAG = "SetupUtils"
private const val LOGV = false // Set to true for verbose logging
const val GMS_PACKAGE = "com.google.android.gms"
const val GMS_SUW_PACKAGE = "com.google.android.setupwizard"
const val GMS_TV_SUW_PACKAGE = "com.google.android.tungsten.setupwraith"
const val UPDATER_PACKAGE = "org.lineageos.updater"
const val UPDATE_RECOVERY_EXEC = "/vendor/bin/install-recovery.sh"
const val CONFIG_HIDE_RECOVERY_UPDATE = "config_hideRecoveryUpdate"
private const val COMPONENT_ENABLED_STATE_DISABLED = PackageManager.COMPONENT_ENABLED_STATE_DISABLED
private const val COMPONENT_ENABLED_STATE_ENABLED = PackageManager.COMPONENT_ENABLED_STATE_ENABLED
private const val DONT_KILL_APP = PackageManager.DONT_KILL_APP
private const val GET_ACTIVITIES = PackageManager.GET_ACTIVITIES
private const val KEY_SEND_METRICS = "send_metrics"
private const val DISABLE_NAV_KEYS = "disable_nav_keys"
private const val ENABLE_RECOVERY_UPDATE = "enable_recovery_update"
private const val NAVIGATION_OPTION_KEY = "navigation_option"
}
fun getBuildDateTimestamp(): Long {
return Build.TIME
}
fun isOwner(context: Context): Boolean {
val userManager = context.getSystemService(UserManager::class.java)
return userManager?.isSystemUser == true
}
fun isManagedProfile(context: Context): Boolean {
return context.getSystemService(UserManager::class.java)?.isManagedProfile == true
}
fun isNetworkConnectedToInternetViaEthernet(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java)
val activeNetwork = cm.activeNetwork
val networkCapabilities = cm.getNetworkCapabilities(activeNetwork)
return networkCapabilities != null &&
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
fun isConnectedViaWifi(context: Context): Boolean {
return try {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val network = connectivityManager?.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} catch (e: Exception) {
false
}
}
fun isConnectedViaCellular(context: Context): Boolean {
return try {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val network = connectivityManager?.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
} catch (e: Exception) {
false
}
}
fun getWifiInfo(context: Context): WifiInfo? {
return try {
val wifiService = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
when (wifiService) {
is WifiManager -> wifiService.connectionInfo
else -> {
// Log warning or handle the case where service is not available
null
}
}
} catch (e: SecurityException) {
// Handle permission issues
null
} catch (e: Exception) {
null
}
}
fun isValidNetwork(ssid: String): Boolean {
// Remove quotes from SSID if present
val cleanSsid = ssid.removeSurrounding("\"")
// Define your criteria for valid networks
val blockedNetworks = listOf("guest", "public", "attwifi", "xfinitywifi")
val allowedNetworks = listOf("your_company_network", "your_home_network")
return when {
// Check if it's in blocked list
blockedNetworks.any { cleanSsid.contains(it, ignoreCase = true) } -> false
// Check if it's in allowed list (if you have specific allowed networks)
allowedNetworks.any { cleanSsid.contains(it, ignoreCase = true) } -> true
// Default behavior - accept all networks except blocked ones
else -> true
}
}
fun isCaptivePortal(context: Context): Boolean {
return try {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(network)
// Check for captive portal capability
capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) == true
} catch (e: Exception) {
// Fallback: Test connectivity to known endpoints
testCaptivePortalConnectivity()
}
}
private fun testCaptivePortalConnectivity(): Boolean {
val testUrls = listOf(
"http://www.google.com",
"http://www.apple.com",
"http://connectivitycheck.gstatic.com/generate_204"
)
// If most connectivity tests fail but we have WiFi, likely captive portal
val successCount = testUrls.count { url ->
testConnectivity(url, 3000)
}
return successCount < testUrls.size / 2
}
fun hasInternetAccess(context: Context): Boolean {
return try {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(network)
capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} catch (e: Exception) {
// Fallback: Test actual connectivity
testConnectivity("https://www.google.com", 5000)
}
}
fun testConnectivity(url: String, timeout: Int): Boolean {
return try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = timeout
connection.readTimeout = timeout
connection.requestMethod = "HEAD"
connection.responseCode == HttpURLConnection.HTTP_OK
} catch (e: Exception) {
false
}
}
fun hasLeanback(context: Context): Boolean {
val packageManager = context.packageManager
return packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
@RequiresPermission(Manifest.permission.USE_BIOMETRIC)
fun hasBiometric(context: Context): Boolean {
val biometricManager = context.getSystemService(BiometricManager::class.java)
val result = biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_WEAK
)
return when (result) {
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED, BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
}
fun hasBluetooth(context: Context): Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)
}
fun hasWifi(context: Context): Boolean {
val packageManager = context.packageManager
return packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)
}
fun hasTelephony(context: Context): Boolean {
val packageManager = context.packageManager
return packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
}
@RequiresPermission(Manifest.permission.READ_PHONE_STATE)
@SuppressLint("ObsoleteSdkInt")
private fun isLteCapable(subTelephonyManager: TelephonyManager): Boolean {
// Check if device supports LTE using standard methods
return when {
// Method 1: Check data network type for LTE
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> {
subTelephonyManager.dataNetworkType == TelephonyManager.NETWORK_TYPE_LTE
}
// Method 2: For older devices, check network type
else -> {
@Suppress("DEPRECATION")
subTelephonyManager.networkType == TelephonyManager.NETWORK_TYPE_LTE
}
}
}
@RequiresPermission(Manifest.permission.READ_PHONE_STATE)
private fun isLteOnCdma(subTelephonyManager: TelephonyManager): Boolean {
// Check if this is a CDMA device that also supports LTE
val isCdma = subTelephonyManager.phoneType == TelephonyManager.PHONE_TYPE_CDMA
// Modern CDMA networks (like Verizon, Sprint) support LTE, so if it's CDMA and LTE capable
return isCdma && isLteCapable(subTelephonyManager)
}
private fun isGSM(subTelephonyManager: TelephonyManager): Boolean {
return subTelephonyManager.phoneType == TelephonyManager.PHONE_TYPE_GSM
}
@SuppressLint("MissingPermission")
fun simMissing(context: Context): Boolean {
val tm = context.getSystemService(TelephonyManager::class.java)
val sm = context.getSystemService(SubscriptionManager::class.java)
if (tm == null || sm == null) {
return false
}
// First check if device even has telephony capability
if (!hasTelephony(context)) {
return false // No telephony, so no SIM to be missing
}
val subs = sm.activeSubscriptionInfoList
if (subs != null && subs.isNotEmpty()) {
for (sub in subs) {
val simState = tm.getSimState(sub.simSlotIndex)
if (LOGV) {
Log.v(TAG, "getSimState(${sub.subscriptionId}) == $simState")
}
// If any SIM is present and not absent/unknown, we have at least one SIM
if (simState != TelephonyManager.SIM_STATE_ABSENT &&
simState != TelephonyManager.SIM_STATE_UNKNOWN
) {
return false
}
}
}
// Also check the default SIM state as fallback
return tm.simState == TelephonyManager.SIM_STATE_ABSENT ||
tm.simState == TelephonyManager.SIM_STATE_UNKNOWN
}
fun hasRecoveryUpdater(context: Context): Boolean {
val fileExists = File(UPDATE_RECOVERY_EXEC).exists()
if (!fileExists) {
return false
}
var featureHidden = false
try {
val pm = context.packageManager
val updaterResources = pm.getResourcesForApplication(UPDATER_PACKAGE)
val res = updaterResources.getIdentifier(
CONFIG_HIDE_RECOVERY_UPDATE, "bool", UPDATER_PACKAGE
)
featureHidden = updaterResources.getBoolean(res)
} catch (ignored: PackageManager.NameNotFoundException) {
} catch (ignored: Resources.NotFoundException) {
}
return !featureHidden
}
fun hasGMS(context: Context): Boolean {
val gmsSuwPackage = if (hasLeanback(context)) GMS_TV_SUW_PACKAGE else GMS_SUW_PACKAGE
if (isPackageInstalled(context, GMS_PACKAGE) &&
isPackageInstalled(context, gmsSuwPackage)
) {
val packageManager = context.packageManager
if (LOGV) {
Log.v(
TAG, "$GMS_SUW_PACKAGE state = " +
packageManager.getApplicationEnabledSetting(gmsSuwPackage)
)
}
return packageManager.getApplicationEnabledSetting(gmsSuwPackage) !=
COMPONENT_ENABLED_STATE_DISABLED
}
return false
}
fun isPackageInstalled(context: Context, packageName: String?): Boolean {
val pm = context.packageManager
return try {
pm.getPackageInfo(packageName!!, GET_ACTIVITIES)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
/**
* Disable the Home component, which is presumably SetupWizardActivity at this time.
*/
fun disableHome(context: Context) {
val homeComponent = getHomeComponent(context)
if (homeComponent != null) {
setComponentEnabledState(context, homeComponent, COMPONENT_ENABLED_STATE_DISABLED)
} else {
Log.w(TAG, "Home component not found. Skipping.")
}
}
@SuppressLint("QueryPermissionsNeeded")
private fun getHomeComponent(context: Context): ComponentName? {
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
`package` = context.packageName
}
val comp = intent.resolveActivity(context.packageManager)
if (LOGV) {
Log.v(TAG, "resolveActivity for intent=$intent returns $comp")
}
return comp
}
fun disableComponent(context: Context, cls: Class<*>) {
setComponentEnabledState(
context, ComponentName(context, cls),
COMPONENT_ENABLED_STATE_DISABLED
)
}
fun enableComponent(context: Context, cls: Class<*>) {
setComponentEnabledState(
context, ComponentName(context, cls),
COMPONENT_ENABLED_STATE_ENABLED
)
}
fun setComponentEnabledState(
context: Context, componentName: ComponentName,
enabledState: Int
) {
context.packageManager.setComponentEnabledSetting(
componentName,
enabledState, DONT_KILL_APP
)
}
private fun handleEnableMetrics(context: Context) {
val privacyData: Bundle? = getSettingsBundle(context)
if (privacyData != null && privacyData.containsKey(KEY_SEND_METRICS)) {
// Use standard Android Analytics settings or SharedPreferences
val prefs = context.getSharedPreferences("setup_wizard_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(KEY_SEND_METRICS, privacyData.getBoolean(KEY_SEND_METRICS)) }
// Alternatively, use Secure settings for system-wide analytics
Settings.Secure.putInt(
context.contentResolver,
"stats_collection",
if (privacyData.getBoolean(KEY_SEND_METRICS)) 1 else 0
)
}
}
private fun handleNavKeys(context: Context) {
val settingsBundle = getSettingsBundle(context)
if (settingsBundle.containsKey(DISABLE_NAV_KEYS)) {
writeDisableNavkeysOption(
context,
settingsBundle.getBoolean(DISABLE_NAV_KEYS)
)
}
}
private fun handleRecoveryUpdate(context: Context) {
val settingsBundle = getSettingsBundle(context)
if (settingsBundle.containsKey(ENABLE_RECOVERY_UPDATE)) {
val update: Boolean = settingsBundle.getBoolean(ENABLE_RECOVERY_UPDATE)
// Store in SharedPreferences instead of SystemProperties
val prefs = context.getSharedPreferences("system_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean("recovery_update_enabled", update) }
}
}
@SuppressLint("ObsoleteSdkInt")
private fun handleNavigationOption(context: Context) {
val settingsBundle = getSettingsBundle(context)
if (settingsBundle.containsKey(NAVIGATION_OPTION_KEY)) {
val selectedNavMode = settingsBundle.getString(NAVIGATION_OPTION_KEY)
// Store navigation preference in SharedPreferences
val prefs = context.getSharedPreferences("navigation_prefs", Context.MODE_PRIVATE)
prefs.edit { putString("navigation_mode", selectedNavMode) }
// As a system app, we can write to system settings
when (selectedNavMode) {
"gestural" -> {
// Enable gesture navigation
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
/*Settings.Global.putString(
context.contentResolver,
Settings.Global.ENABLE_GESTURE_NAVIGATION,
"1"
)*/
}
Settings.Secure.putInt(context.contentResolver, "navigation_mode", 2)
}
"2button" -> {
// 2-button navigation
Settings.Secure.putInt(context.contentResolver, "navigation_mode", 1)
}
else -> {
// 3-button navigation (default)
Settings.Secure.putInt(context.contentResolver, "navigation_mode", 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
/*Settings.Global.putString(
context.contentResolver,
Settings.Global.ENABLE_GESTURE_NAVIGATION,
"0"
)*/
}
}
}
}
}
@SuppressLint("ObsoleteSdkInt")
private fun writeDisableNavkeysOption(context: Context, enabled: Boolean) {
// Use standard Android navigation settings
val prefs = context.getSharedPreferences("navigation_prefs", Context.MODE_PRIVATE)
val currentSetting = prefs.getBoolean("force_show_navbar", false)
if (enabled != currentSetting) {
prefs.edit { putBoolean("force_show_navbar", enabled) }
// As a system app, we can use the actual system settings
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// For Android 10+ - use gesture navigation settings
/*Settings.Global.putString(
context.contentResolver,
Settings.Global.ENABLE_GESTURE_NAVIGATION,
if (enabled) "1" else "0"
)*/
}
// The actual system setting for navigation bar (used by SystemUI)
Settings.Secure.putInt(
context.contentResolver,
"navigation_bar_show",
if (enabled) 1 else 0
)
// Another common system setting
Settings.Secure.putInt(
context.contentResolver,
"force_show_navbar",
if (enabled) 1 else 0
)
}
}
// Helper method to get settings bundle - replace with your actual implementation
private fun getSettingsBundle(context: Context): Bundle {
val prefs = context.getSharedPreferences("setup_wizard_settings", Context.MODE_PRIVATE)
val bundle = Bundle()
// Add your settings to the bundle
if (prefs.contains(KEY_SEND_METRICS)) {
bundle.putBoolean(KEY_SEND_METRICS, prefs.getBoolean(KEY_SEND_METRICS, false))
}
if (prefs.contains(DISABLE_NAV_KEYS)) {
bundle.putBoolean(DISABLE_NAV_KEYS, prefs.getBoolean(DISABLE_NAV_KEYS, false))
}
if (prefs.contains(ENABLE_RECOVERY_UPDATE)) {
bundle.putBoolean(ENABLE_RECOVERY_UPDATE, prefs.getBoolean(ENABLE_RECOVERY_UPDATE, false))
}
if (prefs.contains(NAVIGATION_OPTION_KEY)) {
bundle.putString(NAVIGATION_OPTION_KEY, prefs.getString(NAVIGATION_OPTION_KEY, "default") ?: "default")
}
return bundle
}
// Helper method to save settings bundle
fun saveSettingsBundle(context: Context, bundle: Bundle) {
val prefs = context.getSharedPreferences("setup_wizard_settings", Context.MODE_PRIVATE)
prefs.edit {
bundle.keySet().forEach { key ->
when (val value = bundle.get(key)) {
is Boolean -> putBoolean(key, value)
is String -> putString(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
else -> { /* ignore other types */
}
}
}
}
}
fun finishSetupWizard(context: Context) {
if (LOGV) {
Log.v(TAG, "finishSetupWizard")
}
val contentResolver: ContentResolver = context.contentResolver
// Mark device as provisioned
Settings.Global.putInt(
contentResolver,
Settings.Global.DEVICE_PROVISIONED, 1
)
// Mark user setup as complete (system setting)
Settings.Secure.putInt(
context.contentResolver,
"user_setup_complete", 1
)
// For TV devices
if (hasLeanback(context)) {
Settings.Secure.putInt(
context.contentResolver,
"tv_user_setup_complete", 1
)
}
handleEnableMetrics(context)
handleNavKeys(context)
handleRecoveryUpdate(context)
handleNavigationOption(context)
WallpaperManager.getInstance(context).forgetLoadedWallpaper()
disableHome(context)
Log.i(TAG, "Setup complete!")
}
}
@@ -0,0 +1,112 @@
package me.pawlet.setupwizard.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
data class Feature(
val title: String,
val description: String,
val icon: ImageVector,
val onClick: () -> Unit
)
@Composable
fun FeatureCard(
title: String,
description: String,
icon: ImageVector,
onClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold
)
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
@Composable
fun FeatureCardSimple(title: String, description: String) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold
)
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
@@ -0,0 +1,95 @@
package me.pawlet.setupwizard.ui.components
import android.annotation.SuppressLint
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QuestionMark
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.internal.Helpers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun SecretTapLogo(
iconOrImage: Any = R.drawable.ic_launcher_foreground,
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
showRemainingTaps: Boolean = false, // option to show countdown toast
unlockCount: Int = 7, // how many taps needed
onUnlock: () -> Unit = {}
) {
val context = LocalContext.current
var tapCount by remember { mutableIntStateOf(0) }
var resetJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
val clickModifier = modifier
.size(48.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null // removes ripple effect
) {
tapCount++
resetJob?.cancel()
if (tapCount >= unlockCount) {
onUnlock()
tapCount = 0
} else {
if (showRemainingTaps) {
val remaining = unlockCount - tapCount
Helpers.Notify.toast(context, "$remaining taps remaining")
}
resetJob = scope.launch {
delay(3000)
tapCount = 0
}
}
}
when (iconOrImage) {
is Int -> {
// Assume it's a drawable resource ID
Image(
painter = painterResource(id = iconOrImage),
contentDescription = "Secret Tap Logo",
modifier = clickModifier,
contentScale = contentScale
)
}
is ImageVector -> {
// It's a Material Icon
Icon(
imageVector = iconOrImage,
contentDescription = "Secret Tap Logo",
modifier = clickModifier
)
}
else -> {
// Fallback to default icon
Icon(
imageVector = Icons.Default.QuestionMark,
contentDescription = "Secret Tap Logo",
modifier = clickModifier
)
}
}
}
@@ -0,0 +1,198 @@
package me.pawlet.setupwizard.ui.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Image
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.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
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.Email
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import me.pawlet.setupwizard.BuildConfig
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.internal.Helpers
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit) {
val context = LocalContext.current
val versionName = remember {
Helpers.Other.getAppVersion(context)
}
val appName = remember {
context.getString(R.string.app_name)
}
val appDescription = remember {
context.getString(R.string.app_description)
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
"About This App",
style = MaterialTheme.typography.headlineSmall
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = painterResource(id = R.drawable.pink_protogen),
contentDescription = "Logo",
modifier = Modifier.size(120.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = appName,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Version $versionName",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Developed by oxmc",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = appDescription,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(32.dp))
// Contact info section
Text(
text = "Contact Information",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp)
)
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
onClick = {
val appInfo = "version: $versionName\nBuild Type: ${BuildConfig.BUILD_TYPE}"
val subject = "Inquiry about $appName"
val body = "Hello,\n\nI would like to inquire about $appName.\n\n\nHere is the app information,\n$appInfo\n\n"
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = ("mailto:contact@oxmc.dev?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body)).toUri()
}
context.startActivity(Intent.createChooser(intent, "Send Email"))
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Email,
contentDescription = "Email",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Email",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "contact@oxmc.dev",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Tap to send an email",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -0,0 +1,290 @@
package me.pawlet.setupwizard.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
data class TestPageInfo(
val title: String,
val description: String? = null,
val icon: ImageVector? = null,
val action: () -> Unit
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TestNavigationScreen(
pages: List<TestPageInfo>,
onNavigateBack: () -> Unit,
appName: String = "System Updater"
) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Test Navigation",
style = MaterialTheme.typography.headlineSmall
)
Text(
appName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, "Back")
}
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
item {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(8.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = MaterialTheme.colorScheme.surfaceVariant.let {
CardDefaults.cardColors(
containerColor = it,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
},
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Filled.Dashboard,
contentDescription = "Navigation",
modifier = Modifier
.padding(bottom = 16.dp)
.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Screen Navigator",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Test all screens in the app",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
Text(
text = "${pages.size} screens available",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(top = 8.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Available Screens",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
)
}
}
// Page buttons
items(pages.size) { index ->
PageButton(
pageInfo = pages[index],
isFirst = index == 0,
isLast = index == pages.size - 1
)
}
// Footer
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Test Navigation",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(
text = "Tap any button to navigate",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
}
}
}
}
// Simple version with just titles and actions (no icons/descriptions)
@Composable
fun SimpleTestNavigationScreen(
pageTitles: List<String>,
onPageClick: (String) -> Unit,
onNavigateBack: () -> Unit
) {
val pages = pageTitles.map { title ->
TestPageInfo(
title = title,
action = { onPageClick(title) }
)
}
TestNavigationScreen(
pages = pages,
onNavigateBack = onNavigateBack
)
}
@Composable
fun PageButton(
pageInfo: TestPageInfo,
isFirst: Boolean = false,
isLast: Boolean = false
) {
val shape = when {
isFirst && isLast -> RoundedCornerShape(12.dp)
isFirst -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
isLast -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
else -> RoundedCornerShape(0.dp)
}
Button(
onClick = pageInfo.action,
modifier = Modifier
.fillMaxWidth()
.height(70.dp),
shape = shape,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 1.dp)
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Icon
if (pageInfo.icon != null) {
Icon(
imageVector = pageInfo.icon,
contentDescription = null,
modifier = Modifier.padding(end = 16.dp),
tint = MaterialTheme.colorScheme.primary
)
} else {
// Placeholder for spacing
Spacer(modifier = Modifier.width(40.dp))
}
// Content
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Text(
text = pageInfo.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
)
pageInfo.description?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
// Chevron or indicator
Box(
modifier = Modifier.padding(start = 8.dp)
) {
Icon(
Icons.Filled.ChevronRight,
"Next",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
@@ -0,0 +1,486 @@
package me.pawlet.setupwizard.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
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.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.PhoneAndroid
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.Scaffold
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import dev.oxmc.androiddeviceinfo.AndroidInfo
import me.pawlet.setupwizard.ui.theme.BrandColors
data class DeviceInfoCard(
val title: String,
val subtitle: String,
val icon: ImageVector,
val onClick: () -> Unit = {}
)
@Composable
fun AboutDeviceScreen(
deviceInfo: Array<String?>,
onNavigateToAndroidVersion: () -> Unit,
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val deviceName = deviceInfo[0] ?: AndroidInfo.Info.model
val manufacturer = deviceInfo[1] ?: AndroidInfo.Info.manufacturer
val model = deviceInfo[2] ?: AndroidInfo.Info.model
val codename = deviceInfo[3] ?: AndroidInfo.Version.codename
val imageUrl = deviceInfo[4]
val deviceType = AndroidInfo.getType(context).toString()
val isEmulator = AndroidInfo.isEmulator
// Main content
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(16.dp)
) {
item {
// Top section with device image and 2 cards
DeviceHeaderWithCards(
deviceName = deviceName,
manufacturer = manufacturer,
model = model,
codename = codename,
imageUrl = imageUrl,
isEmulator = isEmulator,
deviceType = deviceType,
onNavigateToAndroidVersion = onNavigateToAndroidVersion,
onNavigateToDeviceModel = {}
)
}
// "Device Specifications" header
/*item {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Device Specifications",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}*/
// Grid of cards using items for proper scrolling
/*items(gridCards.chunked(2)) { rowCards ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// First card in row
DeviceInfoCardItem(
card = rowCards[0],
modifier = Modifier.weight(1f)
)
// Second card in row (or empty space if only one card in last row)
if (rowCards.size > 1) {
DeviceInfoCardItem(
card = rowCards[1],
modifier = Modifier.weight(1f)
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}*/
}
}
/*
Device info screen
*/
@Composable
fun DeviceHeaderWithCards(
deviceName: String,
manufacturer: String,
model: String,
codename: String,
imageUrl: String?,
isEmulator: Boolean,
deviceType: String,
onNavigateToAndroidVersion: () -> Unit,
onNavigateToDeviceModel: () -> Unit
) {
// notification spacing header
Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
// Row with device image and 2 cards
Row(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Device Image (60% width)
Box(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
) {
if (!imageUrl.isNullOrEmpty()) {
AsyncImage(
model = imageUrl,
contentDescription = "Device Image",
modifier = Modifier
.fillMaxSize(),
contentScale = ContentScale.Fit,
placeholder = rememberAsyncImagePainter(model = "")
)
} else {
// Fallback icon
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PhoneAndroid,
contentDescription = "Device Icon",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Two cards stacked vertically (40% width)
Column(
modifier = Modifier
.weight(0.4f)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Android Version Card
DeviceInfoCardItem(
card = DeviceInfoCard(
title = "OS Info",
subtitle = "Android ${AndroidInfo.Version.release}",
icon = Icons.Filled.Android,
onClick = onNavigateToAndroidVersion
),
modifier = Modifier.weight(1f)
)
// Device Model Card
DeviceInfoCardItem(
card = DeviceInfoCard(
title = "Device Info",
subtitle = deviceName.ifEmpty { "$manufacturer $model" },
icon = Icons.Filled.PhoneAndroid,
onClick = {
// Todo
}
),
modifier = Modifier.weight(1f)
)
}
}
// Device info below the image/cards row
Spacer(modifier = Modifier.height(12.dp))
DeviceInfoSummary(
deviceType = deviceType,
isEmulator = isEmulator,
codename = codename
)
}
@Composable
fun DeviceInfoSummary(
deviceType: String,
isEmulator: Boolean,
codename: String
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
// Device type and emulator status
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = deviceType,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
if (isEmulator) {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "• Emulator",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Codename: $codename",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = "API ${AndroidInfo.Version.sdkInt}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium
)
}
}
/*
Android Version screen
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AndroidVersionScreen(onNavigateBack: () -> Unit) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
"Android Version",
style = MaterialTheme.typography.headlineSmall
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, "Back")
}
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(16.dp)
) {
item {
AndroidVersionHeader()
}
item {
Spacer(modifier = Modifier.height(24.dp))
AndroidVersionDetails()
}
}
}
}
@Composable
fun AndroidVersionHeader() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Android,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = BrandColors.AndroidGreen
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Android ${AndroidInfo.Version.release}",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "API Level ${AndroidInfo.Version.sdkInt}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
@Composable
fun AndroidVersionDetails() {
val versionItems = listOf(
"Release Version" to AndroidInfo.Version.release,
"API Level" to AndroidInfo.Version.sdkInt.toString(),
"Code Name" to AndroidInfo.Version.codename,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
versionItems.forEach { (title, value) ->
VersionInfoRow(title = title, value = value)
}
}
}
/*
Helpers
*/
@Composable
fun VersionInfoRow(title: String, value: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeviceInfoCardItem(
card: DeviceInfoCard,
modifier: Modifier = Modifier
) {
Card(
onClick = card.onClick,
modifier = modifier
.height(100.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(15.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
// Top row: Icon and title
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = card.icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = card.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1
)
}
// Bottom: Subtitle and chevron
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = card.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = "Navigate",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,249 @@
package me.pawlet.setupwizard.ui.screens
import android.annotation.SuppressLint
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
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.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import dev.oxmc.androiddeviceinfo.AndroidInfo
import dev.oxmc.androiddeviceinfo.DeviceInfo
import me.pawlet.setupwizard.R
import kotlinx.coroutines.launch
@SuppressLint("HardwareIds", "MissingPermission", "ObsoleteSdkInt")
@Composable
fun DeviceInfoScreen(onDismiss: () -> Unit) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
onDismiss()
}
},
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures {
// Empty to prevent dismissal when clicking on card
}
},
shape = MaterialTheme.shapes.extraLarge,
elevation = CardDefaults.cardElevation(defaultElevation = 24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
var deviceName: String? by remember { mutableStateOf("Unknown") }
var manufacturer: String? by remember { mutableStateOf("Unknown") }
var model: String? by remember { mutableStateOf("Unknown") }
var codename: String? by remember { mutableStateOf("Unknown") }
var imageUrl by remember { mutableStateOf<String?>(null) }
var deviceType by remember { mutableStateOf("Unknown") }
var androidVersion by remember { mutableStateOf("Unknown") }
var serial by remember { mutableStateOf("Unknown") }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
coroutineScope.launch {
try {
val (name, manu, mod, code, url) = DeviceInfo.getDeviceInfo(context)
deviceName = name
manufacturer = manu
model = mod
codename = code
imageUrl = url
deviceType = AndroidInfo.getType(context).toString()
androidVersion = "${AndroidInfo.Version.release} (SDK ${AndroidInfo.Version.sdkInt})"
serial = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@Suppress("DEPRECATION")
Build.SERIAL.takeIf { it != Build.UNKNOWN }
?: Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
} else {
try {
Build.getSerial()
} catch (_: SecurityException) {
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
}
}
} catch (_: Exception) { }
isLoading = false
}
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Text(
text = "Loading device info...",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 25.dp),
textAlign = TextAlign.Center
)
}
}
} else {
Column(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
"Device Info",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.align(Alignment.Center)
)
IconButton(
onClick = { onDismiss() },
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Dismiss"
)
}
}
Spacer(modifier = Modifier.height(16.dp))
imageUrl?.let {
val painter = rememberAsyncImagePainter(model = it)
val painterState = painter.state
Box(
modifier = Modifier
.size(180.dp)
.padding(8.dp),
contentAlignment = Alignment.Center
) {
if (painterState is AsyncImagePainter.State.Loading) {
CircularProgressIndicator()
}
Image(
painter = painter,
contentDescription = "Device Image",
modifier = Modifier.matchParentSize()
)
}
} ?: run {
Image(
painter = painterResource(id = R.drawable.pink_protogen),
contentDescription = "Unknown device",
modifier = Modifier
.size(180.dp)
.padding(8.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
InfoText("Name", deviceName)
InfoText("Manufacturer", manufacturer)
InfoText("Model", model)
InfoText("Codename", codename)
InfoText("Serial", serial)
InfoText("Type", deviceType)
InfoText("Android", androidVersion)
if (imageUrl == null) {
Text(
text = "Unable to determine device image from the model. If you know the model or would like to upload this information to improve detection, please use the button below:",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
coroutineScope.launch {
DeviceInfo.reportDeviceInfo(
context,
deviceName ?: "Unknown",
manufacturer ?: "Unknown",
model ?: "Unknown",
codename ?: "Unknown"
)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Upload Phone Data",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
}
@Composable
fun InfoText(label: String, value: String?) {
Text(
text = "$label: $value",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 2.dp),
textAlign = TextAlign.Center
)
}
@@ -0,0 +1,213 @@
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))
}
}
}
}
@@ -0,0 +1,161 @@
package me.pawlet.setupwizard.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.internal.PrefManager
import me.pawlet.setupwizard.ui.components.Feature
import me.pawlet.setupwizard.ui.components.FeatureCard
import me.pawlet.setupwizard.ui.components.SecretTapLogo
@Composable
fun MainScreen(
onLogoutClick: () -> Unit,
prefs: PrefManager
) {
val context = LocalContext.current
var showDeviceInfo by remember { mutableStateOf(false) }
var showAbout by remember { mutableStateOf(false) }
val features = listOf(
Feature(
title = "About",
description = "About this app",
icon = Icons.Default.Info,
onClick = { showAbout = true }
)
)
Box(
modifier = Modifier.fillMaxSize()
) {
Surface(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 48.dp, start = 24.dp, end = 24.dp)
.navigationBarsPadding()
) {
// Title row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
SecretTapLogo(
onUnlock = { showDeviceInfo = true }
)
Text(
text = stringResource(R.string.screen_splash_os_name),
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
IconButton(onClick = onLogoutClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = "Logout",
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Welcome back, Admin!",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
// Monitoring Status
/*Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = if (isMonitoring) "Call Monitoring: ACTIVE" else "Call Monitoring: INACTIVE",
style = MaterialTheme.typography.bodyMedium,
color = if (isMonitoring) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Switch(
checked = isMonitoring,
onCheckedChange = onMonitoringToggle
)
}*/
Spacer(modifier = Modifier.height(16.dp))
// "Feature" cards
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(bottom = 24.dp)
) {
items(features) { feature ->
FeatureCard(
title = feature.title,
description = feature.description,
icon = feature.icon,
onClick = feature.onClick
)
}
}
}
}
// DeviceInfo overlay
if (showDeviceInfo) {
DeviceInfoScreen(
onDismiss = { showDeviceInfo = false }
)
}
// About overlay
if (showAbout) {
AboutScreen(
onBack = { showAbout = false }
)
}
}
}
@@ -0,0 +1,348 @@
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,106 @@
package me.pawlet.setupwizard.ui.screens
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.pawlet.setupwizard.R
import kotlinx.coroutines.delay
@Composable
fun SplashScreen(
onSplashFinished: () -> Unit
) {
// Animation states
val alpha = remember { Animatable(0f) }
val scale = remember { Animatable(0.8f) }
val offsetY = remember { Animatable(0f) }
val spinnerAlpha = remember { Animatable(0f) }
LaunchedEffect(true) {
// Initial delay
delay(500)
// Logo fade-in and bounce animation first
alpha.animateTo(1f, tween(800))
scale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = 0.4f,
stiffness = 200f
)
)
// Wait a moment, then move up to make space for spinner
delay(300)
offsetY.animateTo(-20f, tween(400)) // Gentle upward movement
// Show loading spinner after making space
spinnerAlpha.animateTo(1f, tween(600))
// Keep splash screen visible for at least 2 seconds total
delay(1000)
// Notify that splash screen is finished
onSplashFinished()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(id = R.drawable.pink_protogen),
contentDescription = "Logo",
modifier = Modifier
.graphicsLayer {
translationY = offsetY.value.dp.toPx() - 20.dp.toPx() // Move up 20dp from original position
}
.alpha(alpha.value)
.scale(scale.value)
.size(120.dp)
)
Text(
text = stringResource(R.string.os_name),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier
.graphicsLayer {
translationY = offsetY.value.dp.toPx()
}
.alpha(alpha.value)
.scale(scale.value)
)
// Add some spacing between text and spinner
Spacer(modifier = Modifier.height(16.dp))
CircularProgressIndicator(
modifier = Modifier.alpha(spinnerAlpha.value)
)
}
}
}
@@ -0,0 +1,159 @@
package me.pawlet.setupwizard.ui.screens
import android.annotation.SuppressLint
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.JavascriptInterface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.platform.LocalContext
import org.json.JSONObject
import java.io.File
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewScreen(
url: String = "https://example.com"
) {
val context = LocalContext.current
val storageDir = File(context.filesDir, "webview_storage")
// Ensure storage directory exists
LaunchedEffect(Unit) {
if (!storageDir.exists()) {
storageDir.mkdirs()
}
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
WebView(ctx).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
allowFileAccess = true
}
webViewClient = WebViewClient()
// Add JavaScript interface for file-based storage
addJavascriptInterface(
FileStorageInterface(storageDir),
"AndroidStorage"
)
// Inject storage polyfill that redirects localStorage to file storage
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
view?.evaluateJavascript("""
(function() {
var storage = {};
// Load existing data
var data = AndroidStorage.getAllItems();
if (data) {
try {
storage = JSON.parse(data);
} catch(e) {}
}
// Override localStorage
window.localStorage = {
getItem: function(key) {
return storage[key] || null;
},
setItem: function(key, value) {
storage[key] = String(value);
AndroidStorage.saveItem(key, String(value));
},
removeItem: function(key) {
delete storage[key];
AndroidStorage.removeItem(key);
},
clear: function() {
storage = {};
AndroidStorage.clear();
},
key: function(index) {
return Object.keys(storage)[index] || null;
},
get length() {
return Object.keys(storage).length;
}
};
})();
""".trimIndent(), null)
}
}
loadUrl(url)
}
}
)
}
class FileStorageInterface(private val storageDir: File) {
@JavascriptInterface
fun saveItem(key: String, value: String) {
try {
val file = File(storageDir, sanitizeFileName(key))
file.writeText(value)
} catch (e: Exception) {
e.printStackTrace()
}
}
@JavascriptInterface
fun getItem(key: String): String? {
return try {
val file = File(storageDir, sanitizeFileName(key))
if (file.exists()) file.readText() else null
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@JavascriptInterface
fun removeItem(key: String) {
try {
val file = File(storageDir, sanitizeFileName(key))
file.delete()
} catch (e: Exception) {
e.printStackTrace()
}
}
@JavascriptInterface
fun clear() {
try {
storageDir.listFiles()?.forEach { it.delete() }
} catch (e: Exception) {
e.printStackTrace()
}
}
@JavascriptInterface
fun getAllItems(): String {
return try {
val map = mutableMapOf<String, String>()
storageDir.listFiles()?.forEach { file ->
map[file.nameWithoutExtension] = file.readText()
}
JSONObject(map).toString()
} catch (e: Exception) {
e.printStackTrace()
"{}"
}
}
private fun sanitizeFileName(key: String): String {
return key.replace(Regex("[^a-zA-Z0-9._-]"), "_")
}
}
@@ -0,0 +1,638 @@
package me.pawlet.setupwizard.ui.screens.wizard
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.res.stringResource
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
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.ConnectionState
import me.pawlet.setupwizard.lib.internal.Helpers
import me.pawlet.setupwizard.lib.ConnectionStatus
import me.pawlet.setupwizard.lib.utils.ConnectionManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun ConnectionSetupScreen(
onContinue: () -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
val manager = remember { ConnectionManager(context) }
var state by remember {
mutableStateOf(
ConnectionState(
status = ConnectionStatus.CHECKING,
type = "",
isChecking = true
)
)
}
val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope()
var connectionStatus by remember { mutableStateOf(ConnectionStatus.CHECKING) }
var connectionType by remember { mutableStateOf<String>("") }
var isChecking by remember { mutableStateOf(true) }
// Check connection when screen loads
LaunchedEffect(Unit) {
state = manager.checkConnection()
}
// Re-check connection when the screen resumes (comes back from settings)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
coroutineScope.launch {
delay(500) // Small delay to allow connection to establish
coroutineScope.launch {
delay(500)
state = manager.checkConnection()
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
fun openLegacyWifiSettings() {
try {
val intent = Intent(Settings.ACTION_WIFI_SETTINGS)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
} catch (_: Exception) {
// Show a toast
Helpers.Notify.toast(context, "Failed to open WiFi settings")
}
}
// Function to open network settings
@SuppressLint("ObsoleteSdkInt")
fun openNetworkSettings() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
// This opens a Wi-Fi panel overlay, not the full settings app
val intent = Intent(Settings.Panel.ACTION_WIFI)
context.startActivity(intent)
} catch (_: Exception) {
// Fallback to regular WiFi settings
openLegacyWifiSettings()
}
} else {
openLegacyWifiSettings()
}
}
// Function to open browser for captive portal
fun openBrowserForPortalLogin() {
try {
val intent = Intent(Intent.ACTION_VIEW, "http://www.google.com".toUri())
context.startActivity(intent)
} catch (_: Exception) {
// Show a toast
Helpers.Notify.toast(context, "Failed to open browser for captive portal login")
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Back button at the top
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
IconButton(
onClick = onBack,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Connection Icon
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = "Connection Setup",
modifier = Modifier.size(120.dp)
)
// Title
Text(
text = stringResource(id = R.string.screen_connectionSetup_title),
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
),
textAlign = TextAlign.Center
)
// Description
Text(
text = stringResource(id = R.string.screen_connectionSetup_description),
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
),
textAlign = TextAlign.Center
)
// Connection Status Card
ConnectionStatusCard(
connectionStatus = state.status,
connectionType = state.type,
isChecking = state.isChecking
)
Spacer(modifier = Modifier.weight(1f))
// Action Buttons
ActionButtons(
connectionStatus = state.status,
connectionType = state.type,
onContinueClick = onContinue,
onBackClick = onBack,
onOpenNetworkSettings = { openNetworkSettings() },
onPortalLogin = { openBrowserForPortalLogin() }
)
}
}
}
@Composable
fun ConnectionStatusCard(
connectionStatus: ConnectionStatus,
connectionType: String,
isChecking: Boolean,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = when (connectionStatus) {
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.primaryContainer
ConnectionStatus.NEEDS_SETUP -> MaterialTheme.colorScheme.secondaryContainer
ConnectionStatus.NEEDS_LOGIN -> MaterialTheme.colorScheme.tertiaryContainer
ConnectionStatus.NO_INTERNET -> MaterialTheme.colorScheme.errorContainer
ConnectionStatus.NO_CONNECTION -> MaterialTheme.colorScheme.errorContainer
ConnectionStatus.CHECKING -> MaterialTheme.colorScheme.surfaceVariant
else -> MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Row(
modifier = Modifier.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
when {
isChecking -> {
CircularProgressIndicator(
modifier = Modifier.size(40.dp),
strokeWidth = 3.dp
)
}
else -> {
Icon(
imageVector = when (connectionStatus) {
ConnectionStatus.CONNECTED -> Icons.Default.CheckCircle
ConnectionStatus.NEEDS_SETUP -> Icons.Default.Info
ConnectionStatus.NEEDS_LOGIN -> Icons.Default.Warning
ConnectionStatus.NO_INTERNET -> Icons.Default.Error
ConnectionStatus.NO_CONNECTION -> Icons.Default.Error
ConnectionStatus.CHECKING -> Icons.Default.Wifi
else -> Icons.Default.Info
},
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = when (connectionStatus) {
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.primary
ConnectionStatus.NEEDS_SETUP -> MaterialTheme.colorScheme.secondary
ConnectionStatus.NEEDS_LOGIN -> MaterialTheme.colorScheme.tertiary
ConnectionStatus.NO_INTERNET -> MaterialTheme.colorScheme.error
ConnectionStatus.NO_CONNECTION -> MaterialTheme.colorScheme.error
ConnectionStatus.CHECKING -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
Column {
Text(
text = when {
isChecking -> "Checking connection..."
else -> connectionStatus.displayText
},
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold
),
color = when (connectionStatus) {
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.onPrimaryContainer
ConnectionStatus.NEEDS_SETUP -> MaterialTheme.colorScheme.onSecondaryContainer
ConnectionStatus.NEEDS_LOGIN -> MaterialTheme.colorScheme.onTertiaryContainer
ConnectionStatus.NO_INTERNET -> MaterialTheme.colorScheme.onErrorContainer
ConnectionStatus.NO_CONNECTION -> MaterialTheme.colorScheme.onErrorContainer
ConnectionStatus.CHECKING -> MaterialTheme.colorScheme.onPrimaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
Text(
text = when {
isChecking -> "Please wait while we detect your network"
connectionStatus == ConnectionStatus.NEEDS_SETUP ->
"Setup required for $connectionType"
connectionStatus == ConnectionStatus.CONNECTED ->
"Connected via $connectionType"
else -> "$connectionType available"
},
style = MaterialTheme.typography.bodyMedium,
color = when (connectionStatus) {
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
ConnectionStatus.NEEDS_SETUP -> MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
ConnectionStatus.NEEDS_LOGIN -> MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
ConnectionStatus.NO_INTERNET -> MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
ConnectionStatus.NO_CONNECTION -> MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
ConnectionStatus.CHECKING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
}
)
// Additional status message for specific cases
if (!isChecking) {
when (connectionStatus) {
ConnectionStatus.NEEDS_LOGIN -> {
Text(
text = "This network requires browser login",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
)
}
ConnectionStatus.NO_INTERNET -> {
Text(
text = "Connected to network but no internet access",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
)
}
else -> {}
}
}
}
}
}
}
@Composable
fun ActionButtons(
connectionStatus: ConnectionStatus,
connectionType: String,
onContinueClick: () -> Unit,
onBackClick: () -> Unit,
onOpenNetworkSettings: () -> Unit,
onPortalLogin: () -> Unit
) {
when (connectionStatus) {
ConnectionStatus.CONNECTED -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onBackClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Back",
fontSize = 16.sp
)
}
Button(
onClick = onContinueClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text(
text = "Continue",
fontSize = 16.sp
)
}
}
}
ConnectionStatus.NEEDS_LOGIN -> {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = onPortalLogin,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Login to WiFi Network",
fontSize = 16.sp
)
}
OutlinedButton(
onClick = onOpenNetworkSettings,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Change Network",
fontSize = 16.sp
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onBackClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Back",
fontSize = 16.sp
)
}
Button(
onClick = onContinueClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Text(
text = "Continue Anyway",
fontSize = 16.sp
)
}
}
}
}
ConnectionStatus.NEEDS_SETUP -> {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedButton(
onClick = onOpenNetworkSettings,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = if (connectionType.isNotEmpty()) "Configure $connectionType" else "Configure Network",
fontSize = 16.sp
)
}
Text(
text = "or",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onBackClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Back",
fontSize = 16.sp
)
}
Button(
onClick = onContinueClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Text(
text = "Continue Offline",
fontSize = 16.sp
)
}
}
}
}
ConnectionStatus.DISCONNECTED,
ConnectionStatus.UNKNOWN,
ConnectionStatus.NO_CONNECTION -> {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedButton(
onClick = onOpenNetworkSettings,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Open Network Settings",
fontSize = 16.sp
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onBackClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Back",
fontSize = 16.sp
)
}
Button(
onClick = onContinueClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Text(
text = "Continue Offline",
fontSize = 16.sp
)
}
}
}
}
ConnectionStatus.NO_INTERNET -> {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedButton(
onClick = onOpenNetworkSettings,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Troubleshoot Network",
fontSize = 16.sp
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onBackClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Back",
fontSize = 16.sp
)
}
Button(
onClick = onContinueClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Text(
text = "Continue Anyway",
fontSize = 16.sp
)
}
}
}
}
ConnectionStatus.CHECKING -> {
// Show nothing while checking
}
}
}
@@ -0,0 +1,428 @@
package me.pawlet.setupwizard.ui.screens.wizard
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.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.internal.Helpers
import java.util.Locale
@Composable
fun LocaleSelectionScreen(
onContinue: (Locale) -> Unit,
onBack: () -> Unit
) {
var locales by remember { mutableStateOf<List<Locale>>(emptyList()) }
var selectedLocale by remember { mutableStateOf<Locale?>(null) }
var isLoading by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
var autoDetectedLocale by remember { mutableStateOf<Locale?>(null) }
// Load locales
LaunchedEffect(Unit) {
isLoading = true
locales = Helpers.getSystemLocales()
autoDetectedLocale = Locale.getDefault()
selectedLocale = autoDetectedLocale
isLoading = false
}
val filteredLocales = if (searchQuery.isNotBlank()) {
locales.filter {
it.displayName.contains(searchQuery, ignoreCase = true) ||
it.language.contains(searchQuery, ignoreCase = true) ||
it.country.contains(searchQuery, ignoreCase = true)
}
} else locales
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Back button at the top
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
IconButton(
onClick = onBack,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
//Spacer(modifier = Modifier.height(8.dp))
// Locale Icon
Icon(
imageVector = Icons.Default.Language,
contentDescription = "Locale Selection",
modifier = Modifier.size(100.dp)
)
// Title
Text(
text = stringResource(id = R.string.screen_localeSelection_title),
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
),
textAlign = TextAlign.Center
)
// Description
Text(
text = stringResource(id = R.string.screen_localeSelection_description),
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
),
textAlign = TextAlign.Center
)
// Auto-detect and search
LocaleSearchAndAutoDetect(
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
showSearch = showSearch,
onShowSearchChange = { showSearch = it },
autoDetectedLocale = autoDetectedLocale,
onAutoDetectClick = { selectedLocale = autoDetectedLocale },
isLoading = isLoading,
selectedLocale = selectedLocale,
onLocaleSelected = { selectedLocale = it },
modifier = Modifier.fillMaxWidth()
)
if (isLoading) {
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LocaleList(
locales = filteredLocales,
selectedLocale = selectedLocale,
onLocaleSelected = { selectedLocale = it },
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(16.dp))
// Action buttons
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = onBack,
modifier = Modifier.weight(1f).height(56.dp),
shape = RoundedCornerShape(12.dp)
) { Text("Back", fontSize = 16.sp) }
Button(
onClick = { selectedLocale?.let { onContinue(it) } },
modifier = Modifier.weight(1f).height(56.dp),
shape = RoundedCornerShape(12.dp),
enabled = selectedLocale != null
) {
Text(
text = if (selectedLocale != null) "Continue" else "Select Locale",
fontSize = 16.sp
)
}
}
}
}
}
@Composable
fun LocaleSearchAndAutoDetect(
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
showSearch: Boolean,
onShowSearchChange: (Boolean) -> Unit,
autoDetectedLocale: Locale?,
onAutoDetectClick: () -> Unit,
isLoading: Boolean,
selectedLocale: Locale?,
onLocaleSelected: (Locale) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
autoDetectedLocale?.let { locale ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clickable { onLocaleSelected(locale); onAutoDetectClick() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Language,
contentDescription = "Auto-detected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Auto-detected Locale",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "${locale.displayName} (${locale.language}-${locale.country})",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold
)
)
}
if (selectedLocale == locale) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (showSearch) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Search regions...") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search"
)
},
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
} else {
Button(
onClick = { onShowSearchChange(true) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp)
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Search Regions")
}
}
if (showSearch && searchQuery.isNotEmpty()) {
Button(
onClick = { onSearchQueryChange(""); onShowSearchChange(false) },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) { Text("Clear") }
}
}
}
}
@Composable
fun LocaleList(
locales: List<Locale>,
selectedLocale: Locale?,
onLocaleSelected: (Locale) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(locales) { locale ->
LocaleItem(
locale = locale,
isSelected = selectedLocale == locale,
onClick = { onLocaleSelected(locale) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
}
}
}
@Composable
fun LocaleItem(
locale: Locale,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.clickable { onClick() },
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant,
contentColor = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
),
elevation = CardDefaults.cardElevation(
defaultElevation = if (isSelected) 4.dp else 1.dp
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Language,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.3f),
modifier = Modifier.fillMaxSize()
)
Text(
text = locale.country.take(2).uppercase(),
color = Color.White,
fontWeight = FontWeight.Bold
)
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = locale.displayName,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
)
Text(
text = "${locale.language}${locale.country}",
style = MaterialTheme.typography.bodySmall,
color = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
@@ -0,0 +1,471 @@
package me.pawlet.setupwizard.ui.screens.wizard
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowCircleDown
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material.icons.filled.Update
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.Scaffold
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import me.pawlet.setupwizard.lib.OTAInfoCard
import me.pawlet.setupwizard.lib.OTAUpdateInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OTAInfoScreen(
updateInfo: OTAUpdateInfo,
imageUrl: String?,
onNavigateBack: () -> Unit,
onDownloadUpdate: () -> Unit,
onInstallUpdate: () -> Unit
) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
"Update Information",
style = MaterialTheme.typography.headlineSmall
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, "Back")
}
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(16.dp)
) {
item {
// Spacing for status bar
Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
UpdateHeaderSection(
updateInfo = updateInfo,
imageUrl = imageUrl,
onDownloadUpdate = onDownloadUpdate,
onInstallUpdate = onInstallUpdate
)
}
/*item {
Spacer(modifier = Modifier.height(24.dp))
UpdateDetailsSection(updateInfo = updateInfo)
}*/
if (updateInfo.changelog.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(24.dp))
ChangelogSection(changelog = updateInfo.changelog)
}
}
item {
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
fun UpdateHeaderSection(
updateInfo: OTAUpdateInfo,
imageUrl: String?,
onDownloadUpdate: () -> Unit,
onInstallUpdate: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Top row: Image and version info
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Device/Update Image (40% width)
Box(
modifier = Modifier.weight(0.4f)
) {
if (!imageUrl.isNullOrEmpty()) {
AsyncImage(
model = imageUrl,
contentDescription = "Update Preview",
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop,
placeholder = rememberAsyncImagePainter(model = "")
)
} else {
Box(
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.SystemUpdate,
contentDescription = "Update Icon",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Version info (60% width)
Column(
modifier = Modifier.weight(0.6f)
) {
Text(
text = "Update Available",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = updateInfo.versionName,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Build ${updateInfo.versionCode}${updateInfo.buildType}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Card(
onClick = onDownloadUpdate,
modifier = Modifier.weight(1f),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.ArrowCircleDown,
contentDescription = "Download",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Download",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
}
}
Card(
onClick = onInstallUpdate,
modifier = Modifier.weight(1f),
colors = CardDefaults.cardColors(
containerColor = if (updateInfo.isAvailable) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (updateInfo.isAvailable) {
MaterialTheme.colorScheme.onSecondary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
),
shape = RoundedCornerShape(12.dp),
enabled = updateInfo.isAvailable && !updateInfo.isInstalling
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Update,
contentDescription = "Install",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (updateInfo.isInstalling) "Installing..." else "Install",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}
@Composable
fun UpdateDetailsSection(updateInfo: OTAUpdateInfo) {
val infoCards = listOf(
OTAInfoCard(
title = "Android Version",
value = updateInfo.androidVersion,
icon = Icons.Filled.Android
),
OTAInfoCard(
title = "Security Patch",
value = updateInfo.securityPatch,
icon = Icons.Filled.Security
),
OTAInfoCard(
title = "Build Date",
value = updateInfo.buildDate,
icon = Icons.Filled.CalendarToday
),
OTAInfoCard(
title = "File Size",
value = updateInfo.fileSize,
icon = Icons.Filled.Info
)
)
Column {
Text(
text = "Update Details",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 16.dp)
)
// Grid of info cards
infoCards.chunked(2).forEach { rowCards ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
rowCards.forEach { card ->
OTAInfoCardItem(
card = card,
modifier = Modifier.weight(1f)
)
}
// Fill empty space if odd number of cards
if (rowCards.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
fun ChangelogSection(changelog: List<String>) {
Column {
Text(
text = "What's New",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 16.dp)
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
changelog.forEachIndexed { index, item ->
if (index > 0) {
Spacer(modifier = Modifier.height(12.dp))
}
Row(
verticalAlignment = Alignment.Top
) {
Text(
text = "",
modifier = Modifier.padding(end = 12.dp, top = 2.dp),
fontSize = 16.sp
)
Text(
text = item,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OTAInfoCardItem(
card: OTAInfoCard,
modifier: Modifier = Modifier
) {
Card(
onClick = card.onClick,
modifier = modifier.height(100.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
// Top row: Icon and title
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = card.icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = card.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
// Value
Text(
text = card.value,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2
)
}
}
}
// Usage example
/*
@Composable
fun OTAScreenPreview() {
val sampleUpdate = OTAUpdateInfo(
versionName = "PixelOS 2.0",
versionCode = "PIXELOS_2024.01.15.001",
buildDate = "January 15, 2024",
buildType = "Stable",
androidVersion = "Android 14",
securityPatch = "January 5, 2024",
fileSize = "2.4 GB",
changelog = listOf(
"Updated to January security patch",
"Improved system stability and performance",
"Fixed Bluetooth connectivity issues",
"Enhanced camera image processing",
"Added new customization options",
"Battery life optimizations"
)
)
OTAInfoScreen(
updateInfo = sampleUpdate,
imageUrl = "https://example.com/update-preview.png",
onNavigateBack = { /* Handle back */ },
onDownloadUpdate = { /* Handle download */ },
onInstallUpdate = { /* Handle install */ }
)
}
*/
@@ -0,0 +1,516 @@
package me.pawlet.setupwizard.ui.screens.wizard
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.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.Region
import me.pawlet.setupwizard.lib.internal.Helpers
import kotlinx.coroutines.delay
import java.util.Locale
import java.util.TimeZone
@Composable
fun RegionSelectionScreen(
onContinue: (Region) -> Unit,
onBack: () -> Unit
) {
// State management
var regions by remember { mutableStateOf<List<Region>>(emptyList()) }
var selectedRegion by remember { mutableStateOf<Region?>(null) }
var isLoading by remember { mutableStateOf(false) }
var autoDetectedRegion by remember { mutableStateOf<Region?>(null) }
var searchQuery by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
// Load regions on first composition
LaunchedEffect(Unit) {
isLoading = true
// Simulate loading delay
delay(500)
regions = Helpers.getSystemRegions()
// Try to auto-detect region
autoDetectedRegion = Helpers.autoDetectRegion(regions)
// Auto-select detected region if found
autoDetectedRegion?.let { selectedRegion = it }
isLoading = false
}
// Filter regions based on search query
val filteredRegions = if (searchQuery.isNotBlank()) {
regions.filter { region ->
region.name.contains(searchQuery, ignoreCase = true) ||
region.code.contains(searchQuery, ignoreCase = true) ||
region.countryCode.contains(searchQuery, ignoreCase = true)
}
} else {
regions
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Back button at the top
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
IconButton(
onClick = onBack,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
//Spacer(modifier = Modifier.height(8.dp))
// Region Icon
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = "Region Selection",
modifier = Modifier.size(100.dp)
)
// Title
Text(
text = stringResource(id = R.string.screen_regionSelection_title),
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
),
textAlign = TextAlign.Center
)
// Description
Text(
text = stringResource(id = R.string.screen_regionSelection_description),
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
),
textAlign = TextAlign.Center
)
// Search and Auto-detect section
RegionSearchAndAutoDetect(
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
showSearch = showSearch,
onShowSearchChange = { showSearch = it },
autoDetectedRegion = autoDetectedRegion,
onAutoDetectClick = {
isLoading = true
// Re-run auto-detection
autoDetectedRegion = Helpers.autoDetectRegion(regions)
isLoading = false
},
isLoading = isLoading,
modifier = Modifier.fillMaxWidth()
)
// Region List
if (isLoading) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
RegionList(
regions = filteredRegions,
selectedRegion = selectedRegion,
onRegionSelected = { region ->
selectedRegion = region
},
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(16.dp))
// Action Buttons Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Back Button
OutlinedButton(
onClick = onBack,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Back",
fontSize = 16.sp
)
}
// Continue Button
Button(
onClick = {
selectedRegion?.let { onContinue(it) }
},
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
enabled = selectedRegion != null && !isLoading
) {
Text(
text = if (selectedRegion != null) {
"Continue"
} else {
"Select Region"
},
fontSize = 16.sp
)
}
}
}
}
}
@Composable
fun RegionSearchAndAutoDetect(
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
showSearch: Boolean,
onShowSearchChange: (Boolean) -> Unit,
autoDetectedRegion: Region?,
onAutoDetectClick: () -> Unit,
isLoading: Boolean,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Auto-detect card
autoDetectedRegion?.let { region ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clickable { onAutoDetectClick() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = "Auto-detected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Auto-detected Location",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "${region.name} (${region.code})",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold
)
)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (showSearch) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Search regions...") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search"
)
},
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
} else {
Button(
onClick = { onShowSearchChange(true) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp)
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Search Regions")
}
}
if (showSearch && searchQuery.isNotEmpty()) {
Button(
onClick = {
onSearchQueryChange("")
onShowSearchChange(false)
},
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text("Clear")
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegionList(
regions: List<Region>,
selectedRegion: Region?,
onRegionSelected: (Region) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
if (regions.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Language,
contentDescription = "No regions",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No regions found",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(regions) { region ->
RegionItem(
region = region,
isSelected = selectedRegion?.id == region.id,
onClick = { onRegionSelected(region) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
}
}
}
}
@Composable
fun RegionItem(
region: Region,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.clickable { onClick() },
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
),
elevation = if (isSelected) {
CardDefaults.cardElevation(defaultElevation = 4.dp)
} else {
CardDefaults.cardElevation(defaultElevation = 1.dp)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Region flag/icon
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Language,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.3f),
modifier = Modifier.fillMaxSize()
)
Text(
text = region.countryCode.take(2).uppercase(),
color = Color.White,
fontWeight = FontWeight.Bold
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = region.name,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
)
Text(
text = "${region.code}${region.timeZone}",
style = MaterialTheme.typography.bodySmall,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
}
)
}
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
}
}
}
@@ -0,0 +1,150 @@
package me.pawlet.setupwizard.ui.screens.wizard
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.lib.utils.SetupUtils
import me.pawlet.setupwizard.ui.components.SecretTapLogo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.sqrt
@Composable
fun SetupCompleteScreen(
onFinish: () -> Unit,
onSecretUnlocked: () -> Unit
) {
val context = LocalContext.current
var isFinishing by remember { mutableStateOf(false) }
var shouldFinish by remember { mutableStateOf(false) }
// Circular reveal: 1f = full screen visible, 0f = collapsed to point (like LineageOS FinishActivity)
val revealFraction by animateFloatAsState(
targetValue = if (isFinishing) 0f else 1f,
animationSpec = tween(durationMillis = 900, easing = FastOutSlowInEasing),
label = "circularReveal",
finishedListener = { if (isFinishing) shouldFinish = true }
)
LaunchedEffect(shouldFinish) {
if (shouldFinish) {
withContext(Dispatchers.IO) { SetupUtils().finishSetupWizard(context) }
onFinish()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
// Compute the circle radius needed to cover the entire screen at fraction=1
val halfDiag = sqrt(
(size.width * size.width + size.height * size.height).toDouble()
).toFloat() / 2f
val path = Path().apply {
addOval(
Rect(
center = Offset(size.width / 2f, size.height / 2f),
radius = halfDiag * revealFraction
)
)
}
clipPath(path) { drawContent() }
}
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
SecretTapLogo(
iconOrImage = R.drawable.pink_protogen,
modifier = Modifier.size(120.dp),
contentScale = ContentScale.Fit,
onUnlock = onSecretUnlocked
)
Text(
text = stringResource(id = R.string.screen_setupComplete_title),
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
),
textAlign = TextAlign.Center
)
Text(
text = stringResource(id = R.string.screen_setupComplete_description),
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { isFinishing = true },
enabled = !isFinishing,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = Color.White
)
) {
Text("Finish Setup", fontSize = 18.sp)
}
}
}
}
}
@@ -0,0 +1,97 @@
package me.pawlet.setupwizard.ui.screens.wizard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
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
import me.pawlet.setupwizard.R
import me.pawlet.setupwizard.ui.components.SecretTapLogo
@Composable
fun WelcomeScreen(
onGetStartedClick: () -> Unit,
onSecretUnlocked: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
SecretTapLogo(
iconOrImage = R.drawable.pink_protogen,
modifier = Modifier.size(120.dp),
contentScale = ContentScale.Fit,
onUnlock = onSecretUnlocked
)
Text(
text = stringResource(id = R.string.screen_welcome_title, stringResource(id= R.string.os_name)),
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
),
textAlign = TextAlign.Center
)
Text(
text = stringResource(id = R.string.screen_welcome_description),
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onGetStartedClick,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = Color.White
)
) {
Text("Get Started", fontSize = 18.sp)
}
}
}
}
}
@@ -0,0 +1,160 @@
package me.pawlet.setupwizard.ui.theme
import android.annotation.SuppressLint
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import me.pawlet.setupwizard.R
// --- Pawlet Theme (Light) ---
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
)
// --- 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
)
// --- Brand Colors ---
object BrandColors {
// Applebee's
val ApplebeesRed = Color(0xFFD32F2F)
val ApplebeesRedDark = Color(0xFFC62828)
val ApplebeesRedLight = Color(0xFFEF5350)
val ApplebeesTextDark = Color(0xFF333333)
val ApplebeesWhite = Color.White
// Other / Random
val LightBlue = Color(0xFFE3F2FD)
val Blue = Color(0xFF1976D2)
// Other Brands
val AndroidGreen = Color(0xFF3DDC84)
}
// --- Theme Selection Enum ---
enum class AppTheme {
PAWLET,
OXMC,
SYSTEM
}
// --- Typography ---
object FontFamilies {
val UbuntuFontFamily = FontFamily(
Font(R.font.ubuntu_regular, FontWeight.Normal),
Font(R.font.ubuntu_medium, FontWeight.Medium),
Font(R.font.ubuntu_bold, FontWeight.Bold)
)
val UbuntuMonoFontFamily = FontFamily(
Font(R.font.ubuntumono_regular, FontWeight.Normal),
Font(R.font.ubuntumono_bold, FontWeight.Bold)
)
val MapleMonoFontFamily = FontFamily(
Font(R.font.maplemono_extrabold, FontWeight.ExtraBold),
)
}
private fun typographyFor(onBackground: Color, fontFamily: FontFamily = FontFamilies.UbuntuFontFamily) = Typography(
displayLarge = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, color = onBackground),
displayMedium = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, color = onBackground),
displaySmall = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Bold, fontSize = 36.sp, color = onBackground),
headlineLarge = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, color = onBackground),
headlineMedium = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, color = onBackground),
headlineSmall = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, color = onBackground),
titleLarge = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Medium, fontSize = 22.sp, color = onBackground),
titleMedium = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, color = onBackground),
titleSmall = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = onBackground),
bodyLarge = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, color = onBackground),
bodyMedium = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, color = onBackground),
bodySmall = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, color = onBackground),
labelLarge = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = onBackground),
labelMedium = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, color = onBackground),
labelSmall = TextStyle(fontFamily = fontFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, color = onBackground)
)
@SuppressLint("ObsoleteSdkInt")
@Composable
fun MainTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
useDynamicColor: Boolean = false,
appTheme: AppTheme = AppTheme.PAWLET, // Default to Pawlet
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme = when {
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
appTheme == AppTheme.PAWLET -> {
if (useDarkTheme) PawletDarkColors else PawletLightColors
}
else -> if (useDarkTheme) PawletDarkColors else PawletLightColors // Default
}
val typography = typographyFor(colorScheme.onBackground)
MaterialTheme(
colorScheme = colorScheme,
typography = typography,
content = content
)
}
// --- Convenience Composables ---
@Composable
fun PawletTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MainTheme(
useDarkTheme = useDarkTheme,
useDynamicColor = false,
appTheme = AppTheme.PAWLET,
content = content
)
}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SetupWizard" parent="Theme.Material3.Dark.NoActionBar">
<item name="android:statusBarColor">?attr/colorPrimary</item>
</style>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SetupWizard" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Enable light icons if status bar is dark -->
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SetupWizard.Splash" parent="Theme.Material3.DayNight">
<item name="android:windowSplashScreenBackground">@color/lavender_500</item>
<item name="android:windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/lavender_secondary</item>
<item name="postSplashScreenTheme">@style/Theme.SetupWizard</item>
</style>
</resources>
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ==== Light Theme ==== -->
<color name="lavender_200">#E0B3FF</color>
<color name="lavender_400">#B266FF</color>
<color name="lavender_500">#B388FF</color>
<color name="lavender_700">#7C4DFF</color>
<color name="lavender_on_primary">#FFFFFF</color>
<color name="lavender_secondary">#7C4DFF</color>
<color name="lavender_on_secondary">#FFFFFF</color>
<color name="lavender_background">#F3E5F5</color>
<color name="lavender_on_background">#2A003F</color>
<!-- Optional containers (Material3 style) -->
<color name="lavender_primary_container">#E9DDFF</color>
<color name="lavender_on_primary_container">#2A003F</color>
<color name="lavender_secondary_container">#E2D4FF</color>
<color name="lavender_on_secondary_container">#2A003F</color>
<!-- ==== Dark Theme ==== -->
<color name="lavender_primary_dark">#CFB2FF</color>
<color name="lavender_on_primary_dark">#000000</color>
<color name="lavender_secondary_dark">#9F6BFF</color>
<color name="lavender_on_secondary_dark">#FFFFFF</color>
<color name="lavender_background_dark">#121212</color>
<color name="lavender_on_background_dark">#FFFFFF</color>
<!-- Optional containers (Material3 style) -->
<color name="lavender_primary_container_dark">#4C3C70</color>
<color name="lavender_on_primary_container_dark">#E9DDFF</color>
<color name="lavender_secondary_container_dark">#4A356C</color>
<color name="lavender_on_secondary_container_dark">#E2D4FF</color>
</resources>
+50
View File
@@ -0,0 +1,50 @@
<resources>
<string name="os_name">PawletOS</string>
<string name="os_compname">oxmc-servers</string>
<string name="app_name">Setup Wizard</string>
<string name="app_description">An app used to setup and provision android devices.</string>
<string name="developer_name">oxmc</string>
<string name="email">contact@oxmc.me</string>
<!-- API Urls -->
<string name="oxmc_api_url">https://cdn.oxmc.me/applebees/api/v2</string>
<string name="weather_api_url">https://api.weatherapi.com/v1/current.json?key={}&amp;q={}&amp;aqi=no</string>
<string name="weather_api_key">72d96a584a454382af104944260401</string>
<!-- API Urls -->
<string name="api_url_base">https://cdn.oxmc.me/api</string>
<string name="api_version">v2</string>
<string name="api_app">oxmc-servers-mdm</string>
<string name="api_deviceSearch">search-device</string>
<string name="api_reportDevice">report-device-info</string>
<!--
Screen title/description
-->
<!-- splash -->
<string name="screen_splash_os_name">@string/os_name</string>
<!-- welcome -->
<string name="screen_welcome_title">Welcome to %1$s</string>
<string name="screen_welcome_description">Let\'s get your new device setup and ready!</string>
<!-- region screen -->
<string name="screen_regionSelection_title">Select Your Region</string>
<string name="screen_regionSelection_description">Choose your region to set up time zone, language, and general regional settings</string>
<!-- locale screen -->
<string name="screen_localeSelection_title">Select Your Locale</string>
<string name="screen_localeSelection_description">Choose your locale to set language, date, time, and number formats for your region</string>
<!-- connection setup -->
<string name="screen_connectionSetup_title">Connection Setup</string>
<string name="screen_connectionSetup_description">We\'ll check your network connection to continue with setup</string>
<!-- setup complete screen -->
<string name="screen_setupComplete_title">Setup Complete!</string>
<string name="screen_setupComplete_description">Your device is ready to use. Tap finish to start exploring.</string>
</resources>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark Theme -->
<style name="Theme.SetupWizard" parent="Theme.Material3.Dark.NoActionBar">
<item name="colorPrimary">@color/lavender_200</item>
<item name="colorOnPrimary">@color/lavender_on_primary_dark</item>
<item name="colorSecondary">@color/lavender_secondary_dark</item>
<item name="colorOnSecondary">@color/lavender_on_secondary_dark</item>
<item name="android:colorBackground">@color/lavender_background_dark</item>
<item name="colorOnBackground">@color/lavender_on_background_dark</item>
</style>
<!-- Light Theme -->
<style name="Theme.SetupWizard.Light" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/lavender_500</item>
<item name="colorOnPrimary">@color/lavender_on_primary</item>
<item name="colorSecondary">@color/lavender_secondary</item>
<item name="colorOnSecondary">@color/lavender_on_secondary</item>
<item name="android:colorBackground">@color/lavender_background</item>
<item name="colorOnBackground">@color/lavender_on_background</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="."/>
</full-backup-content>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<device-transfer>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</device-transfer>
</data-extraction-rules>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." />
</paths>
+6
View File
@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+33
View File
@@ -0,0 +1,33 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false
+13
View File
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/39846e8427e64a3824c13e399d7d813c/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
+40
View File
@@ -0,0 +1,40 @@
[versions]
agp = "9.0.1"
kotlin = "2.2.10"
coreKtx = "1.17.0"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
coil = "2.7.0"
okhttp = "4.12.0"
splashscreen = "1.0.1"
gson = "2.11.0"
material = "1.9.0"
appcompat = "1.7.0"
animation = "1.10.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: The LineageOS Project
SPDX-License-Identifier: Apache-2.0
-->
<permissions>
<privapp-permissions package="me.pawlet.setupwizard">
<permission name="android.permission.BACKUP" />
<permission name="android.permission.CHANGE_COMPONENT_ENABLED_STATE" />
<permission name="android.permission.CHANGE_CONFIGURATION" />
<permission name="android.permission.GET_ACCOUNTS_PRIVILEGED" />
<permission name="android.permission.INTERACT_ACROSS_USERS" />
<permission name="android.permission.MANAGE_USERS" />
<permission name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
<permission name="android.permission.SET_TIME" />
<permission name="android.permission.SET_TIME_ZONE" />
<permission name="android.permission.STATUS_BAR" />
<permission name="android.permission.WRITE_SECURE_SETTINGS" />
</privapp-permissions>
</permissions>
+16
View File
@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: 2015 The CyanogenMod Project
# SPDX-FileCopyrightText: The LineageOS Project
# SPDX-License-Identifier: Apache-2.0
-keep class * extends java.util.ListResourceBundle {
protected Object[][] getContents();
}
# Needed for Parcelable/SafeParcelable Creators to not get stripped
-keepnames class * implements android.os.Parcelable {
public static final ** CREATOR;
}
# Needed when building against the Marshmallow SDK
-dontwarn org.apache.http.**
-dontwarn androidx.**
+27
View File
@@ -0,0 +1,27 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "SetupWizard"
include(":app")