commit c85367f3d493b721ed9c86f6f7b77e2db432003f Author: oxmc <67136658+oxmc@users.noreply.github.com> Date: Tue Jun 16 22:30:40 2026 -0700 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 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33b714a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Android.bp b/Android.bp new file mode 100644 index 0000000..d4c8533 --- /dev/null +++ b/Android.bp @@ -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 +} diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..2f44946 --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..4796c65 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/app/libs/android-device-info.aar b/app/libs/android-device-info.aar new file mode 100644 index 0000000..0c9a15d Binary files /dev/null and b/app/libs/android-device-info.aar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3098f6e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/Permissions.kt b/app/src/main/java/dev/oxmc/setupwizard/Permissions.kt new file mode 100644 index 0000000..953735d --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/Permissions.kt @@ -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 11–12L, API 30–32) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + addPermissions(Manifest.permission.READ_EXTERNAL_STORAGE) + setPermissionName(Manifest.permission.READ_EXTERNAL_STORAGE, "Read Storage") + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Storage (Android 13+, API 33+) + addPermissions(Manifest.permission.READ_MEDIA_AUDIO) + setPermissionName(Manifest.permission.READ_MEDIA_AUDIO, "Read Audio") + } + } + + // Notifications (Android 13+, API 33+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + addPermissions(Manifest.permission.POST_NOTIFICATIONS) + setPermissionName(Manifest.permission.POST_NOTIFICATIONS, "Show Notifications") + } + + // Phone state (All versions) + addPermissions(Manifest.permission.READ_PHONE_STATE) + setPermissionName(Manifest.permission.READ_PHONE_STATE, "Read Phone State") + + // Install packages (All versions) + includeInstallPermission(true) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/activities/LoginActivity.kt b/app/src/main/java/dev/oxmc/setupwizard/activities/LoginActivity.kt new file mode 100644 index 0000000..9a44eab --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/activities/LoginActivity.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/activities/MainActivity.kt b/app/src/main/java/dev/oxmc/setupwizard/activities/MainActivity.kt new file mode 100644 index 0000000..0f8751d --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/activities/MainActivity.kt @@ -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() } + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/dev/oxmc/setupwizard/activities/PermissionsActivity.kt b/app/src/main/java/dev/oxmc/setupwizard/activities/PermissionsActivity.kt new file mode 100644 index 0000000..da9a2bc --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/activities/PermissionsActivity.kt @@ -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 + private lateinit var multiplePermissionLauncher: ActivityResultLauncher> + private lateinit var installPermissionLauncher: ActivityResultLauncher + + // Track which permission is being requested (for single permission launcher) + private var currentRequestedPermission: String? = null + + // Track permission states + private val permissionStates = mutableStateMapOf() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + prefs = PrefManager(this) + + // Create permission manager with the permissions you need + permissionManager = Perms(this) + + // Initialize permission states + permissionManager.allPermissions.forEach { permission -> + permissionStates[permission] = permissionManager.isPermissionGranted(permission) + } + + // Setup launchers + singlePermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + // Update the specific permission that was requested + currentRequestedPermission?.let { permission -> + // Update the state with the actual permission check + permissionStates[permission] = permissionManager.isPermissionGranted(permission) + currentRequestedPermission = null + } + } + + multiplePermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + // Update ALL permission states with fresh checks + permissionManager.allPermissions.forEach { permission -> + permissionStates[permission] = permissionManager.isPermissionGranted(permission) + } + } + + installPermissionLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + // Update install permission state with fresh check + permissionStates[PermissionManager.INSTALL_PERMISSION_KEY] = + permissionManager.isPermissionGranted(PermissionManager.INSTALL_PERMISSION_KEY) + } + + // REMOVED the auto-continue check - user must press continue button + setContent { + MainTheme { + PermissionsScreen( + permissionManager = permissionManager, + permissionStates = permissionStates, + onRequestPermission = ::requestPermission, + onRequestAllPermissions = ::requestAllPermissions, + onContinue = ::finishWithSuccess + ) + } + } + } + + private fun requestPermission(permission: String) { + if (permission == PermissionManager.INSTALL_PERMISSION_KEY) { + // Handle install permission separately + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = "package:$packageName".toUri() + } + installPermissionLauncher.launch(intent) + } else { + // Store which permission we're requesting and launch the request + currentRequestedPermission = permission + singlePermissionLauncher.launch(permission) + } + } + + private fun requestAllPermissions() { + permissionManager.requestAllMissingPermissions(multiplePermissionLauncher) + + // Also request install permission if needed + if (permissionManager.config.includeInstallPermission && + !permissionManager.isPermissionGranted(PermissionManager.INSTALL_PERMISSION_KEY)) { + requestPermission(PermissionManager.INSTALL_PERMISSION_KEY) + } + } + + private fun finishWithSuccess() { + prefs.hasCompletedInitialPermissions = true + startActivity(Intent(this, EntryPoint::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + }) + finish() + } + + @Composable + fun PermissionsScreen( + permissionManager: PermissionManager, + permissionStates: Map, + onRequestPermission: (String) -> Unit, + onRequestAllPermissions: () -> Unit, + onContinue: () -> Unit + ) { + // Calculate all granted state based on current permission states + val allGranted = permissionManager.allPermissions.all { permission -> + permissionStates[permission] == true + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "Required Permissions", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(Modifier.height(32.dp)) + + // Display each permission + permissionManager.allPermissions.forEach { permission -> + val displayName = permissionManager.permissionDisplayNames[permission] ?: permission + val granted = permissionStates[permission] ?: false + + PermissionRequestItem( + name = displayName, + granted = granted, + onRequest = { onRequestPermission(permission) } + ) + Spacer(Modifier.height(16.dp)) + } + + // Request all button + if (!allGranted) { + Button( + onClick = onRequestAllPermissions, + modifier = Modifier.fillMaxWidth() + ) { + Text("Allow All Permissions") + } + Spacer(Modifier.height(16.dp)) + } + + // Continue button + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + enabled = allGranted + ) { + Text(if (allGranted) "Continue to App" else "Complete all permissions to continue") + } + } + } + } + + @Composable + private fun PermissionRequestItem( + name: String, + granted: Boolean, + onRequest: () -> Unit + ) { + Button( + onClick = onRequest, + enabled = !granted, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = if (granted) "✓ $name - Granted" else "Allow $name", + maxLines = 1 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/entryPoint.kt b/app/src/main/java/dev/oxmc/setupwizard/entryPoint.kt new file mode 100644 index 0000000..0b30a1d --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/entryPoint.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/ApkManager.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/ApkManager.kt new file mode 100644 index 0000000..2faa10b --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/ApkManager.kt @@ -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 can’t 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/AppMarkets.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/AppMarkets.kt new file mode 100644 index 0000000..e6223bd --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/AppMarkets.kt @@ -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? = 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, 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) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/ConnectionManager.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/ConnectionManager.kt new file mode 100644 index 0000000..c08a7dc --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/ConnectionManager.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/DataClasses.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/DataClasses.kt new file mode 100644 index 0000000..08e4e07 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/DataClasses.kt @@ -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 = 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, + 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? +) \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/DeviceProfile.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/DeviceProfile.kt new file mode 100644 index 0000000..c1227ae --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/DeviceProfile.kt @@ -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 } + } +} diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/KioskBaseActivity.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/KioskBaseActivity.kt new file mode 100644 index 0000000..72b2500 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/KioskBaseActivity.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/SetupWizardManager.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/SetupWizardManager.kt new file mode 100644 index 0000000..8b8f4ba --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/SetupWizardManager.kt @@ -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 = 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 = _currentIndex.asStateFlow() + + private val _overlay = MutableStateFlow(Overlay.None) + val overlay: StateFlow = _overlay.asStateFlow() + + private val _deviceInfo = MutableStateFlow>(emptyArray()) + val deviceInfo: StateFlow> = _deviceInfo.asStateFlow() + + fun registerPages(allPages: List) { + 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) + } +} diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/WizardPage.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/WizardPage.kt new file mode 100644 index 0000000..cb5ff50 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/WizardPage.kt @@ -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 +) diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/WizardRegistry.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/WizardRegistry.kt new file mode 100644 index 0000000..32e61ed --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/WizardRegistry.kt @@ -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 = 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) + } + ) +) diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/fullscreenhelper.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/fullscreenhelper.kt new file mode 100644 index 0000000..5697bc0 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/fullscreenhelper.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/AuthManager.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/AuthManager.kt new file mode 100644 index 0000000..52c4e3a --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/AuthManager.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/Helpers.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/Helpers.kt new file mode 100644 index 0000000..49e2a9a --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/Helpers.kt @@ -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 { + return Locale.getAvailableLocales() + .distinctBy { it.toLanguageTag() } + .sortedBy { it.displayName } + } + fun getSystemRegions(): List { + 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 { + 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 + ) + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/PermissionManager.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/PermissionManager.kt new file mode 100644 index 0000000..bc29f47 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/PermissionManager.kt @@ -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 = emptySet(), + val includeInstallPermission: Boolean = false, + val autoIncludeDependencies: Boolean = true, + val customPermissionNames: Map = emptyMap() + ) + + class Builder(private val context: Context) { + private val permissions = mutableSetOf() + private var includeInstallPermission = false + private var autoIncludeDependencies = true + private val customPermissionNames = mutableMapOf() + + /** + * Add a single permission to request + */ + fun addPermission(permission: String) = apply { + permissions.add(permission) + } + + /** + * Add multiple permissions to request + */ + fun addPermissions(vararg permissions: String) = apply { + this.permissions.addAll(permissions) + } + + /** + * Add permissions from a collection + */ + fun addPermissions(permissions: Collection) = apply { + this.permissions.addAll(permissions) + } + + /** + * Include the "Install Unknown Apps" permission (Android O+) + */ + fun includeInstallPermission(include: Boolean = true) = apply { + this.includeInstallPermission = include + } + + /** + * Automatically include permission dependencies (e.g., location for Bluetooth) + */ + fun autoIncludeDependencies(autoInclude: Boolean = true) = apply { + this.autoIncludeDependencies = autoInclude + } + + /** + * Set a custom display name for a permission + */ + fun setPermissionName(permission: String, displayName: String) = apply { + customPermissionNames[permission] = displayName + } + + /** + * Set custom display names for multiple permissions + */ + fun setPermissionNames(names: Map) = apply { + customPermissionNames.putAll(names) + } + + fun build(): PermissionManager { + return PermissionManager(context, Config( + permissions = buildFinalPermissionSet(), + includeInstallPermission = includeInstallPermission, + autoIncludeDependencies = autoIncludeDependencies, + customPermissionNames = customPermissionNames + )) + } + + private fun buildFinalPermissionSet(): Set { + val finalPermissions = permissions.toMutableSet() + + if (autoIncludeDependencies) { + // Auto-add dependencies based on the permissions requested + if (finalPermissions.any { it.startsWith("android.permission.BLUETOOTH") }) { + finalPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION) + } + + if (finalPermissions.contains(Manifest.permission.ACCESS_COARSE_LOCATION) && + !finalPermissions.contains(Manifest.permission.ACCESS_FINE_LOCATION)) { + finalPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION) + } + } + + return finalPermissions.filter { shouldRequestPermission(it) }.toSet() + } + + /** + * Check if a permission should be requested based on Android version + */ + private fun shouldRequestPermission(permission: String): Boolean { + return when { + // Storage permissions are not needed on Android 11+ + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + (permission == Manifest.permission.READ_EXTERNAL_STORAGE || + permission == Manifest.permission.WRITE_EXTERNAL_STORAGE) -> false + + // Skip legacy Bluetooth permissions on Android 12+ + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + (permission == Manifest.permission.BLUETOOTH || + permission == Manifest.permission.BLUETOOTH_ADMIN) -> false + + else -> true + } + } + } + + // Runtime permissions that need to be requested + val runtimePermissions: Set by lazy { + config.permissions.filter { shouldRequestPermission(it) }.toSet() + } + + // All permissions including special ones like install permission + val allPermissions: Set by lazy { + runtimePermissions + if (config.includeInstallPermission && shouldRequestInstallPermission()) { + setOf(INSTALL_PERMISSION_KEY) + } else { + emptySet() + } + } + + val permissionDisplayNames: Map by lazy { + allPermissions.associateWith { permission -> + config.customPermissionNames[permission] ?: when (permission) { + INSTALL_PERMISSION_KEY -> "Install Apps" + Manifest.permission.CAMERA -> "Camera" + Manifest.permission.READ_EXTERNAL_STORAGE -> "Storage Access" + Manifest.permission.WRITE_EXTERNAL_STORAGE -> "Storage Access" + Manifest.permission.POST_NOTIFICATIONS -> "Notifications" + Manifest.permission.ACCESS_FINE_LOCATION -> "Precise Location" + Manifest.permission.ACCESS_COARSE_LOCATION -> "Approximate Location" + Manifest.permission.BLUETOOTH -> "Bluetooth" + Manifest.permission.BLUETOOTH_ADMIN -> "Bluetooth Admin" + Manifest.permission.BLUETOOTH_SCAN -> "Bluetooth Scan" + Manifest.permission.BLUETOOTH_CONNECT -> "Bluetooth Connect" + Manifest.permission.BLUETOOTH_ADVERTISE -> "Bluetooth Advertise" + Manifest.permission.RECORD_AUDIO -> "Microphone" + Manifest.permission.READ_CONTACTS -> "Contacts" + Manifest.permission.WRITE_CONTACTS -> "Contacts" + Manifest.permission.READ_CALENDAR -> "Calendar" + Manifest.permission.WRITE_CALENDAR -> "Calendar" + Manifest.permission.READ_PHONE_STATE -> "Phone" + Manifest.permission.CALL_PHONE -> "Phone Calls" + Manifest.permission.READ_SMS -> "SMS" + Manifest.permission.RECEIVE_SMS -> "SMS" + Manifest.permission.SEND_SMS -> "SMS" + Manifest.permission.BODY_SENSORS -> "Body Sensors" + Manifest.permission.ACTIVITY_RECOGNITION -> "Physical Activity" + else -> permission.substringAfterLast('.').replace("_", " ") + } + } + } + + /** + * Check if a specific permission is granted + */ + fun isPermissionGranted(permission: String): Boolean { + return when { + permission == INSTALL_PERMISSION_KEY -> isInstallPermissionGranted() + else -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + } + + /** + * Check if all permissions are granted + */ + fun areAllPermissionsGranted(): Boolean { + return allPermissions.all { isPermissionGranted(it) } + } + + /** + * Check if all runtime permissions (excluding install permission) are granted + */ + fun areRuntimePermissionsGranted(): Boolean { + return runtimePermissions.all { isPermissionGranted(it) } + } + + /** + * Request a single permission + */ + fun requestPermission( + permission: String, + launcher: ActivityResultLauncher + ) { + if (permission == INSTALL_PERMISSION_KEY) { + requestInstallPermission() + } else { + launcher.launch(permission) + } + } + + /** + * Request all missing permissions at once + */ + fun requestAllMissingPermissions(launcher: ActivityResultLauncher>) { + val missingPermissions = runtimePermissions.filter { !isPermissionGranted(it) }.toTypedArray() + if (missingPermissions.isNotEmpty()) { + launcher.launch(missingPermissions) + } + } + + /** + * Get the list of granted permissions + */ + fun getGrantedPermissions(): Set { + return allPermissions.filter { isPermissionGranted(it) }.toSet() + } + + /** + * Get the list of missing permissions + */ + fun getMissingPermissions(): Set { + return allPermissions.filter { !isPermissionGranted(it) }.toSet() + } + + /** + * Check if a permission should be requested based on Android version + */ + fun shouldRequestPermission(permission: String): Boolean { + return when { + // Storage permissions are not needed on Android 11+ + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + (permission == Manifest.permission.READ_EXTERNAL_STORAGE || + permission == Manifest.permission.WRITE_EXTERNAL_STORAGE) -> false + + // Skip legacy Bluetooth permissions on Android 12+ + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + (permission == Manifest.permission.BLUETOOTH || + permission == Manifest.permission.BLUETOOTH_ADMIN) -> false + + else -> true + } + } + + private fun shouldRequestInstallPermission(): Boolean { + return config.includeInstallPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + } + + private fun isInstallPermissionGranted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else { + true + } + } + + @SuppressLint("InlinedApi") + private fun requestInstallPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = "package:${context.packageName}".toUri() + } + context.startActivity(intent) + } + } + + companion object { + const val INSTALL_PERMISSION_KEY = "INSTALL_UNKNOWN_APPS" + + fun create(context: Context, block: Builder.() -> Unit = {}): PermissionManager { + return Builder(context).apply(block).build() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/SharedPrefs.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/SharedPrefs.kt new file mode 100644 index 0000000..3e905e9 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/SharedPrefs.kt @@ -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) } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/URiHandler.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/URiHandler.kt new file mode 100644 index 0000000..0275003 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/URiHandler.kt @@ -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=&silent= — 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= — 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" + } +} diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/internal/oxmcservers.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/oxmcservers.kt new file mode 100644 index 0000000..ae343ca --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/internal/oxmcservers.kt @@ -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 { + data class Success(val data: T) : ServerResult() + data class Error(val message: String) : ServerResult() +} + +class OxmcServers(private val context: Context) { + private val authManager = AuthManager(context) + private val baseUrl = context.getString(R.string.oxmc_api_url) + + /** + * Helper function to make authenticated requests and handle token renewal + */ + private suspend fun makeAuthenticatedRequest( + urlPath: String, + method: String, + body: JSONObject? = null, + requireAuth: Boolean = false + ): Pair { + return withContext(Dispatchers.IO) { + val url = URL("$urlPath") + val connection = url.openConnection() as HttpURLConnection + + try { + connection.apply { + requestMethod = method + setRequestProperty("Content-Type", "application/json") + + if (requireAuth) { + val token = authManager.getToken() + if (token != null) { + setRequestProperty("Authorization", "Bearer $token") + } + } + + if (body != null) { + doOutput = true + } + + connectTimeout = 10000 + readTimeout = 10000 + } + + // Send body if present + if (body != null) { + OutputStreamWriter(connection.outputStream).use { writer -> + writer.write(body.toString()) + writer.flush() + } + } + + val responseCode = connection.responseCode + val response = BufferedReader(InputStreamReader( + if (responseCode in 200..299) connection.inputStream + else connection.errorStream + )).use { it.readText() } + + // Check for token renewal in response headers + if (requireAuth) { + authManager.checkAndUpdateTokenFromHeaders(connection) + } + + Pair(responseCode, response) + } finally { + connection.disconnect() + } + } + } + + /** + * Check for app updates + */ + suspend fun checkForUpdates(appId: String, currentVersion: String): ServerResult { + return try { + val jsonBody = JSONObject().apply { + put("app", JSONObject().apply { + put("id", appId) + put("version", currentVersion) + }) + } + + val (responseCode, response) = makeAuthenticatedRequest( + "$baseUrl/updateCheck", + "POST", + jsonBody + ) + + if (responseCode == HttpURLConnection.HTTP_OK) { + val jsonResponse = JSONObject(response) + ServerResult.Success( + UpdateInfo( + latestVersion = jsonResponse.getString("latestVersion"), + updateAvailable = jsonResponse.getBoolean("updateAvailable"), + downloadUrl = jsonResponse.getString("downloadUrl"), + releaseNotes = jsonResponse.optString("releaseNotes", null) + ) + ) + } else { + val errorMsg = try { + JSONObject(response).optString("error", "Update check failed") + } catch (e: Exception) { + "Update check failed" + } + ServerResult.Error(errorMsg) + } + } catch (e: Exception) { + Log.e("OxmcServers", "Update check error: ${e.message}", e) + ServerResult.Error("Network error: ${e.message}") + } + } + + /** + * Get the current manager on duty for a store + */ + suspend fun getManagerOnDuty(storeId: String? = null): ServerResult { + return try { + val store = storeId ?: authManager.getStoreId() + if (store == null) { + return ServerResult.Error("No store ID available") + } + + val token = authManager.getToken() + if (token == null) { + return ServerResult.Error("Not authenticated") + } + + val (responseCode, response) = makeAuthenticatedRequest( + "$baseUrl/manager/$store", + "GET", + requireAuth = true + ) + + if (responseCode == HttpURLConnection.HTTP_OK) { + val jsonResponse = JSONObject(response) + if (jsonResponse.getBoolean("success")) { + val manager = jsonResponse.getJSONObject("manager") + ServerResult.Success( + ManagerInfo( + name = manager.getString("name"), + pronouns = manager.getString("pronouns"), + picture = manager.getString("picture"), + storeId = manager.getString("storeId"), + storeName = manager.getString("storeName"), + setAt = manager.getString("setAt"), + setBy = manager.getString("setBy") + ) + ) + } else { + ServerResult.Error(jsonResponse.optString("message", "Failed to get manager")) + } + } else { + val errorMsg = try { + JSONObject(response).optString("message", "Failed to get manager") + } catch (e: Exception) { + "Failed to get manager" + } + ServerResult.Error(errorMsg) + } + } catch (e: Exception) { + Log.e("OxmcServers", "Get manager error: ${e.message}", e) + ServerResult.Error("Network error: ${e.message}") + } + } + + /** + * Set the manager on duty for a store + */ + suspend fun setManagerOnDuty( + managerId: String? = null, + name: String? = null, + pronouns: String? = null, + picture: String? = null, + storeId: String? = null + ): ServerResult { + return try { + val store = storeId ?: authManager.getStoreId() + if (store == null) { + return ServerResult.Error("No store ID available") + } + + val token = authManager.getToken() + if (token == null) { + return ServerResult.Error("Not authenticated") + } + + val jsonBody = JSONObject().apply { + put("storeId", store) + if (managerId != null) { + put("managerId", managerId) + } else { + put("name", name) + put("pronouns", pronouns) + put("picture", picture) + } + } + + val (responseCode, response) = makeAuthenticatedRequest( + "$baseUrl/manager", + "POST", + jsonBody, + requireAuth = true + ) + + if (responseCode == HttpURLConnection.HTTP_OK) { + val jsonResponse = JSONObject(response) + if (jsonResponse.getBoolean("success")) { + val manager = jsonResponse.getJSONObject("manager") + ServerResult.Success( + ManagerInfo( + name = manager.getString("name"), + pronouns = manager.getString("pronouns"), + picture = manager.getString("picture"), + storeId = manager.getString("storeId"), + storeName = manager.getString("storeName"), + setAt = manager.getString("setAt"), + setBy = manager.getString("setBy") + ) + ) + } else { + ServerResult.Error(jsonResponse.optString("message", "Failed to set manager")) + } + } else { + val errorMsg = try { + JSONObject(response).optString("message", "Failed to set manager") + } catch (e: Exception) { + "Failed to set manager" + } + ServerResult.Error(errorMsg) + } + } catch (e: Exception) { + Log.e("OxmcServers", "Set manager error: ${e.message}", e) + ServerResult.Error("Network error: ${e.message}") + } + } + + /** + * Get list of all available managers + */ + suspend fun getAvailableManagers(): ServerResult> { + return try { + val token = authManager.getToken() + if (token == null) { + return ServerResult.Error("Not authenticated") + } + + val (responseCode, response) = makeAuthenticatedRequest( + "$baseUrl/managers", + "GET", + requireAuth = true + ) + + if (responseCode == HttpURLConnection.HTTP_OK) { + val jsonResponse = JSONObject(response) + if (jsonResponse.getBoolean("success")) { + val managersArray = jsonResponse.getJSONArray("managers") + val managers = mutableListOf() + + for (i in 0 until managersArray.length()) { + val manager = managersArray.getJSONObject(i) + managers.add( + ManagerInfo( + name = manager.getString("name"), + pronouns = manager.getString("pronouns"), + picture = manager.getString("picture"), + storeId = "", + storeName = "", + setAt = manager.optString("created_at", ""), + setBy = "" + ) + ) + } + + ServerResult.Success(managers) + } else { + ServerResult.Error(jsonResponse.optString("message", "Failed to get managers")) + } + } else { + val errorMsg = try { + JSONObject(response).optString("message", "Failed to get managers") + } catch (e: Exception) { + "Failed to get managers" + } + ServerResult.Error(errorMsg) + } + } catch (e: Exception) { + Log.e("OxmcServers", "Get managers error: ${e.message}", e) + ServerResult.Error("Network error: ${e.message}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/lib/utils/SetupUtils.kt b/app/src/main/java/dev/oxmc/setupwizard/lib/utils/SetupUtils.kt new file mode 100644 index 0000000..39d83e3 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/lib/utils/SetupUtils.kt @@ -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!") + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/components/FeatureCard.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/components/FeatureCard.kt new file mode 100644 index 0000000..7b78adf --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/components/FeatureCard.kt @@ -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) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/components/SecretTapButton.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/components/SecretTapButton.kt new file mode 100644 index 0000000..8e35bfe --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/components/SecretTapButton.kt @@ -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(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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/AboutScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/AboutScreen.kt new file mode 100644 index 0000000..e371ee9 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/AboutScreen.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DebugMenuScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DebugMenuScreen.kt new file mode 100644 index 0000000..a485c40 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DebugMenuScreen.kt @@ -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, + 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, + 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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DeviceInfoScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DeviceInfoScreen.kt new file mode 100644 index 0000000..1e06b4d --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DeviceInfoScreen.kt @@ -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, + 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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DeviceInfoScreenDebug.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DeviceInfoScreenDebug.kt new file mode 100644 index 0000000..289133d --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/DeviceInfoScreenDebug.kt @@ -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(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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/LoginScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..901ae6b --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/LoginScreen.kt @@ -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)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/MainScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/MainScreen.kt new file mode 100644 index 0000000..7701bb2 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/MainScreen.kt @@ -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 } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SettingsScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..b07863f --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SettingsScreen.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SplashScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SplashScreen.kt new file mode 100644 index 0000000..cf46078 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/SplashScreen.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/WebViewScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/WebViewScreen.kt new file mode 100644 index 0000000..d665a51 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/WebViewScreen.kt @@ -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() + 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._-]"), "_") + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ConnectionSetupScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ConnectionSetupScreen.kt new file mode 100644 index 0000000..4b63bd5 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/ConnectionSetupScreen.kt @@ -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("") } + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/LocaleSelectionScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/LocaleSelectionScreen.kt new file mode 100644 index 0000000..da8590d --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/LocaleSelectionScreen.kt @@ -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>(emptyList()) } + var selectedLocale by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var showSearch by remember { mutableStateOf(false) } + var autoDetectedLocale by remember { mutableStateOf(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, + 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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/OTAUpdateScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/OTAUpdateScreen.kt new file mode 100644 index 0000000..28c28eb --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/OTAUpdateScreen.kt @@ -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) { + 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 */ } + ) +} +*/ \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RegionSelectionScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RegionSelectionScreen.kt new file mode 100644 index 0000000..5fc186c --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/RegionSelectionScreen.kt @@ -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>(emptyList()) } + var selectedRegion by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var autoDetectedRegion by remember { mutableStateOf(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, + 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) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SetupCompleteScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SetupCompleteScreen.kt new file mode 100644 index 0000000..9cca0e4 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/SetupCompleteScreen.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/WelcomeScreen.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/WelcomeScreen.kt new file mode 100644 index 0000000..e082eaf --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/screens/wizard/WelcomeScreen.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/oxmc/setupwizard/ui/theme/Theme.kt b/app/src/main/java/dev/oxmc/setupwizard/ui/theme/Theme.kt new file mode 100644 index 0000000..6011762 --- /dev/null +++ b/app/src/main/java/dev/oxmc/setupwizard/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pink_protogen.png b/app/src/main/res/drawable/pink_protogen.png new file mode 100644 index 0000000..f1205c0 Binary files /dev/null and b/app/src/main/res/drawable/pink_protogen.png differ diff --git a/app/src/main/res/font/maplemono_extrabold.ttf b/app/src/main/res/font/maplemono_extrabold.ttf new file mode 100644 index 0000000..04b4a6c Binary files /dev/null and b/app/src/main/res/font/maplemono_extrabold.ttf differ diff --git a/app/src/main/res/font/ubuntu_bold.ttf b/app/src/main/res/font/ubuntu_bold.ttf new file mode 100644 index 0000000..b173da2 Binary files /dev/null and b/app/src/main/res/font/ubuntu_bold.ttf differ diff --git a/app/src/main/res/font/ubuntu_medium.ttf b/app/src/main/res/font/ubuntu_medium.ttf new file mode 100644 index 0000000..ca9c03a Binary files /dev/null and b/app/src/main/res/font/ubuntu_medium.ttf differ diff --git a/app/src/main/res/font/ubuntu_regular.ttf b/app/src/main/res/font/ubuntu_regular.ttf new file mode 100644 index 0000000..d748728 Binary files /dev/null and b/app/src/main/res/font/ubuntu_regular.ttf differ diff --git a/app/src/main/res/font/ubuntumono_bold.ttf b/app/src/main/res/font/ubuntumono_bold.ttf new file mode 100644 index 0000000..7bd6665 Binary files /dev/null and b/app/src/main/res/font/ubuntumono_bold.ttf differ diff --git a/app/src/main/res/font/ubuntumono_regular.ttf b/app/src/main/res/font/ubuntumono_regular.ttf new file mode 100644 index 0000000..fdd309d Binary files /dev/null and b/app/src/main/res/font/ubuntumono_regular.ttf differ diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-v21/themes.xml b/app/src/main/res/values-v21/themes.xml new file mode 100644 index 0000000..00ae491 --- /dev/null +++ b/app/src/main/res/values-v21/themes.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v23/themes.xml b/app/src/main/res/values-v23/themes.xml new file mode 100644 index 0000000..5e03a5b --- /dev/null +++ b/app/src/main/res/values-v23/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..aed28c8 --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ffdfa79 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,34 @@ + + + + #E0B3FF + #B266FF + #B388FF + #7C4DFF + + #FFFFFF + #7C4DFF + #FFFFFF + #F3E5F5 + #2A003F + + + #E9DDFF + #2A003F + #E2D4FF + #2A003F + + + #CFB2FF + #000000 + #9F6BFF + #FFFFFF + #121212 + #FFFFFF + + + #4C3C70 + #E9DDFF + #4A356C + #E2D4FF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bc2623b --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,50 @@ + + PawletOS + oxmc-servers + + Setup Wizard + An app used to setup and provision android devices. + + oxmc + contact@oxmc.me + + + https://cdn.oxmc.me/applebees/api/v2 + + https://api.weatherapi.com/v1/current.json?key={}&q={}&aqi=no + 72d96a584a454382af104944260401 + + + https://cdn.oxmc.me/api + v2 + oxmc-servers-mdm + search-device + report-device-info + + + + + @string/os_name + + + Welcome to %1$s + Let\'s get your new device setup and ready! + + + Select Your Region + Choose your region to set up time zone, language, and general regional settings + + + Select Your Locale + Choose your locale to set language, date, time, and number formats for your region + + + Connection Setup + We\'ll check your network connection to continue with setup + + + Setup Complete! + Your device is ready to use. Tap finish to start exploring. + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..438eceb --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..41b1d6b --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..ae51535 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..82f0888 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..952b930 --- /dev/null +++ b/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4540b75 --- /dev/null +++ b/gradle.properties @@ -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 \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..2ef1b89 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..f8490a6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..980502d Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/me.pawlet.setupwizard.xml b/me.pawlet.setupwizard.xml new file mode 100644 index 0000000..8a86740 --- /dev/null +++ b/me.pawlet.setupwizard.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/proguard.flags b/proguard.flags new file mode 100644 index 0000000..4bcaa49 --- /dev/null +++ b/proguard.flags @@ -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.** diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d4fe414 --- /dev/null +++ b/settings.gradle.kts @@ -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") + \ No newline at end of file