diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..7a4748b --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,31 @@ +name: Android build + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Make Gradle wrapper executable + run: chmod +x gradlew + + - name: Build debug APK + run: ./gradlew :app:assembleDebug + + - name: Upload test APK + uses: actions/upload-artifact@v4 + with: + name: QuestHomeSwitcher-debug + path: app/build/outputs/apk/debug/app-debug.apk diff --git a/.gitignore b/.gitignore index f4502a3..9b18319 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,21 @@ -*.iml +# Android Studio and local machine settings .gradle/ -build/ -*/build/ .idea/ +.kotlin/ local.properties +keystore.properties +*.jks +*.keystore + +# Generated build output +build/ +**/build/ + +# OS files +.DS_Store +Thumbs.db + +# Local release packages +release/ *.apk -*.ap_ -*.dex -*.class -bin/ -gen/ -out/ -.navigation/ -captures/ -.externalNativeBuild/ -.cxx/ -*.log +*.aab diff --git a/BuildAndInstall.bat b/BuildAndInstall.bat deleted file mode 100644 index ce7bdc8..0000000 --- a/BuildAndInstall.bat +++ /dev/null @@ -1,63 +0,0 @@ -@echo off -setlocal EnableExtensions EnableDelayedExpansion - -REM ---- CONFIG ---- -set PKG=qu.astro.vrshellpatcher -set APK=app\build\outputs\apk\debug\app-debug.apk -set SHELLPKG=com.oculus.vrshell - -REM Ensure we run from this script's folder (project root) -pushd "%~dp0" - -:build -cls -echo ============================================== -echo Android Build + Install Loop -echo %date% %time% -echo ============================================== - -echo [%time%] Cleaning Gradle project... -call gradlew.bat clean -if errorlevel 1 ( - echo [%time%] ERROR: Gradle clean failed. Check your Gradle setup. - echo. - pause - goto build -) - -echo [%time%] Building Debug APK... -call gradlew.bat assembleDebug -if errorlevel 1 ( - echo [%time%] ERROR: Build failed. Fix errors and retry. - echo. - pause - goto build -) - -if not exist "%APK%" ( - echo [%time%] ERROR: APK not found at "%APK%". - echo Verify your module path and build variant. - echo. - pause - goto build -) - - -echo [%time%] Installing updated APK... -adb install -r "%APK%" -if errorlevel 1 ( - echo [%time%] ERROR: adb install failed. - echo. - pause - goto build -) - -echo [%time%] Restarting vrshell... -adb shell am force-stop %SHELLPKG% >nul 2>&1 -adb shell am start %SHELLPKG%/.MainActivity - -echo [%time%] Build and installation complete. -echo ============================================== -echo. -pause -goto build diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ffbe014 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 1.1.0 + +- Added root-first privilege detection with no Shizuku dependency on rooted devices. +- Preserved direct `oculuspreferences` support for environments, vistas, and footprints. +- Added a Shizuku fallback for unrooted devices and NoRoot-Spoof Home APKs. +- Added automatic Home discovery in Download and dedicated Home folders. +- Added `assets/scene.zip` validation so unrelated APKs stay hidden. +- Added Home and package-name search. +- Replaced the legacy layout with a Quest-friendly Material 3 landscape interface. +- Added an Android CI build workflow. diff --git a/README.md b/README.md index c84012b..802ac41 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,47 @@ # Quest Environment Picker -A root-based tool for managing Meta Quest environments, vistas, and footprints via `oculuspreferences`. +A hybrid root and rootless environment picker for Meta Quest, based on xAstroBoy's original root environment manager. ## Features -- **Tabbed UI** — Environments | Vistas | Footprints -- **Scanner** — Detects installed `.environment.`, `.env.vista.`, and `.env.footprint.` APKs -- **Apply** — Sets env keys with one tap per type -- **Set Default** — Sets `environment_default` + `default_footprint` without changing active env -- **As Env** — Apply a footprint as the main environment -- **Set Defaults** — Reverts env, vista, and footprint to stock (haven2025 / central / nuxd fallback) -- **Restart VRS** — Force-stops `com.oculus.vrshell` -- **Uninstall** — Root-based `pm uninstall` with protection for stock packages -- **Protected packages** — `central`, `haven2025`, `nuxd` cannot be uninstalled - -## Requirements - -- **Root access** (Magisk) — `su` must be available -- **`oculuspreferences`** binary on device -- Meta Quest running v74+ - -## Keys Managed - -| Key | Purpose | -|-----|---------| -| `environment_selected` | Active environment | -| `environment_default` | Fallback environment | -| `resolved_environment` | Resolved environment | -| `default_footprint` | Footprint fallback | -| `default_vista` | Vista fallback | -| `resolved_vista` | Resolved vista | -| `environment_vista_selected` | Active vista | - -## Stock Defaults - -| Type | URI | -|------|-----| -| Environment | `apk://com.meta.shell.env.footprint.haven2025/assets/scene.zip` | -| Vista | `apk://com.meta.shell.env.vista.central/assets/scene.zip` | -| Footprint | `apk://com.meta.shell.env.footprint.haven2025/assets/scene.zip` | +- Automatic Magisk/`su` root detection +- Direct rooted switching through `oculuspreferences` +- Correct handling of environments, vistas, and footprints +- Installed environment package discovery on rooted devices +- Rootless Shizuku fallback for NoRoot-Spoof Home APKs +- Automatic Home APK discovery in `Download`, `Quest Homes`, `QuestHomes`, and `Homes` +- APK validation through `assets/scene.zip` +- Search by Home name or package name +- Quest-friendly Material 3 landscape UI +- VR Shell reload after direct root switching + +## Root mode + +Install the `Rooted-System` environment packages normally. The picker discovers them and updates the matching preferences: + +- Environment: `environment_selected`, `environment_default`, `resolved_environment` +- Vista: `default_vista`, `resolved_vista`, `environment_vista_selected` +- Footprint: `default_footprint` + +Shizuku is not required when root is available. + +## Rootless mode + +Start Shizuku through Wireless ADB and copy compatible `NoRoot-Spoof` APKs to the Quest `Download` folder or a dedicated Home folder. The picker keeps the APK available and installs the selected Home over `com.meta.shell.env.footprint.haven2025`. ## Build +Java 17 and Android SDK 35 are required. + ```bash -./gradlew assembleDebug -adb install -r app/build/outputs/apk/debug/app-debug.apk +./gradlew :app:assembleDebug ``` + +The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. + +## Credits + +- Original Quest Environment Picker and root preference mapping: [xAstroBoy](https://github.com/xAstroBoy) +- Hybrid Shizuku workflow and Material 3 picker UI contributed by Nikita with Codex assistance + +This is an unofficial community project and is not affiliated with Meta, Shizuku, or Quest Home Porter. diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index ab6a0df..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,35 +0,0 @@ -plugins { id 'com.android.application' } - -android { - namespace 'qu.astro.envswitch' - compileSdk 34 - - defaultConfig { - applicationId 'qu.astro.envswitch' - minSdk 26 - targetSdk 34 - versionCode 1 - versionName '1.0' - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } -} - -dependencies { - implementation 'androidx.core:core:1.13.1' - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.recyclerview:recyclerview:1.3.2' - implementation 'androidx.activity:activity:1.9.2' - implementation 'androidx.lifecycle:lifecycle-runtime:2.8.4' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..69e4a70 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,86 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties().apply { + if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use(::load) + } +} + +android { + namespace = "dev.codex.questhomeswitcher" + compileSdk = 35 + + defaultConfig { + applicationId = "qu.astro.envswitch" + minSdk = 29 + targetSdk = 32 + versionCode = 2 + versionName = "1.1.0" + } + + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + storeFile = rootProject.file(requireNotNull(keystoreProperties["storeFile"] as String?)) + storePassword = requireNotNull(keystoreProperties["storePassword"] as String?) + keyAlias = requireNotNull(keystoreProperties["keyAlias"] as String?) + keyPassword = requireNotNull(keystoreProperties["keyPassword"] as String?) + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.findByName("release") + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + lint { + // This app is sideloaded on Quest's Android 12L base and is not distributed via Google Play. + disable += "ExpiredTargetSdkVersion" + // Android Studio can lock migrated lint jars on Windows while generating a signed APK. + checkReleaseBuilds = false + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material.icons) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.shizuku.api) + implementation(libs.shizuku.provider) + + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee3690f..228957f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,16 +1,30 @@ - + + + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.QuestHomeSwitcher"> + + + + android:name=".MainActivity" + android:exported="true" + android:screenOrientation="landscape"> - \ No newline at end of file + diff --git a/app/src/main/java/dev/codex/questhomeswitcher/MainActivity.kt b/app/src/main/java/dev/codex/questhomeswitcher/MainActivity.kt new file mode 100644 index 0000000..cf1c894 --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/MainActivity.kt @@ -0,0 +1,91 @@ +package dev.codex.questhomeswitcher + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import dev.codex.questhomeswitcher.ui.HomeSwitcherApp +import rikka.shizuku.Shizuku + +class MainActivity : ComponentActivity() { + private var shizukuListenerRegistered = false + private val binderReceivedListener = Shizuku.OnBinderReceivedListener { + requestShizukuPermissionWhenReady() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestStoragePermission() + setContent { + HomeSwitcherApp() + } + Thread { + if (!isRootAvailable()) { + runOnUiThread { + Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) + shizukuListenerRegistered = true + openShizukuIfServerIsOffline() + } + } + }.start() + } + + override fun onResume() { + super.onResume() + if (shizukuListenerRegistered) { + window.decorView.postDelayed({ requestShizukuPermissionWhenReady() }, 350L) + } + } + + override fun onDestroy() { + if (shizukuListenerRegistered) { + Shizuku.removeBinderReceivedListener(binderReceivedListener) + } + super.onDestroy() + } + + private fun requestStoragePermission() { + val permission = Manifest.permission.READ_EXTERNAL_STORAGE + if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(permission), 7) + } + } + + private fun requestShizukuPermissionWhenReady() { + if (Shizuku.pingBinder() && Shizuku.checkSelfPermission() != PackageManager.PERMISSION_GRANTED) { + Shizuku.requestPermission(42) + } + } + + private fun isRootAvailable(): Boolean { + return try { + ProcessBuilder("su", "-c", "id").start().waitFor() == 0 + } catch (_: Exception) { + false + } + } + + private fun openShizukuIfServerIsOffline() { + window.decorView.postDelayed( + { + if (!Shizuku.pingBinder()) { + packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE)?.let { intent -> + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } + } else { + requestShizukuPermissionWhenReady() + } + }, + 700L, + ) + } + + companion object { + private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + } +} diff --git a/app/src/main/java/dev/codex/questhomeswitcher/data/HomeRepository.kt b/app/src/main/java/dev/codex/questhomeswitcher/data/HomeRepository.kt new file mode 100644 index 0000000..31f2520 --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/data/HomeRepository.kt @@ -0,0 +1,221 @@ +package dev.codex.questhomeswitcher.data + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Environment +import dev.codex.questhomeswitcher.domain.HomeEnvironment +import dev.codex.questhomeswitcher.domain.HomeEnvironmentType +import dev.codex.questhomeswitcher.domain.QuestHomeContract +import dev.codex.questhomeswitcher.shell.ShellRunner +import java.io.File +import java.text.DecimalFormat + +class HomeRepository(context: Context) { + private val packageManager = context.packageManager + private val previewExtensions = listOf("png", "jpg", "jpeg", "webp") + + fun homesDirectory(): File { + return File(Environment.getExternalStorageDirectory(), QuestHomeContract.HomesFolderName) + } + + fun loadHomes(): List { + return QuestHomeContract.SearchFolders + .map { File(Environment.getExternalStorageDirectory(), it) } + .filter { it.exists() && it.isDirectory } + .flatMap { dir -> dir.walkTopDown().maxDepth(3).filter { it.isFile }.toList() } + .filter { + it.extension.equals("apk", ignoreCase = true) && + (isTrustedHomeFolder(it.absolutePath) || isHomeApk(it.absolutePath)) + } + .distinctBy { it.absolutePath } + ?.sortedBy { it.nameWithoutExtension.lowercase() } + ?.map { apk -> + HomeEnvironment( + displayName = apk.nameWithoutExtension.cleanHomeName(), + apkPath = apk.absolutePath, + previewPath = findPreviewFor(apk)?.absolutePath, + sizeBytes = apk.length(), + lastModifiedMillis = apk.lastModified(), + ) + } + .orEmpty() + } + + suspend fun loadHomesWithShell(shellRunner: ShellRunner): List { + val dirs = QuestHomeContract.SearchFolders.joinToString(" ") { "/sdcard/${it.shellQuote()}" } + val command = """ + for dir in $dirs; do + [ -d "${'$'}dir" ] || continue + find "${'$'}dir" -maxdepth 3 -type f \( -iname '*.apk' \) 2>/dev/null + done | while IFS= read -r f; do + [ -f "${'$'}f" ] || continue + base="${'$'}{f%.*}" + preview="" + for ext in png jpg jpeg webp PNG JPG JPEG WEBP; do + [ -f "${'$'}base.${'$'}ext" ] && preview="${'$'}base.${'$'}ext" && break + done + size=${'$'}(wc -c < "${'$'}f" | tr -d ' ') + mod=${'$'}(stat -c %Y "${'$'}f" 2>/dev/null || echo 0) + home=0 + unzip -l "${'$'}f" 2>/dev/null | grep -qE '[[:space:]]assets/scene\.zip${'$'}' && home=1 + printf '%s\t%s\t%s\t%s\t%s\n' "${'$'}f" "${'$'}size" "${'$'}mod" "${'$'}preview" "${'$'}home" + done + """.trimIndent() + + val result = shellRunner.run(command) + if (!result.success) return emptyList() + + return result.output + .lineSequence() + .mapNotNull { line -> + val parts = line.split('\t') + if (parts.size < 3) return@mapNotNull null + val apkPath = parts[0] + val fileName = apkPath.substringAfterLast('/') + HomeEnvironment( + displayName = fileName.substringBeforeLast('.').cleanHomeName(), + apkPath = apkPath, + previewPath = parts.getOrNull(3)?.takeIf { it.isNotBlank() }, + sizeBytes = parts[1].toLongOrNull() ?: 0L, + lastModifiedMillis = (parts[2].toLongOrNull() ?: 0L) * 1000L, + verifiedHomeApk = parts.getOrNull(4) == "1", + ) + } + .filter { it.verifiedHomeApk || isTrustedHomeFolder(it.apkPath) || isHomeApk(it.apkPath) } + .sortedBy { it.displayName.lowercase() } + .toList() + } + + suspend fun loadInstalledHomes(rootRunner: ShellRunner): List { + val command = """ + for pkg in ${'$'}(pm list packages | sed 's/^package://'); do + case "${'$'}pkg" in + *environment*|*env.vista*|*env.footprint*) ;; + *) continue ;; + esac + path=${'$'}(pm path "${'$'}pkg" 2>/dev/null | sed -n 's/^package://p' | head -n 1) + [ -n "${'$'}path" ] || continue + entry=${'$'}(unzip -Z1 "${'$'}path" 2>/dev/null | grep -Ei '(^|/)scene\.zip${'$'}' | head -n 1) + [ -n "${'$'}entry" ] || entry=assets/scene.zip + printf '%s\t%s\t%s\n' "${'$'}pkg" "${'$'}path" "${'$'}entry" + done + """.trimIndent() + val result = rootRunner.run(command) + if (!result.success) return emptyList() + return result.output.lineSequence().mapNotNull { line -> + val parts = line.split('\t') + if (parts.size < 3) return@mapNotNull null + val pkg = parts[0] + HomeEnvironment( + displayName = pkg.substringAfterLast('.').cleanHomeName(), + apkPath = parts[1], + previewPath = null, + sizeBytes = 0L, + lastModifiedMillis = 0L, + packageName = pkg, + sceneUri = "apk://$pkg/${parts[2]}", + installed = true, + type = when { + ".env.vista." in pkg -> HomeEnvironmentType.VISTA + ".env.footprint." in pkg -> HomeEnvironmentType.FOOTPRINT + else -> HomeEnvironmentType.ENVIRONMENT + }, + ) + }.distinctBy { it.packageName }.sortedBy { it.displayName.lowercase() }.toList() + } + + suspend fun findActiveHomeWithShell( + shellRunner: ShellRunner, + homes: List, + ): HomeEnvironment? { + if (homes.isEmpty()) return null + + val candidates = homes.filter { !it.installed }.joinToString(" ") { it.apkPath.shellQuote() } + val command = """ + installed=${'$'}(pm path --user 0 ${QuestHomeContract.TargetPackage} 2>/dev/null | sed -n 's/^package://p' | head -n 1) + [ -n "${'$'}installed" ] && [ -r "${'$'}installed" ] || exit 1 + installed_hash=${'$'}(sha256sum "${'$'}installed" | cut -d ' ' -f 1) + for f in $candidates; do + [ -f "${'$'}f" ] || continue + candidate_hash=${'$'}(sha256sum "${'$'}f" | cut -d ' ' -f 1) + if [ "${'$'}installed_hash" = "${'$'}candidate_hash" ]; then + printf '%s\n' "${'$'}f" + exit 0 + fi + done + exit 1 + """.trimIndent() + + val result = shellRunner.run(command) + if (!result.success) return null + val activePath = result.output.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: return null + return homes.firstOrNull { it.apkPath == activePath } + } + + suspend fun findActiveInstalledHome( + rootRunner: ShellRunner, + homes: List, + ): HomeEnvironment? { + val result = rootRunner.run( + "oculuspreferences --getc environment_selected environment_vista_selected default_footprint", + ) + if (!result.success) return null + val uris = Regex("apk://[^\\\"\\s]+", RegexOption.IGNORE_CASE) + .findAll(result.output) + .map { it.value } + .toSet() + return homes.firstOrNull { it.type == HomeEnvironmentType.ENVIRONMENT && it.sceneUri in uris } + ?: homes.firstOrNull { it.sceneUri in uris } + } + + fun formatSize(bytes: Long): String { + val mb = bytes / 1024.0 / 1024.0 + return "${DecimalFormat("0.0").format(mb)} MB" + } + + private fun findPreviewFor(apk: File): File? { + val base = File(apk.parentFile, apk.nameWithoutExtension) + return previewExtensions + .map { File(base.parentFile, "${base.name}.$it") } + .firstOrNull { it.exists() && it.isFile } + } + + @Suppress("DEPRECATION") + private fun isHomeApk(path: String): Boolean { + val info = packageManager.getPackageArchiveInfo(path, 0) + ?: return false + return isHomePackage(info.packageName) + } + + private fun isTrustedHomeFolder(path: String): Boolean { + val normalized = path.replace('\\', '/').lowercase() + return normalized.contains("/quest homes/") || + normalized.contains("/questhomes/") || + normalized.contains("/homes/") + } + + @Suppress("DEPRECATION") + private fun isHomePackage(packageName: String): Boolean { + val name = packageName.lowercase() + return name == QuestHomeContract.TargetPackage || + ".environment." in name || + name.startsWith("com.environment.") || + name.startsWith("com.oculus.environment.") || + name.startsWith("com.meta.environment.") || + ".shell.env." in name || + ".env.vista." in name || + ".env.footprint." in name + } + + private fun String.cleanHomeName(): String { + return replace('_', ' ') + .replace('-', ' ') + .split(' ') + .filter { it.isNotBlank() } + .joinToString(" ") { word -> word.replaceFirstChar { it.titlecase() } } + } + + private fun String.shellQuote(): String { + return "'" + replace("'", "'\\''") + "'" + } +} diff --git a/app/src/main/java/dev/codex/questhomeswitcher/domain/ActivateHomeUseCase.kt b/app/src/main/java/dev/codex/questhomeswitcher/domain/ActivateHomeUseCase.kt new file mode 100644 index 0000000..e58b4f4 --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/domain/ActivateHomeUseCase.kt @@ -0,0 +1,96 @@ +package dev.codex.questhomeswitcher.domain + +import dev.codex.questhomeswitcher.shell.ShellRunner + +class ActivateHomeUseCase( + private val shellRunner: ShellRunner, + private val rootRunner: ShellRunner, +) { + suspend operator fun invoke(home: HomeEnvironment): ActivationResult { + if (home.installed && home.sceneUri != null && rootRunner.isReady()) { + return activateWithRoot(home) + } + val packageRunner = when { + rootRunner.isReady() -> rootRunner + shellRunner.isReady() -> shellRunner + else -> return ActivationResult(false, false, "Neither root nor Shizuku is ready.") + } + val log = StringBuilder() + + fun appendStep(title: String, output: String) { + log.appendLine("== $title ==") + if (output.isBlank()) { + log.appendLine("(no output)") + } else { + log.appendLine(output.trim()) + } + log.appendLine() + } + + val escapedApkPath = home.apkPath.shellQuote() + val uninstall = packageRunner.run("pm uninstall --user 0 ${QuestHomeContract.TargetPackage}") + appendStep("Uninstall old ${QuestHomeContract.TargetPackage}", uninstall.output) + + val install = packageRunner.run("cat $escapedApkPath | pm install -S ${home.sizeBytes} -r -d -g --user 0") + appendStep("Install ${home.displayName}", install.output) + if (!install.success) { + return ActivationResult( + success = false, + needsReboot = false, + log = log.appendLine("Install failed.").toString(), + ) + } + + val reload = tryReloadHorizon(packageRunner) + appendStep("Try Horizon OS reload", reload.output) + + return ActivationResult( + success = true, + needsReboot = true, + log = log.toString(), + ) + } + + private suspend fun activateWithRoot(home: HomeEnvironment): ActivationResult { + val uri = requireNotNull(home.sceneUri).shellQuote() + val preferenceCommands = when (home.type) { + HomeEnvironmentType.ENVIRONMENT -> """ + oculuspreferences --setc environment_selected $uri || exit 10 + oculuspreferences --setc environment_default $uri || exit 11 + oculuspreferences --setc resolved_environment $uri || exit 12 + """.trimIndent() + HomeEnvironmentType.VISTA -> """ + oculuspreferences --setc default_vista $uri || exit 20 + oculuspreferences --setc resolved_vista $uri || exit 21 + oculuspreferences --setc environment_vista_selected $uri || exit 22 + """.trimIndent() + HomeEnvironmentType.FOOTPRINT -> + "oculuspreferences --setc default_footprint $uri || exit 30" + } + val command = """ + command -v oculuspreferences >/dev/null 2>&1 || exit 127 + $preferenceCommands + am force-stop com.oculus.vrshell + """.trimIndent() + val result = rootRunner.run(command) + return ActivationResult( + success = result.success, + needsReboot = false, + log = if (result.success) "Root mode: preferences updated and VR Shell reloaded." else result.output, + ) + } + + private suspend fun tryReloadHorizon(runner: ShellRunner): dev.codex.questhomeswitcher.shell.ShellResult { + val command = """ + am force-stop com.oculus.vrshell >/dev/null 2>&1 + am force-stop com.oculus.shellenv >/dev/null 2>&1 + cmd activity broadcast -a android.intent.action.PACKAGE_CHANGED -d package:${QuestHomeContract.TargetPackage} >/dev/null 2>&1 + """.trimIndent() + + return runner.run(command) + } + + private fun String.shellQuote(): String { + return "'" + replace("'", "'\\''") + "'" + } +} diff --git a/app/src/main/java/dev/codex/questhomeswitcher/domain/HomeEnvironment.kt b/app/src/main/java/dev/codex/questhomeswitcher/domain/HomeEnvironment.kt new file mode 100644 index 0000000..ce0dca5 --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/domain/HomeEnvironment.kt @@ -0,0 +1,32 @@ +package dev.codex.questhomeswitcher.domain + +data class HomeEnvironment( + val displayName: String, + val apkPath: String, + val previewPath: String?, + val sizeBytes: Long, + val lastModifiedMillis: Long, + val packageName: String? = null, + val sceneUri: String? = null, + val installed: Boolean = false, + val type: HomeEnvironmentType = HomeEnvironmentType.ENVIRONMENT, + val verifiedHomeApk: Boolean = false, +) + +enum class HomeEnvironmentType { + ENVIRONMENT, + VISTA, + FOOTPRINT, +} + +data class ActivationResult( + val success: Boolean, + val needsReboot: Boolean, + val log: String, +) + +object QuestHomeContract { + const val TargetPackage = "com.meta.shell.env.footprint.haven2025" + const val HomesFolderName = "Quest Homes" + val SearchFolders = listOf("Quest Homes", "QuestHomes", "Homes", "Download") +} diff --git a/app/src/main/java/dev/codex/questhomeswitcher/shell/RootShellRunner.kt b/app/src/main/java/dev/codex/questhomeswitcher/shell/RootShellRunner.kt new file mode 100644 index 0000000..1169ea6 --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/shell/RootShellRunner.kt @@ -0,0 +1,33 @@ +package dev.codex.questhomeswitcher.shell + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.InputStreamReader + +class RootShellRunner : ShellRunner { + override suspend fun isReady(): Boolean = withContext(Dispatchers.IO) { + runDirect("id").success + } + + override suspend fun requestPermissionIfNeeded(requestCode: Int) { + // Running su triggers the Magisk prompt when approval is still missing. + isReady() + } + + override suspend fun run(command: String): ShellResult = withContext(Dispatchers.IO) { + runDirect(command) + } + + private fun runDirect(command: String): ShellResult { + return try { + val process = ProcessBuilder("su", "-c", command).start() + val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText() + val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText() + val exit = process.waitFor() + ShellResult(exit, listOf(stdout, stderr).filter { it.isNotBlank() }.joinToString("\n")) + } catch (error: Exception) { + ShellResult(-1, "Root unavailable: ${error.message}") + } + } +} diff --git a/app/src/main/java/dev/codex/questhomeswitcher/shell/ShellRunner.kt b/app/src/main/java/dev/codex/questhomeswitcher/shell/ShellRunner.kt new file mode 100644 index 0000000..9c5fbfb --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/shell/ShellRunner.kt @@ -0,0 +1,14 @@ +package dev.codex.questhomeswitcher.shell + +interface ShellRunner { + suspend fun isReady(): Boolean + suspend fun requestPermissionIfNeeded(requestCode: Int) + suspend fun run(command: String): ShellResult +} + +data class ShellResult( + val exitCode: Int, + val output: String, +) { + val success: Boolean get() = exitCode == 0 +} diff --git a/app/src/main/java/dev/codex/questhomeswitcher/shell/ShizukuShellRunner.kt b/app/src/main/java/dev/codex/questhomeswitcher/shell/ShizukuShellRunner.kt new file mode 100644 index 0000000..850873f --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/shell/ShizukuShellRunner.kt @@ -0,0 +1,42 @@ +package dev.codex.questhomeswitcher.shell + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import rikka.shizuku.Shizuku +import java.io.BufferedReader +import java.io.InputStreamReader + +class ShizukuShellRunner : ShellRunner { + override suspend fun isReady(): Boolean { + return Shizuku.pingBinder() && Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED + } + + override suspend fun requestPermissionIfNeeded(requestCode: Int) { + if (Shizuku.pingBinder() && Shizuku.checkSelfPermission() != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Shizuku.requestPermission(requestCode) + } + } + + override suspend fun run(command: String): ShellResult = withContext(Dispatchers.IO) { + if (!isReady()) { + return@withContext ShellResult(1, "Shizuku is not ready or permission is missing.") + } + + val process = createShizukuProcess(command) + val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText() + val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText() + val exit = process.waitFor() + ShellResult(exit, listOf(stdout, stderr).filter { it.isNotBlank() }.joinToString("\n")) + } + + private fun createShizukuProcess(command: String): Process { + val method = Shizuku::class.java.getDeclaredMethod( + "newProcess", + Array::class.java, + Array::class.java, + String::class.java, + ) + method.isAccessible = true + return method.invoke(null, arrayOf("sh", "-c", command), null, null) as Process + } +} diff --git a/app/src/main/java/dev/codex/questhomeswitcher/ui/HomeSwitcherApp.kt b/app/src/main/java/dev/codex/questhomeswitcher/ui/HomeSwitcherApp.kt new file mode 100644 index 0000000..27038f5 --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/ui/HomeSwitcherApp.kt @@ -0,0 +1,903 @@ +package dev.codex.questhomeswitcher.ui + +import android.graphics.BitmapFactory +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Security +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Warning +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.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.codex.questhomeswitcher.R +import dev.codex.questhomeswitcher.domain.HomeEnvironment + +private val Mint = Color(0xFF5EE0B8) +private val Cyan = Color(0xFF61D9FF) +private val Amber = Color(0xFFF2C94C) +private val Violet = Color(0xFF8792FF) +private val Ink = Color(0xFF070A10) +private val VoidPanel = Color(0xFF0E141D) +private val Glass = Color(0xCC121A25) +private val GlassRaised = Color(0xFF202A39) +private val TextSoft = Color(0xFFB8C2D2) + +private val AppColors = darkColorScheme( + primary = Mint, + secondary = Amber, + tertiary = Violet, + surface = VoidPanel, + background = Ink, + surfaceVariant = GlassRaised, +) + +@Composable +fun HomeSwitcherApp( + onActivationStarted: () -> Unit = {}, + viewModel: HomeSwitcherViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + MaterialTheme(colorScheme = AppColors) { + SpatialHomeSwitcherScreen( + state = state, + formatSize = viewModel::formatSize, + onRefresh = viewModel::refresh, + onRequestShizuku = viewModel::requestShizukuPermission, + onSelect = viewModel::select, + onActivate = { + viewModel.activateSelected() + onActivationStarted() + }, + onRestart = viewModel::restartQuest, + ) + } +} + +@Composable +private fun SpatialHomeSwitcherScreen( + state: HomeSwitcherUiState, + formatSize: (Long) -> String, + onRefresh: () -> Unit, + onRequestShizuku: () -> Unit, + onSelect: (HomeEnvironment) -> Unit, + onActivate: () -> Unit, + onRestart: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf( + Color(0xFF070A10), + Color(0xFF101926), + Color(0xFF161128), + ), + ), + ), + ) { + SpatialBackdrop() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 28.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + SpatialHeader( + state = state, + onRefresh = onRefresh, + onRequestShizuku = onRequestShizuku, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + CommandRail( + state = state, + onRequestShizuku = onRequestShizuku, + onRefresh = onRefresh, + modifier = Modifier + .width(86.dp) + .fillMaxHeight(), + ) + + EnvironmentDeck( + homes = state.homes, + selected = state.selected, + activeHome = state.activeHome, + formatSize = formatSize, + onSelect = onSelect, + modifier = Modifier + .weight(0.42f) + .fillMaxHeight(), + ) + + HologramStage( + state = state, + onActivate = onActivate, + onRestart = onRestart, + modifier = Modifier + .weight(0.58f) + .fillMaxHeight(), + ) + } + } + } +} + +@Composable +private fun SpatialBackdrop() { + Canvas(Modifier.fillMaxSize()) { + val gridColor = Color(0x1A5EE0B8) + val horizonY = size.height * 0.72f + var x = -size.width + while (x < size.width * 2f) { + drawLine( + color = gridColor, + start = Offset(x, size.height), + end = Offset(size.width * 0.5f, horizonY), + strokeWidth = 1.2f, + ) + x += 96f + } + var y = horizonY + var step = 32f + while (y < size.height) { + drawLine( + color = gridColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1f, + ) + y += step + step *= 1.18f + } + drawLine( + color = Color(0x335EE0B8), + start = Offset(0f, horizonY), + end = Offset(size.width, horizonY), + strokeWidth = 2f, + ) + } +} + +@Composable +private fun SpatialHeader( + state: HomeSwitcherUiState, + onRefresh: () -> Unit, + onRequestShizuku: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(74.dp) + .shadow(18.dp, RoundedCornerShape(8.dp), clip = false) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xE60B1018)) + .border(1.dp, Color(0x335EE0B8), RoundedCornerShape(8.dp)) + .padding(horizontal = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppMark() + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + "Quest Environment Picker", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Black, + color = Color.White, + ) + Text( + "Spatial environment console", + color = TextSoft, + style = MaterialTheme.typography.labelLarge, + ) + } + MetricChip("${state.homes.size}", "homes") + Spacer(Modifier.width(10.dp)) + StatusPill( + label = when { + state.rootReady -> "Root direct" + state.shizukuReady -> "Shizuku online" + else -> "Shizuku offline" + }, + positive = state.rootReady || state.shizukuReady, + onClick = onRequestShizuku, + ) + Spacer(Modifier.width(10.dp)) + IconButtonFrame(onClick = onRefresh) { + Icon(Icons.Rounded.Refresh, contentDescription = null, tint = Mint) + } + } +} + +@Composable +private fun AppMark() { + Box( + modifier = Modifier + .size(48.dp) + .shadow(12.dp, RoundedCornerShape(8.dp), clip = false) + .clip(RoundedCornerShape(8.dp)) + .background( + Brush.linearGradient( + listOf(Color(0xFF213041), Color(0xFF0D121A)), + ), + ) + .border(1.dp, Color(0x665EE0B8), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(29.dp) + .graphicsLayer { + rotationZ = -8f + shadowElevation = 10f + } + .clip(RoundedCornerShape(4.dp)) + .background(Mint), + ) + Box( + modifier = Modifier + .size(19.dp) + .align(Alignment.BottomCenter) + .padding(bottom = 7.dp) + .clip(RoundedCornerShape(3.dp)) + .background(Color(0xFF07100D)), + ) + Icon(Icons.Rounded.Home, contentDescription = null, tint = Color.White, modifier = Modifier.size(25.dp)) + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(7.dp) + .size(8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Amber), + ) + } +} + +@Composable +private fun MetricChip(value: String, label: String) { + Column( + modifier = Modifier + .height(46.dp) + .width(72.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF171F2B)) + .border(1.dp, Color(0x334B5B72), RoundedCornerShape(8.dp)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(value, color = Color.White, fontWeight = FontWeight.Black) + Text(label, color = TextSoft, style = MaterialTheme.typography.labelSmall) + } +} + +@Composable +private fun StatusPill(label: String, positive: Boolean, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, if (positive) Mint else Amber), + contentPadding = PaddingValues(horizontal = 14.dp), + ) { + Icon( + imageVector = if (positive) Icons.Rounded.Security else Icons.Rounded.Warning, + contentDescription = null, + tint = if (positive) Mint else Amber, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(label, fontWeight = FontWeight.SemiBold, color = if (positive) Mint else Amber) + } +} + +@Composable +private fun IconButtonFrame(onClick: () -> Unit, content: @Composable () -> Unit) { + Box( + modifier = Modifier + .size(46.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF141D28)) + .border(1.dp, Color(0x665EE0B8), RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + content() + } +} + +@Composable +private fun CommandRail( + state: HomeSwitcherUiState, + onRequestShizuku: () -> Unit, + onRefresh: () -> Unit, + modifier: Modifier, +) { + Column( + modifier = modifier + .graphicsLayer { + rotationY = 5f + cameraDistance = 18f * density + } + .shadow(20.dp, RoundedCornerShape(8.dp), clip = false) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xE60B1018)) + .border(1.dp, Color(0x244B5B72), RoundedCornerShape(8.dp)) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + RailButton(active = true, onClick = {}) { + Icon(Icons.Rounded.Home, contentDescription = null, tint = Mint) + } + RailButton(active = state.shizukuReady, onClick = onRequestShizuku) { + Icon( + imageVector = if (state.shizukuReady) Icons.Rounded.Security else Icons.Rounded.Warning, + contentDescription = null, + tint = if (state.shizukuReady) Mint else Amber, + ) + } + RailButton(active = false, onClick = onRefresh) { + Icon(Icons.Rounded.Refresh, contentDescription = null, tint = Cyan) + } + } +} + +@Composable +private fun RailButton(active: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) { + Box( + modifier = Modifier + .size(52.dp) + .shadow(if (active) 12.dp else 4.dp, RoundedCornerShape(8.dp), clip = false) + .clip(RoundedCornerShape(8.dp)) + .background(if (active) Color(0xFF1E3440) else Color(0xFF151D28)) + .border(1.dp, if (active) Mint else Color(0x294B5B72), RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + content() + } +} + +@Composable +private fun EnvironmentDeck( + homes: List, + selected: HomeEnvironment?, + activeHome: HomeEnvironment?, + formatSize: (Long) -> String, + onSelect: (HomeEnvironment) -> Unit, + modifier: Modifier, +) { + var searchQuery by remember { mutableStateOf("") } + val visibleHomes = remember(homes, searchQuery) { + val query = searchQuery.trim() + if (query.isEmpty()) homes else homes.filter { home -> + home.displayName.contains(query, ignoreCase = true) || + home.packageName?.contains(query, ignoreCase = true) == true + } + } + Column( + modifier = modifier + .graphicsLayer { + rotationY = -4f + cameraDistance = 18f * density + } + .shadow(28.dp, RoundedCornerShape(8.dp), clip = false) + .clip(RoundedCornerShape(8.dp)) + .background( + Brush.linearGradient( + listOf( + Color(0xE61A2735), + Color(0xE60D151F), + Color(0xF2070B11), + ), + ), + ) + .border(1.dp, Color(0x5568DCC2), RoundedCornerShape(8.dp)) + .padding(16.dp), + ) { + Text("Environment Deck", color = Color.White, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black) + Text("Select a spatial shell", color = TextSoft, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Search homes") }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + ) + Spacer(Modifier.height(8.dp)) + + if (visibleHomes.isEmpty()) { + EmptyState() + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp), + ) { + items(visibleHomes, key = { it.apkPath }) { home -> + EnvironmentTile( + home = home, + selected = home.apkPath == selected?.apkPath, + active = home.apkPath == activeHome?.apkPath, + size = formatSize(home.sizeBytes), + onClick = { onSelect(home) }, + ) + } + } + } + } +} + +@Composable +private fun EnvironmentTile(home: HomeEnvironment, selected: Boolean, active: Boolean, size: String, onClick: () -> Unit) { + val edge = if (selected) Mint else Color(0x334B5B72) + val glass = if (selected) { + Brush.linearGradient( + listOf(Color(0xEE3B536A), Color(0xE6243448), Color(0xF00E1722)), + ) + } else { + Brush.linearGradient( + listOf(Color(0xD936465B), Color(0xE6202A39), Color(0xF0101721)), + ) + } + Card( + modifier = Modifier + .fillMaxWidth() + .height(if (selected) 120.dp else 108.dp) + .graphicsLayer { + scaleX = 1f + scaleY = 1f + rotationX = if (selected) 0f else 0.8f + rotationY = if (selected) 0f else -2.4f + cameraDistance = 18f * density + shadowElevation = if (selected) 22f else 9f + } + .border(1.dp, edge, RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + ) { + Box( + Modifier + .fillMaxSize() + .background(glass), + ) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(2.dp) + .background( + Brush.horizontalGradient( + listOf(Color.Transparent, Color(0xB3E8FFFF), Color.Transparent), + ), + ), + ) + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(if (selected) 18.dp else 10.dp) + .background( + Brush.horizontalGradient( + listOf(Color.Transparent, if (selected) Color(0x775EE0B8) else Color(0x222A94A4)), + ), + ), + ) + Row( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PreviewBox( + home.displayName, + home.previewPath, + Modifier + .size(if (selected) 92.dp else 82.dp) + .shadow(if (selected) 14.dp else 7.dp, RoundedCornerShape(8.dp)), + ) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text( + home.displayName, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Black, + color = Color.White, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = if (active) "ACTIVE • $size" else size, + color = if (active) Mint else TextSoft, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (active) FontWeight.Bold else FontWeight.Normal, + ) + } + if (active) { + Icon(Icons.Rounded.CheckCircle, contentDescription = null, tint = Mint, modifier = Modifier.size(28.dp)) + } + } + } + } +} + +@Composable +private fun HologramStage( + state: HomeSwitcherUiState, + onActivate: () -> Unit, + onRestart: () -> Unit, + modifier: Modifier, +) { + Column( + modifier = modifier + .graphicsLayer { + rotationY = 3f + rotationX = 1.5f + cameraDistance = 18f * density + } + .shadow(34.dp, RoundedCornerShape(8.dp), clip = false) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xE60A0E15)) + .border(1.dp, Color(0x335EE0B8), RoundedCornerShape(8.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + StagePreview( + home = state.selected, + active = state.selected?.apkPath == state.activeHome?.apkPath, + modifier = Modifier.weight(1f), + ) + SpatialActionBar(state, onActivate, onRestart) + + if (state.log.isNotBlank()) { + Text( + text = state.log, + modifier = Modifier + .fillMaxWidth() + .height(112.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xEE05080D)) + .border(1.dp, Color(0x224B5B72), RoundedCornerShape(8.dp)) + .padding(12.dp), + color = Color(0xFFE2E6EF), + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun StagePreview(home: HomeEnvironment?, active: Boolean, modifier: Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF080B10)) + .border(1.dp, Color(0x294B5B72), RoundedCornerShape(8.dp)), + ) { + if (home == null) { + EmptyState() + } else { + PreviewBox(home.displayName, home.previewPath, Modifier.fillMaxSize()) + Canvas(Modifier.fillMaxSize()) { + drawLine( + color = Color(0x885EE0B8), + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 3f, + ) + drawLine( + color = Color(0x445EE0B8), + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 2f, + ) + drawLine( + color = Color(0x335EE0B8), + start = Offset(0f, 0f), + end = Offset(size.width * 0.18f, size.height), + strokeWidth = 1.5f, + ) + drawLine( + color = Color(0x337B8DFF), + start = Offset(size.width, 0f), + end = Offset(size.width * 0.82f, size.height), + strokeWidth = 1.5f, + ) + } + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + Color(0x00000000), + Color(0x22000000), + Color(0xEE030609), + ), + ), + ), + ) + Column( + Modifier + .align(Alignment.BottomStart) + .padding(28.dp), + ) { + Text( + text = if (active) "ACTIVE ENVIRONMENT" else "SELECTED TARGET", + color = if (active) Mint else Cyan, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Black, + ) + Spacer(Modifier.height(8.dp)) + Text( + home.displayName, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Black, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + Text( + home.apkPath, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color(0xFFD2D7DF), + ) + } + } + } +} + +@Composable +private fun SpatialActionBar( + state: HomeSwitcherUiState, + onActivate: () -> Unit, + onRestart: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(94.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xCC111923)) + .border(1.dp, Color(0x224B5B72), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 9.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = onActivate, + enabled = state.selected != null && (state.rootReady || state.shizukuReady) && !state.isBusy, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Mint, + contentColor = Color(0xFF07100D), + disabledContainerColor = Color(0xFF38414C), + disabledContentColor = Color(0xFF8B96A6), + ), + contentPadding = PaddingValues(horizontal = 18.dp), + ) { + if (state.isBusy) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = Color(0xFF07100D)) + } else { + Icon(Icons.Rounded.PlayArrow, contentDescription = null) + } + Spacer(Modifier.width(8.dp)) + Text("Activate", fontWeight = FontWeight.Black) + } + + if (state.showRestartAction) { + OutlinedButton( + onClick = onRestart, + enabled = state.shizukuReady && !state.isBusy, + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, Amber), + ) { + Icon(Icons.Rounded.Refresh, contentDescription = null, tint = Amber) + Spacer(Modifier.width(8.dp)) + Text("Restart", color = Amber, fontWeight = FontWeight.SemiBold) + } + } + + Spacer(Modifier.weight(1f)) + Icon( + imageVector = if (state.rootReady || state.shizukuReady) Icons.Rounded.Security else Icons.Rounded.Warning, + contentDescription = null, + tint = if (state.rootReady || state.shizukuReady) Mint else Amber, + modifier = Modifier.size(24.dp), + ) + } + + Text( + state.message, + color = Color(0xFFD2D7DF), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun PreviewBox(name: String, path: String?, modifier: Modifier) { + val bitmap = remember(path) { + path?.let { BitmapFactory.decodeFile(it)?.asImageBitmap() } + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background( + Brush.linearGradient( + listOf( + Color(0xFF1B2634), + Color(0xFF080C12), + ), + ), + ) + .border(1.dp, Color(0x224B5B72), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + if (bitmap != null) { + Image(bitmap = bitmap, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } else { + ThemedHomeArtwork(name) + } + } +} + +@Composable +private fun ThemedHomeArtwork(name: String) { + val key = name.lowercase() + Canvas(Modifier.fillMaxSize()) { + when { + key.contains("space") || key.contains("station") -> { + drawRect( + brush = Brush.verticalGradient(listOf(Color(0xFF050A18), Color(0xFF12294A))), + size = size, + ) + drawCircle(Color(0xFF9EDBFF), radius = size.minDimension * 0.14f, center = Offset(size.width * 0.76f, size.height * 0.25f)) + drawCircle(Color(0x66FFFFFF), radius = size.minDimension * 0.035f, center = Offset(size.width * 0.22f, size.height * 0.22f)) + drawCircle(Color(0x88FFFFFF), radius = size.minDimension * 0.025f, center = Offset(size.width * 0.45f, size.height * 0.14f)) + drawCircle(Color(0x77FFFFFF), radius = size.minDimension * 0.02f, center = Offset(size.width * 0.62f, size.height * 0.48f)) + drawRect(Color(0xFFE7F7FF), topLeft = Offset(size.width * 0.28f, size.height * 0.55f), size = Size(size.width * 0.44f, size.height * 0.08f)) + drawRect(Color(0xFF5EE0B8), topLeft = Offset(size.width * 0.46f, size.height * 0.38f), size = Size(size.width * 0.08f, size.height * 0.34f)) + drawCircle(Color(0xFF0A0E14), radius = size.minDimension * 0.09f, center = Offset(size.width * 0.5f, size.height * 0.55f)) + drawLine(Color(0xAA61D9FF), Offset(size.width * 0.15f, size.height * 0.78f), Offset(size.width * 0.85f, size.height * 0.78f), strokeWidth = size.minDimension * 0.025f) + } + key.contains("polar") || key.contains("village") -> { + drawRect( + brush = Brush.verticalGradient(listOf(Color(0xFF18305A), Color(0xFF77C7D8), Color(0xFFEAF9FF))), + size = size, + ) + drawLine(Color(0xAA5EE0B8), Offset(0f, size.height * 0.2f), Offset(size.width, size.height * 0.35f), strokeWidth = size.minDimension * 0.06f) + drawLine(Color(0x887B8DFF), Offset(0f, size.height * 0.3f), Offset(size.width, size.height * 0.18f), strokeWidth = size.minDimension * 0.045f) + drawCircle(Color(0xEEFFFFFF), radius = size.minDimension * 0.42f, center = Offset(size.width * 0.2f, size.height * 1.05f)) + drawCircle(Color(0xFFE4F5FF), radius = size.minDimension * 0.5f, center = Offset(size.width * 0.8f, size.height * 1.0f)) + drawRect(Color(0xFF4B2F26), topLeft = Offset(size.width * 0.34f, size.height * 0.55f), size = Size(size.width * 0.28f, size.height * 0.22f)) + drawRect(Color(0xFFF2C94C), topLeft = Offset(size.width * 0.44f, size.height * 0.62f), size = Size(size.width * 0.08f, size.height * 0.08f)) + drawLine(Color.White, Offset(size.width * 0.30f, size.height * 0.56f), Offset(size.width * 0.48f, size.height * 0.42f), strokeWidth = size.minDimension * 0.06f) + drawLine(Color.White, Offset(size.width * 0.66f, size.height * 0.56f), Offset(size.width * 0.48f, size.height * 0.42f), strokeWidth = size.minDimension * 0.06f) + } + key.contains("winter") || key.contains("loft") -> { + drawRect( + brush = Brush.verticalGradient(listOf(Color(0xFF101B36), Color(0xFF415B7D), Color(0xFFD8EBF6))), + size = size, + ) + drawCircle(Color(0x55FFFFFF), radius = size.minDimension * 0.18f, center = Offset(size.width * 0.78f, size.height * 0.22f)) + drawCircle(Color(0xFFEAF7FF), radius = size.minDimension * 0.42f, center = Offset(size.width * 0.18f, size.height * 1.0f)) + drawCircle(Color(0xFFFFFFFF), radius = size.minDimension * 0.5f, center = Offset(size.width * 0.78f, size.height * 1.02f)) + drawRect(Color(0xFF3B261F), topLeft = Offset(size.width * 0.28f, size.height * 0.5f), size = Size(size.width * 0.42f, size.height * 0.26f)) + drawLine(Color(0xFFE6F4FF), Offset(size.width * 0.22f, size.height * 0.51f), Offset(size.width * 0.49f, size.height * 0.32f), strokeWidth = size.minDimension * 0.07f) + drawLine(Color(0xFFE6F4FF), Offset(size.width * 0.76f, size.height * 0.51f), Offset(size.width * 0.49f, size.height * 0.32f), strokeWidth = size.minDimension * 0.07f) + drawRect(Color(0xFFFFD36B), topLeft = Offset(size.width * 0.42f, size.height * 0.6f), size = Size(size.width * 0.12f, size.height * 0.12f)) + drawLine(Color(0xFF5EE0B8), Offset(size.width * 0.16f, size.height * 0.82f), Offset(size.width * 0.84f, size.height * 0.82f), strokeWidth = size.minDimension * 0.025f) + } + key.contains("dome") || key.contains("environment") -> { + drawRect( + brush = Brush.verticalGradient(listOf(Color(0xFF10152A), Color(0xFF143645), Color(0xFF091014))), + size = size, + ) + val left = Offset(size.width * 0.16f, size.height * 0.72f) + val top = Offset(size.width * 0.5f, size.height * 0.26f) + val right = Offset(size.width * 0.84f, size.height * 0.72f) + drawLine(Color(0xCC5EE0B8), left, top, strokeWidth = size.minDimension * 0.025f) + drawLine(Color(0xCC5EE0B8), top, right, strokeWidth = size.minDimension * 0.025f) + drawLine(Color(0x885EE0B8), Offset(size.width * 0.28f, size.height * 0.72f), Offset(size.width * 0.5f, size.height * 0.26f), strokeWidth = size.minDimension * 0.015f) + drawLine(Color(0x885EE0B8), Offset(size.width * 0.72f, size.height * 0.72f), Offset(size.width * 0.5f, size.height * 0.26f), strokeWidth = size.minDimension * 0.015f) + drawLine(Color(0x665EE0B8), Offset(size.width * 0.16f, size.height * 0.72f), Offset(size.width * 0.84f, size.height * 0.72f), strokeWidth = size.minDimension * 0.018f) + drawCircle(Color(0xAAF2C94C), radius = size.minDimension * 0.09f, center = Offset(size.width * 0.5f, size.height * 0.58f)) + drawLine(Color(0x777B8DFF), Offset(0f, size.height * 0.24f), Offset(size.width, size.height * 0.14f), strokeWidth = size.minDimension * 0.04f) + } + else -> { + drawRect( + brush = Brush.linearGradient(listOf(Color(0xFF1B2634), Color(0xFF080C12))), + size = size, + ) + drawCircle(Color(0x335EE0B8), radius = size.minDimension * 0.34f, center = Offset(size.width * 0.7f, size.height * 0.25f)) + } + } + } +} + +@Composable +private fun EmptyState() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Rounded.Folder, contentDescription = null, tint = Color(0xFF7E8797), modifier = Modifier.size(54.dp)) + Spacer(Modifier.height(10.dp)) + Text("No home APKs found", color = Color(0xFFD2D7DF)) + } + } +} diff --git a/app/src/main/java/dev/codex/questhomeswitcher/ui/HomeSwitcherViewModel.kt b/app/src/main/java/dev/codex/questhomeswitcher/ui/HomeSwitcherViewModel.kt new file mode 100644 index 0000000..b6c82a3 --- /dev/null +++ b/app/src/main/java/dev/codex/questhomeswitcher/ui/HomeSwitcherViewModel.kt @@ -0,0 +1,137 @@ +package dev.codex.questhomeswitcher.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dev.codex.questhomeswitcher.data.HomeRepository +import dev.codex.questhomeswitcher.domain.ActivateHomeUseCase +import dev.codex.questhomeswitcher.domain.HomeEnvironment +import dev.codex.questhomeswitcher.domain.QuestHomeContract +import dev.codex.questhomeswitcher.shell.ShizukuShellRunner +import dev.codex.questhomeswitcher.shell.RootShellRunner +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class HomeSwitcherUiState( + val homes: List = emptyList(), + val selected: HomeEnvironment? = null, + val activeHome: HomeEnvironment? = null, + val isBusy: Boolean = false, + val shizukuReady: Boolean = false, + val rootReady: Boolean = false, + val showRestartAction: Boolean = false, + val message: String = "", + val log: String = "", +) + +class HomeSwitcherViewModel(application: Application) : AndroidViewModel(application) { + private val repository = HomeRepository(application) + private val shellRunner = ShizukuShellRunner() + private val rootRunner = RootShellRunner() + private val activateHome = ActivateHomeUseCase(shellRunner, rootRunner) + + private val _uiState = MutableStateFlow(HomeSwitcherUiState()) + val uiState: StateFlow = _uiState + + init { + preparePrivilegesOnLaunch() + } + + private fun preparePrivilegesOnLaunch() { + viewModelScope.launch { + if (!rootRunner.isReady()) { + shellRunner.requestPermissionIfNeeded(42) + } + refresh() + } + } + + fun refresh() { + viewModelScope.launch { + val ready = shellRunner.isReady() + val rootReady = rootRunner.isReady() + val fileHomes = if (ready) repository.loadHomesWithShell(shellRunner) else repository.loadHomes() + val homes = if (rootReady) repository.loadInstalledHomes(rootRunner) + fileHomes else fileHomes + val activeHome = when { + rootReady -> repository.findActiveInstalledHome(rootRunner, homes) + ready -> repository.findActiveHomeWithShell(shellRunner, homes) + else -> null + } + _uiState.update { + it.copy( + homes = homes, + selected = it.selected?.takeIf { selected -> homes.any { home -> home.apkPath == selected.apkPath } } + ?: activeHome + ?: homes.firstOrNull(), + activeHome = activeHome, + shizukuReady = ready, + rootReady = rootReady, + message = if (homes.isEmpty()) { + "Download a home APK or install an environment package" + } else if (activeHome != null) { + "Active: ${activeHome.displayName}" + } else { + "${homes.size} home(s) found · ${if (rootReady) "Root direct mode" else "Shizuku fallback"}" + }, + ) + } + } + } + + fun requestShizukuPermission() { + viewModelScope.launch { + shellRunner.requestPermissionIfNeeded(42) + _uiState.update { + it.copy(message = "Approve Shizuku permission, then press refresh.") + } + } + } + + fun select(home: HomeEnvironment) { + _uiState.update { it.copy(selected = home) } + } + + fun activateSelected() { + val home = _uiState.value.selected ?: return + viewModelScope.launch { + _uiState.update { + it.copy( + isBusy = true, + showRestartAction = false, + message = "Installing ${home.displayName}...", + log = "", + ) + } + val result = activateHome(home) + _uiState.update { + it.copy( + isBusy = false, + activeHome = if (result.success) home else it.activeHome, + showRestartAction = result.success && result.needsReboot, + message = when { + !result.success -> "Install failed. Open the log." + else -> "Active: ${home.displayName}" + }, + log = if (result.success) "" else result.log, + ) + } + } + } + + fun restartQuest() { + viewModelScope.launch { + _uiState.update { it.copy(message = "Requesting Quest restart...") } + val result = shellRunner.run("reboot") + _uiState.update { + it.copy( + message = if (result.success) "Restart requested." else "Restart failed. Use the Quest power menu.", + log = if (result.output.isBlank()) it.log else it.log + "\n== Restart ==\n" + result.output, + ) + } + } + } + + fun formatSize(bytes: Long): String = repository.formatSize(bytes) +} diff --git a/app/src/main/java/qu/astro/envswitch/EnvAdapter.java b/app/src/main/java/qu/astro/envswitch/EnvAdapter.java deleted file mode 100644 index 7c9a2d2..0000000 --- a/app/src/main/java/qu/astro/envswitch/EnvAdapter.java +++ /dev/null @@ -1,122 +0,0 @@ -package qu.astro.envswitch; - -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.graphics.Typeface; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import java.util.List; - -public class EnvAdapter extends RecyclerView.Adapter { - - public interface Callbacks { - void onChooseZip(Models.EnvApk item, TextView sceneView); - void onApply(Models.EnvApk item, TextView stateView, TextView sceneView); - void onApplyAsEnv(Models.EnvApk item, TextView stateView, TextView sceneView); - void onSetDefault(Models.EnvApk item, TextView stateView, TextView sceneView); - void onUninstall(Models.EnvApk item, TextView stateView); - } - - private List items; - private final PackageManager pm; - private final Callbacks cb; - - public EnvAdapter(List items, PackageManager pm, Callbacks cb) { - this.items = items; - this.pm = pm; - this.cb = cb; - } - - public void update(List newItems) { - this.items = newItems; - notifyDataSetChanged(); - } - - static class VH extends RecyclerView.ViewHolder { - ImageView icon; TextView lbl; TextView pkg; TextView scene; TextView state; - Button btnChoose; Button btnApply; Button btnSetDefault; Button btnApplyEnv; Button btnUninstall; - VH(View v) { - super(v); - icon = v.findViewById(R.id.icon); - lbl = v.findViewById(R.id.lbl); - pkg = v.findViewById(R.id.pkg); - scene = v.findViewById(R.id.scene); - state = v.findViewById(R.id.state); - btnChoose = v.findViewById(R.id.btnChooseZip); - btnApply = v.findViewById(R.id.btnApply); - btnSetDefault = v.findViewById(R.id.btnSetDefault); - btnApplyEnv = v.findViewById(R.id.btnApplyEnv); - btnUninstall = v.findViewById(R.id.btnUninstall); - } - } - - @NonNull @Override - public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_env, parent, false); - return new VH(v); - } - - @Override public int getItemCount() { return items == null ? 0 : items.size(); } - - @Override - public void onBindViewHolder(@NonNull VH h, int position) { - Models.EnvApk it = items.get(position); - - try { - ApplicationInfo ai = pm.getApplicationInfo(it.packageName, 0); - h.icon.setImageDrawable(pm.getApplicationIcon(ai)); - } catch (Exception ignore) {} - - h.lbl.setText(it.label); - h.pkg.setText(it.packageName); - h.scene.setText(it.sceneUri); - h.state.setText(""); - - h.lbl.setTypeface(null, Typeface.BOLD); - switch (it.type) { - case VISTA: - h.lbl.setTextColor(0xFF7FC7FF); - break; - case FOOTPRINT: - h.lbl.setTextColor(0xFF7FFF9F); - break; - default: - h.lbl.setTextColor(0xFFD0BCFF); - break; - } - - if (it.zipEntries != null && it.zipEntries.size() > 1) { - h.btnChoose.setVisibility(View.VISIBLE); - h.btnChoose.setOnClickListener(v -> cb.onChooseZip(it, h.scene)); - } else { - h.btnChoose.setVisibility(View.GONE); - } - - switch (it.type) { - case VISTA: - h.btnApply.setText("Apply Vista"); - break; - case FOOTPRINT: - h.btnApply.setText("Apply Footprint"); - break; - default: - h.btnApply.setText("Apply"); - break; - } - - h.btnApply.setOnClickListener(v -> cb.onApply(it, h.state, h.scene)); - h.btnSetDefault.setOnClickListener(v -> cb.onSetDefault(it, h.state, h.scene)); - h.btnApplyEnv.setOnClickListener(v -> cb.onApplyAsEnv(it, h.state, h.scene)); - h.btnUninstall.setOnClickListener(v -> cb.onUninstall(it, h.state)); - - h.btnSetDefault.setVisibility(it.type == Models.Type.ENVIRONMENT ? View.VISIBLE : View.GONE); - h.btnApplyEnv.setVisibility(it.type == Models.Type.FOOTPRINT ? View.VISIBLE : View.GONE); - h.btnUninstall.setVisibility(it.isProtected() ? View.GONE : View.VISIBLE); - } -} diff --git a/app/src/main/java/qu/astro/envswitch/MainActivity.java b/app/src/main/java/qu/astro/envswitch/MainActivity.java deleted file mode 100644 index c959bbd..0000000 --- a/app/src/main/java/qu/astro/envswitch/MainActivity.java +++ /dev/null @@ -1,480 +0,0 @@ -package qu.astro.envswitch; - -import android.app.AlertDialog; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.os.Bundle; -import android.widget.Button; -import android.widget.TextView; -import android.widget.Toast; -import androidx.activity.ComponentActivity; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Enumeration; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; - -public class MainActivity extends ComponentActivity { - private static final String STOCK_VISTA_URI = "apk://com.meta.shell.env.vista.central/assets/scene.zip"; - private static final String STOCK_FOOTPRINT_URI = "apk://com.meta.shell.env.footprint.haven2025/assets/scene.zip"; - private static final String STOCK_ENV_URI = "apk://com.meta.shell.env.footprint.haven2025/assets/scene.zip"; - private static final String FALLBACK_ENV_URI = "apk://com.meta.environment.prod.nuxd/assets/scene.zip"; - - private RecyclerView recycler; - private EnvAdapter adapter; - private TextView txtStatus, txtScene, txtVista, txtFootprint; - private Button btnRefresh, btnRoot, btnDefaults, btnRestart; - private Button tabEnvironments, tabVistas, tabFootprints; - - private String ocPrefsBin = null; - private String currentScene = null; - private String currentVista = null; - private String currentFootprint = null; - - private List allEnvs = new ArrayList<>(); - private List allVistas = new ArrayList<>(); - private List allFootprints = new ArrayList<>(); - private Models.Type activeTab = Models.Type.ENVIRONMENT; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - recycler = findViewById(R.id.recycler); - txtStatus = findViewById(R.id.txtStatus); - txtScene = findViewById(R.id.txtScene); - txtVista = findViewById(R.id.txtVista); - txtFootprint = findViewById(R.id.txtFootprint); - btnRefresh = findViewById(R.id.btnRefresh); - btnRoot = findViewById(R.id.btnRoot); - btnDefaults = findViewById(R.id.btnDefaults); - btnRestart = findViewById(R.id.btnRestart); - tabEnvironments = findViewById(R.id.tabEnvironments); - tabVistas = findViewById(R.id.tabVistas); - tabFootprints = findViewById(R.id.tabFootprints); - - recycler.setLayoutManager(new LinearLayoutManager(this)); - adapter = new EnvAdapter(new ArrayList<>(), getPackageManager(), new EnvAdapter.Callbacks() { - @Override - public void onChooseZip(Models.EnvApk item, TextView sceneView) { - chooseZip(item, sceneView); - } - - @Override - public void onApply(Models.EnvApk item, TextView stateView, TextView sceneView) { - String uri = sceneView.getText().toString(); - applyByType(item, uri, stateView); - } - - @Override - public void onUninstall(Models.EnvApk item, TextView stateView) { - uninstall(item, stateView); - } - - @Override - public void onApplyAsEnv(Models.EnvApk item, TextView stateView, TextView sceneView) { - String uri = sceneView.getText().toString(); - applyAsEnv(item, uri, stateView); - } - - @Override - public void onSetDefault(Models.EnvApk item, TextView stateView, TextView sceneView) { - String uri = sceneView.getText().toString(); - setDefaultOnly(item, uri, stateView); - } - }); - recycler.setAdapter(adapter); - - btnRefresh.setOnClickListener(v -> scan()); - btnRoot.setOnClickListener(v -> checkRoot()); - btnDefaults.setOnClickListener(v -> setDefaults()); - btnRestart.setOnClickListener(v -> restartVrs()); - tabEnvironments.setOnClickListener(v -> selectTab(Models.Type.ENVIRONMENT)); - tabVistas.setOnClickListener(v -> selectTab(Models.Type.VISTA)); - tabFootprints.setOnClickListener(v -> selectTab(Models.Type.FOOTPRINT)); - - checkRoot(); - } - - private void checkRoot() { - Executors.newSingleThreadExecutor().execute(() -> { - boolean root = RootShell.isRootAvailable(); - String resolved = root ? RootShell.resolveOculusPrefs() : null; - ocPrefsBin = resolved; - runOnUiThread(() -> { - if (!root) { - Toast.makeText(this, "Root (su) not available", Toast.LENGTH_LONG).show(); - txtStatus.setText("Root missing — Magisk required"); - } else if (resolved == null) { - Toast.makeText(this, "oculuspreferences not found", Toast.LENGTH_LONG).show(); - txtStatus.setText("oculuspreferences not found"); - } else { - txtStatus.setText("Found: " + resolved); - readAllCurrent(); - scan(); - } - }); - }); - } - - private void readAllCurrent() { - Executors.newSingleThreadExecutor().execute(() -> { - if (ocPrefsBin == null) return; - String sc = RootShell.getCurrentSelected(ocPrefsBin); - String vs = RootShell.getCurrentVista(ocPrefsBin); - String fp = RootShell.getCurrentFootprint(ocPrefsBin); - runOnUiThread(() -> { - currentScene = sc; - currentVista = vs; - currentFootprint = fp; - updateStatus(); - }); - }); - } - - private void selectTab(Models.Type type) { - tabEnvironments.setSelected(type == Models.Type.ENVIRONMENT); - tabVistas.setSelected(type == Models.Type.VISTA); - tabFootprints.setSelected(type == Models.Type.FOOTPRINT); - switch (type) { - case VISTA: adapter.update(allVistas); break; - case FOOTPRINT: adapter.update(allFootprints); break; - default: adapter.update(allEnvs); break; - } - } - - private void updateStatus() { - txtScene.setText(currentScene != null ? currentScene : "(unknown)"); - txtVista.setText(currentVista != null ? currentVista : "(unknown)"); - txtFootprint.setText(currentFootprint != null ? currentFootprint : "(unknown)"); - } - - private void scan() { - txtStatus.setText("Scanning…"); - Executors.newSingleThreadExecutor().execute(() -> { - PackageManager pm = getPackageManager(); - List apps = pm.getInstalledApplications(0); - List candidates = new ArrayList<>(); - - for (ApplicationInfo ai : apps) { - String pn = ai.packageName; - if (pn.contains(".environment.") || pn.contains(".env.vista.") - || pn.contains(".env.footprint.") || pn.startsWith("com.env.")) { - candidates.add(ai); - } - } - candidates.sort((a, b) -> pm.getApplicationLabel(a).toString() - .compareToIgnoreCase(pm.getApplicationLabel(b).toString())); - - List envs = new ArrayList<>(); - List vistas = new ArrayList<>(); - List footprints = new ArrayList<>(); - - for (ApplicationInfo ai : candidates) { - List paths = new ArrayList<>(); - if (ai.sourceDir != null) paths.add(ai.sourceDir); - if (ai.publicSourceDir != null && !ai.publicSourceDir.equals(ai.sourceDir)) - paths.add(ai.publicSourceDir); - if (ai.splitPublicSourceDirs != null) - Collections.addAll(paths, ai.splitPublicSourceDirs); - - List zips = new ArrayList<>(); - for (String p : paths) { - zips.addAll(listZips(p)); - } - if (zips.isEmpty()) continue; - - String label = pm.getApplicationLabel(ai).toString(); - String pkg = ai.packageName; - String defaultUri = "apk://" + pkg + "/assets/scene.zip"; - - Models.EnvApk item = new Models.EnvApk(detectType(pkg), label, pkg, defaultUri, paths, zips); - - switch (item.type) { - case VISTA: - vistas.add(item); - break; - case FOOTPRINT: - footprints.add(item); - break; - default: - envs.add(item); - break; - } - } - - runOnUiThread(() -> { - allEnvs = envs; - allVistas = vistas; - allFootprints = footprints; - int total = envs.size() + vistas.size() + footprints.size(); - txtStatus.setText(envs.size() + " env, " + vistas.size() + " vista" - + (vistas.size() != 1 ? "s" : "") - + ", " + footprints.size() + " fprint"); - - // refresh current tab - switch (activeTab) { - case VISTA: adapter.update(allVistas); break; - case FOOTPRINT: adapter.update(allFootprints); break; - default: adapter.update(allEnvs); break; - } - Toast.makeText(this, "Found " + total + " APKs", Toast.LENGTH_SHORT).show(); - }); - }); - } - - private Models.Type detectType(String pkg) { - if (pkg.contains(".env.vista.")) return Models.Type.VISTA; - if (pkg.contains(".env.footprint.")) return Models.Type.FOOTPRINT; - return Models.Type.ENVIRONMENT; - } - - private List listZips(String apkPath) { - List out = new ArrayList<>(); - try (ZipFile zip = new ZipFile(apkPath)) { - Enumeration e = zip.entries(); - while (e.hasMoreElements()) { - ZipEntry ze = e.nextElement(); - String name = ze.getName(); - if (name != null && name.toLowerCase().endsWith(".zip")) { - out.add(name); - } - } - } catch (Throwable ignore) {} - return out; - } - - private void chooseZip(Models.EnvApk item, TextView sceneView) { - if (item.zipEntries == null || item.zipEntries.isEmpty()) return; - String[] entries = item.zipEntries.toArray(new String[0]); - new AlertDialog.Builder(this) - .setTitle("Choose ZIP in " + item.packageName) - .setItems(entries, (d, which) -> { - String selected = entries[which]; - String uri = "apk://" + item.packageName + "/" + selected; - item.sceneUri = uri; - sceneView.setText(uri); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private void applyByType(Models.EnvApk item, String uri, TextView stateView) { - if (ocPrefsBin == null) return; - stateView.setText("Applying…"); - Executors.newSingleThreadExecutor().execute(() -> { - StringBuilder diag = new StringBuilder(); - boolean ok; - - switch (item.type) { - case ENVIRONMENT: - ok = applyEnvironmentKeys(uri, diag); - break; - case VISTA: - ok = applyVistaKeys(uri, diag); - break; - case FOOTPRINT: - ok = applyFootprintKey(uri, diag); - break; - default: - ok = false; - diag.append("Unknown type"); - break; - } - - boolean finalOk = ok; - runOnUiThread(() -> { - if (finalOk) { - stateView.setText("Applied"); - readAllCurrent(); - Toast.makeText(this, "Updated", Toast.LENGTH_SHORT).show(); - } else { - stateView.setText("Failed"); - String msg = diag.length() == 0 ? "Unknown error" : diag.toString(); - Toast.makeText(this, msg.length() > 400 ? msg.substring(0, 400) : msg, Toast.LENGTH_LONG).show(); - } - }); - }); - } - - private void applyAsEnv(Models.EnvApk item, String uri, TextView stateView) { - if (ocPrefsBin == null) return; - stateView.setText("Applying as Env…"); - Executors.newSingleThreadExecutor().execute(() -> { - StringBuilder diag = new StringBuilder(); - boolean ok = applyEnvironmentKeys(uri, diag); - if (item.type == Models.Type.FOOTPRINT) { - if (!applyFootprintKey(uri, diag)) ok = false; - } - - boolean finalOk = ok; - runOnUiThread(() -> { - if (finalOk) { - stateView.setText("Applied as Env"); - readAllCurrent(); - Toast.makeText(this, "Set as environment", Toast.LENGTH_SHORT).show(); - } else { - stateView.setText("Failed"); - String msg = diag.length() == 0 ? "Unknown error" : diag.toString(); - Toast.makeText(this, msg.length() > 400 ? msg.substring(0, 400) : msg, Toast.LENGTH_LONG).show(); - } - }); - }); - } - - private void setDefaultOnly(Models.EnvApk item, String uri, TextView stateView) { - if (ocPrefsBin == null) return; - stateView.setText("Setting default…"); - Executors.newSingleThreadExecutor().execute(() -> { - String[] rEnv = RootShell.setDefault(ocPrefsBin, uri); - String[] rFoot = RootShell.setDefaultFootprint(ocPrefsBin, uri); - boolean ok = "0".equals(rEnv[0]) && "0".equals(rFoot[0]); - runOnUiThread(() -> { - if (ok) { - stateView.setText("Default set"); - readAllCurrent(); - Toast.makeText(this, "environment_default + default_footprint updated", Toast.LENGTH_SHORT).show(); - } else { - stateView.setText("Failed"); - } - }); - }); - } - - private boolean applyEnvironmentKeys(String uri, StringBuilder diag) { - boolean ok = true; - String[] r; - - r = RootShell.setSelected(ocPrefsBin, uri); - if (!"0".equals(r[0])) { diag.append("environment_selected failed\n"); ok = false; } - - r = RootShell.setDefault(ocPrefsBin, uri); - if (!"0".equals(r[0])) { diag.append("environment_default failed\n"); ok = false; } - - r = RootShell.setResolvedEnvironment(ocPrefsBin, uri); - if (!"0".equals(r[0])) { diag.append("resolved_environment failed\n"); ok = false; } - - return ok; - } - - private boolean applyVistaKeys(String uri, StringBuilder diag) { - boolean ok = true; - String[] r; - - r = RootShell.setDefaultVista(ocPrefsBin, uri); - if (!"0".equals(r[0])) { diag.append("default_vista failed\n"); ok = false; } - - r = RootShell.setResolvedVista(ocPrefsBin, uri); - if (!"0".equals(r[0])) { diag.append("resolved_vista failed\n"); ok = false; } - - r = RootShell.setEnvironmentVistaSelected(ocPrefsBin, uri); - if (!"0".equals(r[0])) { diag.append("environment_vista_selected failed\n"); ok = false; } - - return ok; - } - - private boolean applyFootprintKey(String uri, StringBuilder diag) { - String[] r = RootShell.setDefaultFootprint(ocPrefsBin, uri); - if (!"0".equals(r[0])) { diag.append("default_footprint failed\n"); return false; } - return true; - } - - private void setDefaults() { - if (ocPrefsBin == null) return; - txtStatus.setText("Resetting defaults…"); - Executors.newSingleThreadExecutor().execute(() -> { - String envUri = isPackageInstalled("com.meta.shell.env.footprint.haven2025") - ? STOCK_ENV_URI : FALLBACK_ENV_URI; - - StringBuilder diag = new StringBuilder(); - boolean ok = applyEnvironmentKeys(envUri, diag) - & applyVistaKeys(STOCK_VISTA_URI, diag) - & applyFootprintKey(STOCK_FOOTPRINT_URI, diag); - - boolean finalOk = ok; - runOnUiThread(() -> { - if (finalOk) { - txtStatus.setText("Defaults restored"); - Toast.makeText(this, "Env + Vista + Footprint reset to stock", Toast.LENGTH_SHORT).show(); - } else { - txtStatus.setText("Reset failed"); - String msg = diag.length() == 0 ? "Unknown error" : diag.toString(); - Toast.makeText(this, msg.length() > 400 ? msg.substring(0, 400) : msg, Toast.LENGTH_LONG).show(); - } - readAllCurrent(); - }); - }); - } - - private void uninstall(Models.EnvApk item, TextView stateView) { - if (Models.PROTECTED_PKGS.contains(item.packageName)) { - Toast.makeText(this, "Protected: cannot uninstall " + item.label, Toast.LENGTH_LONG).show(); - return; - } - new AlertDialog.Builder(this) - .setTitle("Uninstall " + item.label + "?") - .setMessage("This will uninstall " + item.packageName + " via root.\nProceed?") - .setPositiveButton("Uninstall", (d, which) -> { - stateView.setText("Uninstalling…"); - Executors.newSingleThreadExecutor().execute(() -> { - String[] r = RootShell.uninstallPackage(item.packageName); - runOnUiThread(() -> { - if ("0".equals(r[0]) || r[1].contains("Success")) { - stateView.setText("Uninstalled"); - Toast.makeText(this, "Uninstalled " + item.packageName, Toast.LENGTH_SHORT).show(); - scan(); - } else { - stateView.setText("Failed"); - String msg = r[1].length() > 400 ? r[1].substring(0, 400) : r[1]; - Toast.makeText(this, msg, Toast.LENGTH_LONG).show(); - } - }); - }); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private void restartVrs() { - if (ocPrefsBin == null) return; - txtStatus.setText("Restarting VRS…"); - Executors.newSingleThreadExecutor().execute(() -> { - String[] r = RootShell.restartVrShell(); - runOnUiThread(() -> { - if ("0".equals(r[0])) { - txtStatus.setText("VRS restarted"); - Toast.makeText(this, "VRS restarted", Toast.LENGTH_SHORT).show(); - } else { - txtStatus.setText("Restart failed"); - Toast.makeText(this, r[1].length() > 400 ? r[1].substring(0, 400) : r[1], Toast.LENGTH_LONG).show(); - } - }); - }); - } - - private boolean isPackageInstalled(String pkg) { - try { - getPackageManager().getPackageInfo(pkg, 0); - return true; - } catch (PackageManager.NameNotFoundException e) { - return false; - } - } - - private void copyCmd(String uri) { - if (ocPrefsBin == null) return; - String cmd = ocPrefsBin + " --setc environment_selected \"" + uri + "\""; - ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - cm.setPrimaryClip(ClipData.newPlainText("cmd", cmd)); - Toast.makeText(this, "Copied: " + cmd, Toast.LENGTH_SHORT).show(); - } -} diff --git a/app/src/main/java/qu/astro/envswitch/Models.java b/app/src/main/java/qu/astro/envswitch/Models.java deleted file mode 100644 index 02dc2f0..0000000 --- a/app/src/main/java/qu/astro/envswitch/Models.java +++ /dev/null @@ -1,46 +0,0 @@ -package qu.astro.envswitch; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public class Models { - public enum Type { ENVIRONMENT, VISTA, FOOTPRINT } - - public static final Set PROTECTED_PKGS = Collections.unmodifiableSet( - new HashSet<>(Arrays.asList( - "com.meta.shell.env.vista.central", - "com.meta.shell.env.footprint.haven2025", - "com.meta.environment.prod.nuxd" - )) - ); - - public static class EnvApk { - public final Type type; - public final String label; - public final String packageName; - public String sceneUri; - public final List apkPaths; - public final List zipEntries; - - public EnvApk(Type type, String label, String packageName, String sceneUri, - List apkPaths, List zipEntries) { - this.type = type; - this.label = label; - this.packageName = packageName; - this.sceneUri = sceneUri; - this.apkPaths = apkPaths; - this.zipEntries = zipEntries; - } - - public boolean isProtected() { - return PROTECTED_PKGS.contains(packageName); - } - } - - public static class SectionHeader { - public final String title; - public SectionHeader(String title) { this.title = title; } - } -} diff --git a/app/src/main/java/qu/astro/envswitch/RootShell.java b/app/src/main/java/qu/astro/envswitch/RootShell.java deleted file mode 100644 index c295fb6..0000000 --- a/app/src/main/java/qu/astro/envswitch/RootShell.java +++ /dev/null @@ -1,129 +0,0 @@ -package qu.astro.envswitch; - -import java.io.BufferedReader; -import java.io.InputStreamReader; - -public class RootShell { - private static final String[] CANDIDATES = new String[] { "oculuspreferences" }; - - public static boolean isRootAvailable() { - try { - Process p = new ProcessBuilder("su", "-c", "id").start(); - int code = p.waitFor(); - return code == 0; - } catch (Exception e) { - return false; - } - } - - public static String[] runAsRoot(String cmd) { - try { - Process p = new ProcessBuilder("su", "-c", cmd).start(); - BufferedReader out = new BufferedReader(new InputStreamReader(p.getInputStream())); - BufferedReader err = new BufferedReader(new InputStreamReader(p.getErrorStream())); - StringBuilder sb = new StringBuilder(); - String line; - while ((line = out.readLine()) != null) { - sb.append(line).append("\n"); - } - while ((line = err.readLine()) != null) { - sb.append("ERR: ").append(line).append("\n"); - } - int code = p.waitFor(); - return new String[] { String.valueOf(code), sb.toString() }; - } catch (Exception e) { - return new String[] { "-1", "Exception: " + e.getMessage() }; - } - } - - public static String resolveOculusPrefs() { - for (String bin : CANDIDATES) { - String[] r = runAsRoot(bin + " --help"); - if ("0".equals(r[0])) - return bin; - } - return null; - } - - public static String getPref(String bin, String key) { - String[] r = runAsRoot(bin + " --getc " + key); - String out = r[1]; - int s = out.indexOf("apk://"); - if (s >= 0) { - int e = out.indexOf('"', s); - if (e > s) - return out.substring(s, e); - } - return null; - } - - public static String getCurrentSelected(String bin) { - return getPref(bin, "environment_selected"); - } - - public static String getCurrentVista(String bin) { - return getPref(bin, "environment_vista_selected"); - } - - public static String getCurrentFootprint(String bin) { - return getPref(bin, "default_footprint"); - } - - public static String getResolvedEnvironment(String bin) { - return getPref(bin, "resolved_environment"); - } - - public static String getResolvedVista(String bin) { - return getPref(bin, "resolved_vista"); - } - - public static String getDefaultVista(String bin) { - return getPref(bin, "default_vista"); - } - - public static String getDefaultFootprint(String bin) { - return getPref(bin, "default_footprint"); - } - - public static String getEnvironmentDefault(String bin) { - return getPref(bin, "environment_default"); - } - - // --- setters --- - - public static String[] setSelected(String bin, String uri) { - return runAsRoot(bin + " --setc environment_selected \"" + uri + "\""); - } - - public static String[] setResolvedEnvironment(String bin, String uri) { - return runAsRoot(bin + " --setc resolved_environment \"" + uri + "\""); - } - - public static String[] setDefault(String bin, String uri) { - return runAsRoot(bin + " --setc environment_default \"" + uri + "\""); - } - - public static String[] setDefaultVista(String bin, String uri) { - return runAsRoot(bin + " --setc default_vista \"" + uri + "\""); - } - - public static String[] setResolvedVista(String bin, String uri) { - return runAsRoot(bin + " --setc resolved_vista \"" + uri + "\""); - } - - public static String[] setEnvironmentVistaSelected(String bin, String uri) { - return runAsRoot(bin + " --setc environment_vista_selected \"" + uri + "\""); - } - - public static String[] setDefaultFootprint(String bin, String uri) { - return runAsRoot(bin + " --setc default_footprint \"" + uri + "\""); - } - - public static String[] uninstallPackage(String pkg) { - return runAsRoot("pm uninstall -k --user 0 " + pkg); - } - - public static String[] restartVrShell() { - return runAsRoot("am force-stop com.oculus.vrshell"); - } -} 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..d670365 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_bg.xml b/app/src/main/res/drawable/ic_launcher_bg.xml deleted file mode 100644 index a5cd3ff..0000000 --- a/app/src/main/res/drawable/ic_launcher_bg.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_fg.xml b/app/src/main/res/drawable/ic_launcher_fg.xml deleted file mode 100644 index b46febf..0000000 --- a/app/src/main/res/drawable/ic_launcher_fg.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - 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..70d73a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 137e12d..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,181 +0,0 @@ - - - - -