diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..fc879b0 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,67 @@ +name: Android APK + +on: + push: + branches: [main] + tags: ["v*"] + paths: + - "android/**" + - ".github/workflows/android.yml" + pull_request: + paths: + - "android/**" + - ".github/workflows/android.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@v4 + + - name: Unit tests + run: ./gradlew --no-daemon testDebugUnitTest + + - name: Build APK + run: ./gradlew --no-daemon assembleDebug + + - name: Rename APK + run: | + version=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts) + mkdir -p dist + cp app/build/outputs/apk/debug/app-debug.apk "dist/HexNext-${version}-${GITHUB_SHA::7}.apk" + + - uses: actions/upload-artifact@v4 + with: + name: HexNext-apk + path: android/dist/*.apk + if-no-files-found: error + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: HexNext-apk + path: dist + - uses: softprops/action-gh-release@v2 + with: + files: dist/*.apk + generate_release_notes: true diff --git a/.gitignore b/.gitignore index c8ea12c..dda49f8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ apps/**/build/ daemon.pid daemon.log *.jar + +# Native Android app: keep the Gradle wrapper and the shared debug signing key. +!android/gradle/wrapper/gradle-wrapper.jar +!android/app/debug.keystore diff --git a/README.md b/README.md index e0a6ef4..884a54b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,26 @@ A next-generation theming framework, diagnostic suite, and compatibility layer f --- +## 📱 HexNext app (native, `android/`) + +The main product is now a native Kotlin + Jetpack Compose app in [`android/`](android/), built by +GitHub Actions on every push. Grab the APK from the **Android APK** workflow run's artifacts (or from a +release when a `v*` tag is pushed). It needs Android 14+ and [Shizuku](https://shizuku.rikka.app/). + +- **Shizuku running as root (or Sui):** HexNext registers real fabricated overlays on the framework: + all 65 Monet shades (`system_accent1..3_*`, `system_neutral1..2_*`) plus the Android 14+ Material role + colors, generated from your accent color and palette style. Pure-black dark mode is available. + Every step shows the real result, and the app reads the color back to verify it applied. +- **Shizuku over wireless debugging (no root):** Android refuses fabricated overlays from the shell user + (`OverlayManagerService`: "Non-root shell cannot fabricate overlays", and `cmd overlay fabricate` + requires root). HexNext instead hands the seed color and style to System UI's own Monet engine through + `theme_customization_overlay_packages`. On One UI 9 this recolors Material You apps but not One UI's own UI. + +Build locally with `cd android && ./gradlew assembleDebug`. The old WebView app in `apps/hex-next/` +is kept only as a design reference. + +--- + ## 🌟 Features - **Fabricated Runtime Resource Overlays (RRO):** Dynamic, instantaneous color and resource styling via native Android `cmd overlay fabricate`—no slow compilation, no reboot required. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..a5e246b --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +.kotlin/ +captures/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..bd3d2bb --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,84 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.hexnext.themer" + compileSdk = 35 + + defaultConfig { + applicationId = "com.hexnext.themer" + // Public FabricatedOverlay / OverlayManagerTransaction APIs arrived in Android 14. + // One UI 7 (Android 15) and later are the real targets. + minSdk = 34 + targetSdk = 35 + versionCode = 200 + versionName = "2.0.0" + } + + signingConfigs { + // A committed debug key keeps CI builds installable over each other. + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + buildTypes { + debug { + signingConfig = signingConfigs.getByName("debug") + } + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + } + } + + buildFeatures { + aidl = true + buildConfig = true + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + lint { + // Material color utilities are marked @RestrictTo but are the reference Monet implementation. + disable += "RestrictedApi" + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-core") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + + // Monet palette generation (HCT, DynamicScheme, MaterialDynamicColors). + implementation("com.google.android.material:material:1.12.0") + + implementation("dev.rikka.shizuku:api:13.1.5") + implementation("dev.rikka.shizuku:provider:13.1.5") + implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1") + + testImplementation("junit:junit:4.13.2") +} diff --git a/android/app/debug.keystore b/android/app/debug.keystore new file mode 100644 index 0000000..75b2336 Binary files /dev/null and b/android/app/debug.keystore differ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3de1323 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/aidl/com/hexnext/themer/IThemeService.aidl b/android/app/src/main/aidl/com/hexnext/themer/IThemeService.aidl new file mode 100644 index 0000000..b34d7bf --- /dev/null +++ b/android/app/src/main/aidl/com/hexnext/themer/IThemeService.aidl @@ -0,0 +1,20 @@ +package com.hexnext.themer; + +import android.os.Bundle; + +// Runs inside the Shizuku user service process, as shell (uid 2000) or root (uid 0). +// Every call returns a Bundle with "code" (int, 0 = success) and "output" (String). +interface IThemeService { + // Reserved transaction code Shizuku uses to tear the service down. + void destroy() = 16777114; + + int getUid() = 1; + + Bundle exec(String command) = 2; + + // Registers (or replaces) a fabricated overlay of ARGB colors and enables it. Root only. + Bundle applyColorOverlay(String name, String targetPackage, in String[] resourceNames, in int[] colors) = 3; + + // Disables and unregisters a fabricated overlay this app created. Root only. + Bundle removeOverlay(String name) = 4; +} diff --git a/android/app/src/main/java/com/hexnext/themer/MainActivity.kt b/android/app/src/main/java/com/hexnext/themer/MainActivity.kt new file mode 100644 index 0000000..b899cd4 --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/MainActivity.kt @@ -0,0 +1,33 @@ +package com.hexnext.themer + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import com.hexnext.themer.shizuku.ShizukuManager +import com.hexnext.themer.ui.HexNextTheme +import com.hexnext.themer.ui.MainScreen +import com.hexnext.themer.ui.MainViewModel + +class MainActivity : ComponentActivity() { + + private val viewModel: MainViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ShizukuManager.init() + enableEdgeToEdge() + setContent { + HexNextTheme { + MainScreen(viewModel) + } + } + } + + override fun onResume() { + super.onResume() + // The user may have started Shizuku or changed its permission while we were away. + ShizukuManager.refresh() + } +} diff --git a/android/app/src/main/java/com/hexnext/themer/shizuku/ShizukuManager.kt b/android/app/src/main/java/com/hexnext/themer/shizuku/ShizukuManager.kt new file mode 100644 index 0000000..e191f5f --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/shizuku/ShizukuManager.kt @@ -0,0 +1,114 @@ +package com.hexnext.themer.shizuku + +import android.content.ComponentName +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.IBinder +import com.hexnext.themer.BuildConfig +import com.hexnext.themer.IThemeService +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withTimeout +import rikka.shizuku.Shizuku + +/** Where Shizuku stands, from this app's point of view. */ +sealed interface ShizukuState { + /** Shizuku isn't installed or isn't running. */ + data object NotRunning : ShizukuState + + /** Shizuku is older than the v11 API this app uses. */ + data object TooOld : ShizukuState + + data object PermissionNeeded : ShizukuState + + /** The user picked "deny and don't ask again"; it must be granted in the Shizuku app. */ + data object PermissionDenied : ShizukuState + + data class Ready(val uid: Int, val version: Int) : ShizukuState { + val isRoot get() = uid == 0 + } +} + +object ShizukuManager { + private const val PERMISSION_REQUEST = 7301 + + private val _state = MutableStateFlow(ShizukuState.NotRunning) + val state: StateFlow = _state.asStateFlow() + + private var service: IThemeService? = null + private var pending: CompletableDeferred? = null + + private val serviceArgs by lazy { + Shizuku.UserServiceArgs(ComponentName(BuildConfig.APPLICATION_ID, ThemeService::class.java.name)) + .daemon(false) + .processNameSuffix("theme") + .debuggable(BuildConfig.DEBUG) + .version(BuildConfig.VERSION_CODE) + } + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + if (binder == null || !binder.pingBinder()) return + val bound = IThemeService.Stub.asInterface(binder) + service = bound + pending?.complete(bound) + pending = null + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + } + } + + private var initialized = false + + fun init() { + if (initialized) return + initialized = true + Shizuku.addBinderReceivedListenerSticky { refresh() } + Shizuku.addBinderDeadListener { + service = null + refresh() + } + Shizuku.addRequestPermissionResultListener { requestCode, _ -> + if (requestCode == PERMISSION_REQUEST) refresh() + } + refresh() + } + + fun refresh() { + _state.value = when { + !Shizuku.pingBinder() -> ShizukuState.NotRunning + Shizuku.isPreV11() -> ShizukuState.TooOld + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED -> + ShizukuState.Ready(Shizuku.getUid(), Shizuku.getVersion()) + Shizuku.shouldShowRequestPermissionRationale() -> ShizukuState.PermissionDenied + else -> ShizukuState.PermissionNeeded + } + } + + fun requestPermission() { + if (Shizuku.pingBinder() && !Shizuku.isPreV11()) { + Shizuku.requestPermission(PERMISSION_REQUEST) + } + } + + /** Binds the privileged user service, starting it if needed. Blocking callers should use IO. */ + suspend fun service(): IThemeService { + service?.let { if (it.asBinder().pingBinder()) return it } + check(_state.value is ShizukuState.Ready) { "Shizuku isn't ready" } + val deferred = pending ?: CompletableDeferred().also { + pending = it + Shizuku.bindUserService(serviceArgs, connection) + } + return try { + withTimeout(15_000) { deferred.await() } + } catch (e: TimeoutCancellationException) { + pending = null + throw IllegalStateException("Shizuku didn't start the theme service within 15 seconds") + } + } +} diff --git a/android/app/src/main/java/com/hexnext/themer/shizuku/ThemeService.kt b/android/app/src/main/java/com/hexnext/themer/shizuku/ThemeService.kt new file mode 100644 index 0000000..f7ccf29 --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/shizuku/ThemeService.kt @@ -0,0 +1,154 @@ +package com.hexnext.themer.shizuku + +import android.content.Context +import android.content.om.FabricatedOverlay +import android.content.om.OverlayManagerTransaction +import android.os.Bundle +import android.os.IBinder +import android.os.Process +import android.util.TypedValue +import androidx.annotation.Keep +import com.hexnext.themer.IThemeService +import org.lsposed.hiddenapibypass.HiddenApiBypass +import java.lang.reflect.InvocationTargetException +import java.util.concurrent.TimeUnit +import kotlin.system.exitProcess + +/** + * Shizuku user service. Shizuku starts this class in its own process with Shizuku's identity: + * uid 2000 (shell) when Shizuku was started over ADB, or uid 0 when it was started with root (or Sui). + * + * Fabricated overlays are registered by talking to the overlay manager binder directly. + * `cmd overlay fabricate` cannot be used: AOSP rejects it unless the caller is root, and + * OverlayManagerService rejects fabricated-overlay transactions from the shell uid outright + * ("Non-root shell cannot fabricate overlays"). So this path needs root-backed Shizuku. + */ +@Keep +class ThemeService() : IThemeService.Stub() { + + // Shizuku prefers a (Context) constructor when one exists. + @Keep + @Suppress("UNUSED_PARAMETER") + constructor(context: Context) : this() + + init { + // Processes started by app_process usually aren't restricted, but be safe. + runCatching { HiddenApiBypass.addHiddenApiExemptions("") } + } + + override fun destroy() { + exitProcess(0) + } + + override fun getUid(): Int = Process.myUid() + + override fun exec(command: String): Bundle = try { + val process = ProcessBuilder("sh", "-c", command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly() + result(124, output + "\n(timed out after 60s)") + } else { + result(process.exitValue(), output) + } + } catch (t: Throwable) { + result(-1, describe(t)) + } + + override fun applyColorOverlay( + name: String, + targetPackage: String, + resourceNames: Array, + colors: IntArray, + ): Bundle { + if (Process.myUid() != 0) return rootRequired() + if (resourceNames.size != colors.size) { + return result(-1, "resourceNames and colors differ in length") + } + return try { + val overlay = newOverlay(name, targetPackage) + resourceNames.forEachIndexed { i, res -> + overlay.setResourceValue(res, TypedValue.TYPE_INT_COLOR_ARGB8, colors[i], null) + } + val register = OverlayManagerTransaction.newInstance() + register.registerFabricatedOverlay(overlay) + commit(register) + + // Enabling through the shell also raises the overlay to the highest priority, + // which puts it above System UI's own Monet overlays. + val id = overlayId(name) + val enable = exec("cmd overlay enable --user current $id") + val code = enable.getInt(KEY_CODE) + result( + code, + "Registered $id with ${resourceNames.size} colors on $targetPackage" + + if (code == 0) ", enabled." else ".\nEnable failed: ${enable.getString(KEY_OUTPUT)}" + ) + } catch (t: Throwable) { + result(-1, describe(t)) + } + } + + override fun removeOverlay(name: String): Bundle { + if (Process.myUid() != 0) return rootRequired() + return try { + val id = overlayId(name) + exec("cmd overlay disable --user current $id") + val unregister = OverlayManagerTransaction.newInstance() + // The identifier is (owning package, name); the target doesn't matter for removal. + unregister.unregisterFabricatedOverlay(newOverlay(name, "android").identifier) + commit(unregister) + result(0, "Removed $id") + } catch (t: Throwable) { + result(-1, describe(t)) + } + } + + private fun newOverlay(name: String, target: String): FabricatedOverlay { + val overlay = FabricatedOverlay(name, target) + // Hidden setter. Matches what `cmd overlay fabricate` uses, so overlays show up as + // com.android.shell: in `cmd overlay list`. + FabricatedOverlay::class.java + .getMethod("setOwningPackage", String::class.java) + .invoke(overlay, OWNER) + return overlay + } + + private fun commit(transaction: OverlayManagerTransaction) { + val binder = Class.forName("android.os.ServiceManager") + .getMethod("getService", String::class.java) + .invoke(null, "overlay") as IBinder + val iom = Class.forName("android.content.om.IOverlayManager\$Stub") + .getMethod("asInterface", IBinder::class.java) + .invoke(null, binder) + Class.forName("android.content.om.IOverlayManager") + .getMethod("commit", OverlayManagerTransaction::class.java) + .invoke(iom, transaction) + } + + private fun rootRequired() = result( + -1, + "Fabricated overlays need Shizuku running as root (uid 0). " + + "This Shizuku runs as uid ${Process.myUid()}, and Android blocks the shell user from fabricating overlays." + ) + + private fun describe(t: Throwable): String { + val cause = if (t is InvocationTargetException) t.targetException ?: t else t + return "${cause.javaClass.simpleName}: ${cause.message}" + } + + companion object { + const val KEY_CODE = "code" + const val KEY_OUTPUT = "output" + const val OWNER = "com.android.shell" + + fun overlayId(name: String) = "$OWNER:$name" + + fun result(code: Int, output: String) = Bundle().apply { + putInt(KEY_CODE, code) + putString(KEY_OUTPUT, output.trimEnd()) + } + } +} diff --git a/android/app/src/main/java/com/hexnext/themer/theme/DeviceInfo.kt b/android/app/src/main/java/com/hexnext/themer/theme/DeviceInfo.kt new file mode 100644 index 0000000..6ca91bf --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/theme/DeviceInfo.kt @@ -0,0 +1,7 @@ +package com.hexnext.themer.theme + +/** `ro.build.version.oneui` is e.g. 80000 for One UI 8.0 or 60100 for 6.1. */ +fun formatOneUi(raw: String): String? { + val value = raw.trim().toIntOrNull() ?: return null + return "One UI ${value / 10000}.${(value / 100) % 100}" +} diff --git a/android/app/src/main/java/com/hexnext/themer/theme/MonetPalette.kt b/android/app/src/main/java/com/hexnext/themer/theme/MonetPalette.kt new file mode 100644 index 0000000..24a9090 --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/theme/MonetPalette.kt @@ -0,0 +1,218 @@ +package com.hexnext.themer.theme + +import com.google.android.material.color.utilities.DynamicScheme +import com.google.android.material.color.utilities.Hct +import com.google.android.material.color.utilities.MaterialDynamicColors +import com.google.android.material.color.utilities.SchemeContent +import com.google.android.material.color.utilities.SchemeExpressive +import com.google.android.material.color.utilities.SchemeFidelity +import com.google.android.material.color.utilities.SchemeFruitSalad +import com.google.android.material.color.utilities.SchemeMonochrome +import com.google.android.material.color.utilities.SchemeNeutral +import com.google.android.material.color.utilities.SchemeRainbow +import com.google.android.material.color.utilities.SchemeTonalSpot +import com.google.android.material.color.utilities.SchemeVibrant +import com.google.android.material.color.utilities.TonalPalette + +/** + * Palette styles. [systemUiName] is the matching `theme_style` value System UI understands, + * used when only the seed color can be handed to System UI (non-root Shizuku). + */ +enum class PaletteStyle(val label: String, val systemUiName: String) { + TONAL_SPOT("Tonal spot", "TONAL_SPOT"), + VIBRANT("Vibrant", "VIBRANT"), + EXPRESSIVE("Expressive", "EXPRESSIVE"), + FIDELITY("Fidelity", "CONTENT"), + CONTENT("Content", "CONTENT"), + NEUTRAL("Neutral", "SPRITZ"), + MONOCHROME("Monochrome", "MONOCHROMATIC"), + RAINBOW("Rainbow", "RAINBOW"), + FRUIT_SALAD("Fruit salad", "FRUIT_SALAD"); + + fun scheme(seed: Int, dark: Boolean): DynamicScheme { + val hct = Hct.fromInt(seed) + return when (this) { + TONAL_SPOT -> SchemeTonalSpot(hct, dark, 0.0) + VIBRANT -> SchemeVibrant(hct, dark, 0.0) + EXPRESSIVE -> SchemeExpressive(hct, dark, 0.0) + FIDELITY -> SchemeFidelity(hct, dark, 0.0) + CONTENT -> SchemeContent(hct, dark, 0.0) + NEUTRAL -> SchemeNeutral(hct, dark, 0.0) + MONOCHROME -> SchemeMonochrome(hct, dark, 0.0) + RAINBOW -> SchemeRainbow(hct, dark, 0.0) + FRUIT_SALAD -> SchemeFruitSalad(hct, dark, 0.0) + } + } +} + +/** The five Monet tonal groups exposed by the framework as `android:color/system__`. */ +enum class ToneGroup(val resourcePrefix: String, val label: String) { + ACCENT1("system_accent1", "Primary"), + ACCENT2("system_accent2", "Secondary"), + ACCENT3("system_accent3", "Tertiary"), + NEUTRAL1("system_neutral1", "Neutral"), + NEUTRAL2("system_neutral2", "Neutral variant"), +} + +data class ThemeSpec( + val seed: Int, + val style: PaletteStyle, + /** Paints dark-theme backgrounds pure black, for AMOLED screens. */ + val pureBlack: Boolean, +) + +/** One fabricated-overlay value: `android:color/` = [argb]. */ +data class ColorEntry(val name: String, val argb: Int) { + val resourceName get() = "android:color/$name" +} + +class MonetPalette private constructor( + val spec: ThemeSpec, + /** Colors per group, ordered like [SHADES]. */ + val shades: Map, + private val light: DynamicScheme, + private val dark: DynamicScheme, +) { + + /** The 65 `system_accentN_*` / `system_neutralN_*` shade colors (Android 12+). */ + fun shadeEntries(): List = ToneGroup.entries.flatMap { group -> + val colors = shades.getValue(group) + SHADES.mapIndexed { i, shade -> ColorEntry("${group.resourcePrefix}_$shade", colors[i]) } + } + + /** + * The Material 3 role colors (`system_primary_light`, `system_surface_container_dark`, ...) + * that Android 14+ apps read directly. Kept separate from the shades so a framework that lacks + * one of these names can't take the shade overlay down with it. + */ + fun roleEntries(): List { + val out = ArrayList(ROLES.size * 2 + FIXED_ROLES.size) + for ((name, role) in ROLES) { + out += ColorEntry("system_${name}_light", role(COLORS).getArgb(light)) + out += ColorEntry("system_${name}_dark", darkRole(name, role(COLORS).getArgb(dark))) + } + for ((name, role) in FIXED_ROLES) { + out += ColorEntry("system_$name", role(COLORS).getArgb(light)) + } + return out + } + + private fun darkRole(name: String, argb: Int): Int = + if (spec.pureBlack && name in PURE_BLACK_ROLES) BLACK else argb + + companion object { + /** Framework shade suffixes, lightest to darkest. */ + val SHADES = intArrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000) + + private const val BLACK = 0xFF000000.toInt() + + private val COLORS = MaterialDynamicColors() + + /** Maps a framework shade to an HCT tone, as System UI's ColorScheme does. */ + fun toneForShade(shade: Int): Int = when (shade) { + 0 -> 100 + 10 -> 99 + 50 -> 95 + else -> 100 - shade / 10 + } + + fun generate(spec: ThemeSpec): MonetPalette { + val light = spec.style.scheme(spec.seed, dark = false) + val dark = spec.style.scheme(spec.seed, dark = true) + val palettes: Map = mapOf( + ToneGroup.ACCENT1 to light.primaryPalette, + ToneGroup.ACCENT2 to light.secondaryPalette, + ToneGroup.ACCENT3 to light.tertiaryPalette, + ToneGroup.NEUTRAL1 to light.neutralPalette, + ToneGroup.NEUTRAL2 to light.neutralVariantPalette, + ) + val shades = palettes.mapValues { (group, palette) -> + IntArray(SHADES.size) { i -> + val shade = SHADES[i] + if (spec.pureBlack && group == ToneGroup.NEUTRAL1 && shade >= 900) BLACK + else palette.tone(toneForShade(shade)) + } + } + return MonetPalette(spec, shades, light, dark) + } + + private val ROLES: List com.google.android.material.color.utilities.DynamicColor>> = listOf( + "primary" to { it.primary() }, + "on_primary" to { it.onPrimary() }, + "primary_container" to { it.primaryContainer() }, + "on_primary_container" to { it.onPrimaryContainer() }, + "secondary" to { it.secondary() }, + "on_secondary" to { it.onSecondary() }, + "secondary_container" to { it.secondaryContainer() }, + "on_secondary_container" to { it.onSecondaryContainer() }, + "tertiary" to { it.tertiary() }, + "on_tertiary" to { it.onTertiary() }, + "tertiary_container" to { it.tertiaryContainer() }, + "on_tertiary_container" to { it.onTertiaryContainer() }, + "error" to { it.error() }, + "on_error" to { it.onError() }, + "error_container" to { it.errorContainer() }, + "on_error_container" to { it.onErrorContainer() }, + "background" to { it.background() }, + "on_background" to { it.onBackground() }, + "surface" to { it.surface() }, + "surface_dim" to { it.surfaceDim() }, + "surface_bright" to { it.surfaceBright() }, + "surface_container_lowest" to { it.surfaceContainerLowest() }, + "surface_container_low" to { it.surfaceContainerLow() }, + "surface_container" to { it.surfaceContainer() }, + "surface_container_high" to { it.surfaceContainerHigh() }, + "surface_container_highest" to { it.surfaceContainerHighest() }, + "on_surface" to { it.onSurface() }, + "surface_variant" to { it.surfaceVariant() }, + "on_surface_variant" to { it.onSurfaceVariant() }, + "outline" to { it.outline() }, + "outline_variant" to { it.outlineVariant() }, + "control_activated" to { it.controlActivated() }, + "control_normal" to { it.controlNormal() }, + "control_highlight" to { it.controlHighlight() }, + "text_primary_inverse" to { it.textPrimaryInverse() }, + "text_secondary_and_tertiary_inverse" to { it.textSecondaryAndTertiaryInverse() }, + "text_primary_inverse_disable_only" to { it.textPrimaryInverseDisableOnly() }, + "text_secondary_and_tertiary_inverse_disabled" to { it.textSecondaryAndTertiaryInverseDisabled() }, + "text_hint_inverse" to { it.textHintInverse() }, + "palette_key_color_primary" to { it.primaryPaletteKeyColor() }, + "palette_key_color_secondary" to { it.secondaryPaletteKeyColor() }, + "palette_key_color_tertiary" to { it.tertiaryPaletteKeyColor() }, + ) + + private val FIXED_ROLES: List com.google.android.material.color.utilities.DynamicColor>> = listOf( + "primary_fixed" to { it.primaryFixed() }, + "primary_fixed_dim" to { it.primaryFixedDim() }, + "on_primary_fixed" to { it.onPrimaryFixed() }, + "on_primary_fixed_variant" to { it.onPrimaryFixedVariant() }, + "secondary_fixed" to { it.secondaryFixed() }, + "secondary_fixed_dim" to { it.secondaryFixedDim() }, + "on_secondary_fixed" to { it.onSecondaryFixed() }, + "on_secondary_fixed_variant" to { it.onSecondaryFixedVariant() }, + "tertiary_fixed" to { it.tertiaryFixed() }, + "tertiary_fixed_dim" to { it.tertiaryFixedDim() }, + "on_tertiary_fixed" to { it.onTertiaryFixed() }, + "on_tertiary_fixed_variant" to { it.onTertiaryFixedVariant() }, + ) + + private val PURE_BLACK_ROLES = setOf( + "background", + "surface", + "surface_dim", + "surface_container_lowest", + "surface_container_low", + ) + } +} + +/** `#RRGGBB` for display and for System UI's seed-color setting. */ +fun Int.toHex(): String = String.format("#%06X", this and 0xFFFFFF) + +/** Parses `#RRGGBB`, `RRGGBB` or `#AARRGGBB`; returns an opaque ARGB int or null. */ +fun parseHexColor(text: String): Int? { + val hex = text.trim().removePrefix("#") + if (hex.length != 6 && hex.length != 8) return null + val value = hex.toLongOrNull(16) ?: return null + return (0xFF000000L or (value and 0xFFFFFFL)).toInt() +} diff --git a/android/app/src/main/java/com/hexnext/themer/theme/ThemeEngine.kt b/android/app/src/main/java/com/hexnext/themer/theme/ThemeEngine.kt new file mode 100644 index 0000000..ab91186 --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/theme/ThemeEngine.kt @@ -0,0 +1,129 @@ +package com.hexnext.themer.theme + +import android.content.SharedPreferences +import android.os.Bundle +import com.hexnext.themer.IThemeService +import com.hexnext.themer.shizuku.ThemeService +import org.json.JSONObject + +enum class LogLevel { INFO, OK, WARN, ERROR } + +data class LogLine(val level: LogLevel, val text: String) + +/** + * Applies and removes themes through the Shizuku user service. Every step reports the real + * exit code and output; nothing here claims success it didn't observe. + */ +class ThemeEngine(private val prefs: SharedPreferences) { + + /** Root path: framework Monet colors as fabricated overlays. */ + fun applyFabricated(service: IThemeService, palette: MonetPalette, log: (LogLine) -> Unit): Boolean { + val shades = palette.shadeEntries() + log(LogLine(LogLevel.INFO, "Fabricating $SHADES_OVERLAY (${shades.size} Monet shades)…")) + val shadeResult = service.applyColorOverlay(SHADES_OVERLAY, TARGET, shades.names(), shades.colors()) + if (!report(shadeResult, log)) return false + + val roles = palette.roleEntries() + log(LogLine(LogLevel.INFO, "Fabricating $ROLES_OVERLAY (${roles.size} Material role colors)…")) + val roleResult = service.applyColorOverlay(ROLES_OVERLAY, TARGET, roles.names(), roles.colors()) + if (!report(roleResult, log, failLevel = LogLevel.WARN)) { + log(LogLine(LogLevel.WARN, "Role colors were skipped; the Monet shades are still applied.")) + } + + verify(service, shades.first { it.name == "system_accent1_500" }, log) + log(LogLine(LogLevel.OK, "Theme applied. Apps pick up the new colors as they redraw or restart.")) + return true + } + + /** + * Non-root path. Android won't let the shell user fabricate overlays, so hand the seed color and + * style to System UI's own Monet engine through the setting it watches. Stock Android honors it; + * On One UI 9 this recolors Material You (Monet) apps but not One UI's own Samsung UI. + */ + fun applySeedSetting(service: IThemeService, spec: ThemeSpec, log: (LogLine) -> Unit): Boolean { + if (!prefs.contains(PREF_SETTING_BACKUP)) { + val current = service.exec("settings get secure $SETTING") + if (current.code() != 0) return report(current, log) + prefs.edit().putString(PREF_SETTING_BACKUP, current.output()).apply() + log(LogLine(LogLevel.INFO, "Saved your current palette setting so Remove can restore it.")) + } + val hex = spec.seed.toHex().removePrefix("#") + val json = JSONObject() + .put("android.theme.customization.color_source", "preset") + .put("android.theme.customization.system_palette", hex) + .put("android.theme.customization.accent_color", hex) + .put("android.theme.customization.theme_style", spec.style.systemUiName) + .put("_applied_timestamp", System.currentTimeMillis()) + log(LogLine(LogLevel.INFO, "Setting System UI seed color $hex (${spec.style.label})…")) + val result = service.exec("settings put secure $SETTING '$json'") + if (!report(result, log)) return false + if (spec.pureBlack) { + log(LogLine(LogLevel.WARN, "Pure black needs root, so it was skipped.")) + } + log(LogLine(LogLevel.OK, "Seed color handed to System UI. Material You apps update; One UI's own UI keeps its colors.")) + return true + } + + fun remove(service: IThemeService, isRoot: Boolean, log: (LogLine) -> Unit): Boolean { + var ok = true + if (isRoot) { + for (name in listOf(ROLES_OVERLAY, SHADES_OVERLAY)) { + log(LogLine(LogLevel.INFO, "Removing $name…")) + ok = report(service.removeOverlay(name), log) && ok + } + } + val backup = prefs.getString(PREF_SETTING_BACKUP, null) + if (backup != null) { + log(LogLine(LogLevel.INFO, "Restoring your previous palette setting…")) + val command = if (backup.isBlank() || backup == "null") { + "settings delete secure $SETTING" + } else { + "settings put secure $SETTING '${backup.replace("'", "")}'" + } + val restored = report(service.exec(command), log) + if (restored) prefs.edit().remove(PREF_SETTING_BACKUP).apply() + ok = restored && ok + } else if (!isRoot) { + log(LogLine(LogLevel.INFO, "Nothing to remove.")) + } + if (ok) log(LogLine(LogLevel.OK, "Back to the system palette.")) + return ok + } + + /** Reads back what the framework now resolves for one color. */ + private fun verify(service: IThemeService, expected: ColorEntry, log: (LogLine) -> Unit) { + val lookup = service.exec("cmd overlay lookup --user current android ${expected.resourceName}") + val text = lookup.output() + val want = expected.argb.toHex().removePrefix("#") + when { + lookup.code() != 0 -> log(LogLine(LogLevel.WARN, "Couldn't read back ${expected.name}: $text")) + text.contains(want, ignoreCase = true) -> + log(LogLine(LogLevel.OK, "Verified: ${expected.name} now resolves to #$want.")) + else -> log(LogLine(LogLevel.WARN, "${expected.name} resolves to \"$text\", expected #$want.")) + } + } + + private fun report(result: Bundle, log: (LogLine) -> Unit, failLevel: LogLevel = LogLevel.ERROR): Boolean { + val ok = result.code() == 0 + val output = result.output() + if (ok) { + if (output.isNotBlank()) log(LogLine(LogLevel.OK, output)) + } else { + log(LogLine(failLevel, "Exit ${result.code()}: ${output.ifBlank { "no output" }}")) + } + return ok + } + + private fun Bundle.code() = getInt(ThemeService.KEY_CODE, -1) + private fun Bundle.output() = getString(ThemeService.KEY_OUTPUT).orEmpty() + private fun List.names() = map { it.resourceName }.toTypedArray() + private fun List.colors() = map { it.argb }.toIntArray() + + companion object { + const val TARGET = "android" + const val SHADES_OVERLAY = "HexNextMonetShades" + const val ROLES_OVERLAY = "HexNextMonetRoles" + private const val SETTING = "theme_customization_overlay_packages" + private const val PREF_SETTING_BACKUP = "setting_backup" + } +} diff --git a/android/app/src/main/java/com/hexnext/themer/ui/MainScreen.kt b/android/app/src/main/java/com/hexnext/themer/ui/MainScreen.kt new file mode 100644 index 0000000..d1942d4 --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/ui/MainScreen.kt @@ -0,0 +1,362 @@ +package com.hexnext.themer.ui + +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +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.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hexnext.themer.shizuku.ShizukuState +import com.hexnext.themer.theme.LogLevel +import com.hexnext.themer.theme.LogLine +import com.hexnext.themer.theme.MonetPalette +import com.hexnext.themer.theme.PaletteStyle +import com.hexnext.themer.theme.ToneGroup +import com.hexnext.themer.theme.parseHexColor +import com.hexnext.themer.theme.toHex +import android.graphics.Color as AndroidColor + +private val PRESETS = listOf( + 0xFF3E7BFA, 0xFF5C6BC0, 0xFF7E57C2, 0xFFEC407A, 0xFFE53935, 0xFFFF7043, + 0xFFF4B400, 0xFF9BC53D, 0xFF34A853, 0xFF00A6A6, 0xFF8D6E63, 0xFF78909C, +).map { it.toInt() } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen(viewModel: MainViewModel) { + val ui by viewModel.ui.collectAsStateWithLifecycle() + val shizuku by viewModel.shizuku.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val ready = shizuku as? ShizukuState.Ready + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { Text("HexNext", fontWeight = FontWeight.SemiBold) }, + scrollBehavior = scrollBehavior, + ) + }, + ) { padding -> + LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { StatusCard(shizuku, ui.oneUiVersion, viewModel::requestPermission) } + item { + ColorCard( + seed = ui.spec.seed, + onSeed = viewModel::setSeed, + ) + } + item { + StyleCard( + style = ui.spec.style, + onStyle = viewModel::setStyle, + pureBlack = ui.spec.pureBlack, + onPureBlack = viewModel::setPureBlack, + pureBlackAvailable = ready?.isRoot != false, + ) + } + item { PaletteCard(ui.palette) } + item { + Actions( + enabled = ready != null && !ui.busy, + busy = ui.busy, + applyLabel = if (ready?.isRoot == false) "Apply seed color" else "Apply overlays", + onApply = viewModel::apply, + onRemove = viewModel::remove, + ) + } + if (ui.log.isNotEmpty()) { + item { LogCard(ui.log, onClear = viewModel::clearLog) } + } + } + } +} + +@Composable +private fun SectionCard(title: String, content: @Composable () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + content() + } + } +} + +@Composable +private fun StatusCard(state: ShizukuState, oneUi: String?, onRequestPermission: () -> Unit) { + val uriHandler = LocalUriHandler.current + SectionCard("Shizuku") { + val body = when (state) { + ShizukuState.NotRunning -> + "Shizuku isn't running. Install it, start it with wireless debugging or root, then come back." + ShizukuState.TooOld -> "This Shizuku is too old. Update it to version 11 or later." + ShizukuState.PermissionNeeded -> "HexNext needs permission to use Shizuku." + ShizukuState.PermissionDenied -> + "Permission was denied. Open Shizuku and allow HexNext under authorized apps." + is ShizukuState.Ready -> if (state.isRoot) { + "Connected as root. Themes are applied as fabricated overlays on the framework." + } else { + "Connected as shell (uid ${state.uid}). Android only lets root create fabricated overlays, " + + "so HexNext hands your seed color to System UI instead. Start Shizuku with root for full overlays." + } + } + Text(body, style = MaterialTheme.typography.bodyMedium) + Text( + listOfNotNull(MainViewModel.androidVersion, oneUi).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when (state) { + ShizukuState.NotRunning, ShizukuState.TooOld -> + OutlinedButton(onClick = { uriHandler.openUri("https://shizuku.rikka.app/download/") }) { + Text("Get Shizuku") + } + ShizukuState.PermissionNeeded -> Button(onClick = onRequestPermission) { Text("Allow access") } + else -> Unit + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ColorCard(seed: Int, onSeed: (Int) -> Unit) { + SectionCard("Accent color") { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color(seed)) + ) + Spacer(Modifier.width(16.dp)) + HexField(seed, onSeed) + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + PRESETS.forEach { color -> + val selected = color == seed + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color(color)) + .then( + if (selected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + else Modifier + ) + .clickable { onSeed(color) } + ) + } + } + val hsv = remember(seed) { FloatArray(3).also { AndroidColor.colorToHSV(seed, it) } } + Text("Hue", style = MaterialTheme.typography.labelLarge) + Slider( + value = hsv[0], + onValueChange = { hue -> + onSeed(AndroidColor.HSVToColor(floatArrayOf(hue, hsv[1].coerceAtLeast(0.35f), hsv[2].coerceAtLeast(0.45f)))) + }, + valueRange = 0f..359f, + ) + } +} + +@Composable +private fun HexField(seed: Int, onSeed: (Int) -> Unit) { + var text by remember { mutableStateOf(seed.toHex()) } + // Follow outside changes (presets, slider) unless the field already holds that color. + LaunchedEffect(seed) { if (parseHexColor(text) != seed) text = seed.toHex() } + OutlinedTextField( + value = text, + onValueChange = { value -> + text = value + parseHexColor(value)?.let(onSeed) + }, + label = { Text("Hex") }, + singleLine = true, + isError = parseHexColor(text) == null, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), + textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StyleCard( + style: PaletteStyle, + onStyle: (PaletteStyle) -> Unit, + pureBlack: Boolean, + onPureBlack: (Boolean) -> Unit, + pureBlackAvailable: Boolean, +) { + SectionCard("Palette style") { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PaletteStyle.entries.forEach { option -> + FilterChip( + selected = option == style, + onClick = { onStyle(option) }, + label = { Text(option.label) }, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Pure black dark mode", style = MaterialTheme.typography.bodyLarge) + Text( + if (pureBlackAvailable) "Black backgrounds for AMOLED screens." + else "Needs Shizuku running as root.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = pureBlack, onCheckedChange = onPureBlack, enabled = pureBlackAvailable) + } + } +} + +@Composable +private fun PaletteCard(palette: MonetPalette) { + SectionCard("Preview") { + ToneGroup.entries.forEach { group -> + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + group.label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + Modifier + .fillMaxWidth() + .height(28.dp) + .clip(RoundedCornerShape(10.dp)) + ) { + palette.shades.getValue(group).forEach { argb -> + Box( + Modifier + .weight(1f) + .height(28.dp) + .background(Color(argb)) + ) + } + } + } + } + } +} + +@Composable +private fun Actions( + enabled: Boolean, + busy: Boolean, + applyLabel: String, + onApply: () -> Unit, + onRemove: () -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onApply, enabled = enabled, modifier = Modifier.weight(1f).height(52.dp)) { + if (busy) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Text(applyLabel) + } + } + OutlinedButton(onClick = onRemove, enabled = enabled, modifier = Modifier.height(52.dp)) { + Text("Remove") + } + } +} + +@Composable +private fun LogCard(lines: List, onClear: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + ) { + Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Log", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onClear) { Text("Clear") } + } + lines.forEach { line -> + Text( + line.text, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = when (line.level) { + LogLevel.OK -> MaterialTheme.colorScheme.primary + LogLevel.WARN -> MaterialTheme.colorScheme.tertiary + LogLevel.ERROR -> MaterialTheme.colorScheme.error + LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(end = 12.dp, bottom = 4.dp), + ) + } + } + } +} diff --git a/android/app/src/main/java/com/hexnext/themer/ui/MainViewModel.kt b/android/app/src/main/java/com/hexnext/themer/ui/MainViewModel.kt new file mode 100644 index 0000000..d49d9a9 --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/ui/MainViewModel.kt @@ -0,0 +1,127 @@ +package com.hexnext.themer.ui + +import android.app.Application +import android.content.Context +import android.os.Build +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.hexnext.themer.shizuku.ShizukuManager +import com.hexnext.themer.shizuku.ShizukuState +import com.hexnext.themer.theme.LogLevel +import com.hexnext.themer.theme.LogLine +import com.hexnext.themer.theme.MonetPalette +import com.hexnext.themer.theme.PaletteStyle +import com.hexnext.themer.theme.ThemeEngine +import com.hexnext.themer.theme.ThemeSpec +import com.hexnext.themer.theme.formatOneUi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class UiState( + val spec: ThemeSpec, + val palette: MonetPalette, + val busy: Boolean = false, + val oneUiVersion: String? = null, + val log: List = emptyList(), +) + +class MainViewModel(app: Application) : AndroidViewModel(app) { + + private val prefs = app.getSharedPreferences("hexnext", Context.MODE_PRIVATE) + private val engine = ThemeEngine(prefs) + + val shizuku: StateFlow = ShizukuManager.state + + private val _ui = MutableStateFlow(initialState()) + val ui: StateFlow = _ui.asStateFlow() + + init { + viewModelScope.launch { + ShizukuManager.state.collect { if (it is ShizukuState.Ready) loadDeviceInfo() } + } + } + + private fun initialState(): UiState { + val style = runCatching { PaletteStyle.valueOf(prefs.getString(PREF_STYLE, null)!!) } + .getOrDefault(PaletteStyle.TONAL_SPOT) + val spec = ThemeSpec( + seed = prefs.getInt(PREF_SEED, DEFAULT_SEED), + style = style, + pureBlack = prefs.getBoolean(PREF_PURE_BLACK, false), + ) + return UiState(spec = spec, palette = MonetPalette.generate(spec)) + } + + fun setSeed(color: Int) = updateSpec { it.copy(seed = color or 0xFF000000.toInt()) } + fun setStyle(style: PaletteStyle) = updateSpec { it.copy(style = style) } + fun setPureBlack(enabled: Boolean) = updateSpec { it.copy(pureBlack = enabled) } + + private fun updateSpec(change: (ThemeSpec) -> ThemeSpec) { + val spec = change(_ui.value.spec) + prefs.edit() + .putInt(PREF_SEED, spec.seed) + .putString(PREF_STYLE, spec.style.name) + .putBoolean(PREF_PURE_BLACK, spec.pureBlack) + .apply() + _ui.update { it.copy(spec = spec, palette = MonetPalette.generate(spec)) } + } + + fun requestPermission() = ShizukuManager.requestPermission() + + fun clearLog() = _ui.update { it.copy(log = emptyList()) } + + fun apply() = runTask { service, ready -> + val palette = _ui.value.palette + if (ready.isRoot) engine.applyFabricated(service, palette, ::log) + else engine.applySeedSetting(service, palette.spec, ::log) + } + + fun remove() = runTask { service, ready -> engine.remove(service, ready.isRoot, ::log) } + + private fun runTask(block: (com.hexnext.themer.IThemeService, ShizukuState.Ready) -> Boolean) { + val ready = ShizukuManager.state.value as? ShizukuState.Ready ?: run { + log(LogLine(LogLevel.ERROR, "Shizuku isn't ready.")) + return + } + if (_ui.value.busy) return + _ui.update { it.copy(busy = true, log = emptyList()) } + viewModelScope.launch { + try { + val service = ShizukuManager.service() + withContext(Dispatchers.IO) { block(service, ready) } + } catch (t: Throwable) { + log(LogLine(LogLevel.ERROR, "${t.javaClass.simpleName}: ${t.message}")) + } finally { + _ui.update { it.copy(busy = false) } + } + } + } + + private fun log(line: LogLine) = _ui.update { it.copy(log = it.log + line) } + + private suspend fun loadDeviceInfo() { + if (_ui.value.oneUiVersion != null) return + val version = runCatching { + val service = ShizukuManager.service() + val raw = withContext(Dispatchers.IO) { + service.exec("getprop ro.build.version.oneui").getString("output").orEmpty().trim() + } + formatOneUi(raw) + }.getOrNull() + _ui.update { it.copy(oneUiVersion = version ?: "Not One UI") } + } + + companion object { + private const val PREF_SEED = "seed" + private const val PREF_STYLE = "style" + private const val PREF_PURE_BLACK = "pure_black" + private const val DEFAULT_SEED = 0xFF3E7BFA.toInt() + + val androidVersion: String get() = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" + } +} diff --git a/android/app/src/main/java/com/hexnext/themer/ui/Theme.kt b/android/app/src/main/java/com/hexnext/themer/ui/Theme.kt new file mode 100644 index 0000000..6e3fd2c --- /dev/null +++ b/android/app/src/main/java/com/hexnext/themer/ui/Theme.kt @@ -0,0 +1,28 @@ +package com.hexnext.themer.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +/** Uses the device's own dynamic colors, so the app itself shows the applied theme. */ +@Composable +fun HexNextTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val colors = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + MaterialTheme( + colorScheme = colors, + // One UI leans on large, soft corners. + shapes = Shapes( + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(20.dp), + large = RoundedCornerShape(26.dp), + ), + content = content, + ) +} diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ad6f727 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..4f5b6ac --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d7958b1 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + HexNext + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..e66380e --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/test/java/com/hexnext/themer/theme/MonetPaletteTest.kt b/android/app/src/test/java/com/hexnext/themer/theme/MonetPaletteTest.kt new file mode 100644 index 0000000..c5ee286 --- /dev/null +++ b/android/app/src/test/java/com/hexnext/themer/theme/MonetPaletteTest.kt @@ -0,0 +1,74 @@ +package com.hexnext.themer.theme + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MonetPaletteTest { + + private val blue = ThemeSpec(seed = 0xFF3E7BFA.toInt(), style = PaletteStyle.TONAL_SPOT, pureBlack = false) + + @Test + fun shadesMapToSystemUiTones() { + val tones = MonetPalette.SHADES.map(MonetPalette::toneForShade) + assertEquals(listOf(100, 99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0), tones) + } + + @Test + fun everyFrameworkShadeIsCovered() { + val entries = MonetPalette.generate(blue).shadeEntries() + assertEquals(65, entries.size) + assertEquals(entries.size, entries.map { it.name }.toSet().size) + assertTrue(entries.any { it.resourceName == "android:color/system_accent1_500" }) + assertTrue(entries.any { it.resourceName == "android:color/system_neutral2_1000" }) + } + + @Test + fun shadeEndsAreWhiteAndBlack() { + val accent = MonetPalette.generate(blue).shades.getValue(ToneGroup.ACCENT1) + assertEquals(0xFFFFFFFF.toInt(), accent.first()) + assertEquals(0xFF000000.toInt(), accent.last()) + } + + @Test + fun pureBlackDarkensNeutralBackgrounds() { + val palette = MonetPalette.generate(blue.copy(pureBlack = true)) + val neutral = palette.shades.getValue(ToneGroup.NEUTRAL1) + assertEquals(0xFF000000.toInt(), neutral[MonetPalette.SHADES.indexOf(900)]) + val background = palette.roleEntries().first { it.name == "system_background_dark" } + assertEquals(0xFF000000.toInt(), background.argb) + } + + @Test + fun roleEntriesHaveLightAndDarkVariants() { + val names = MonetPalette.generate(blue).roleEntries().map { it.name }.toSet() + assertTrue("system_primary_light" in names) + assertTrue("system_primary_dark" in names) + assertTrue("system_surface_container_highest_dark" in names) + assertTrue("system_primary_fixed" in names) + } + + @Test + fun everyStyleGenerates() { + PaletteStyle.entries.forEach { style -> + assertEquals(65, MonetPalette.generate(blue.copy(style = style)).shadeEntries().size) + } + } + + @Test + fun hexParsing() { + assertEquals(0xFF3E7BFA.toInt(), parseHexColor("#3e7bfa")) + assertEquals(0xFF3E7BFA.toInt(), parseHexColor("803E7BFA")) + assertNull(parseHexColor("#12345")) + assertNull(parseHexColor("zzzzzz")) + assertEquals("#3E7BFA", 0xFF3E7BFA.toInt().toHex()) + } + + @Test + fun oneUiVersionFormatting() { + assertEquals("One UI 8.0", formatOneUi("80000")) + assertEquals("One UI 6.1", formatOneUi("60100")) + assertNull(formatOneUi("")) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..cfd711d --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..a7fb4c7 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/android/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="\\\"\\\"" + + +# 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/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= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..d5f96af --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "HexNext" +include(":app")