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")