Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`鈥攏o slow compilation, no reboot required.
Expand Down
7 changes: 7 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
captures/
84 changes: 84 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
Binary file added android/app/debug.keystore
Binary file not shown.
30 changes: 30 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.HexNext">

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- Lets the Shizuku app hand this app its binder. -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:enabled="true"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
</application>
</manifest>
20 changes: 20 additions & 0 deletions android/app/src/main/aidl/com/hexnext/themer/IThemeService.aidl
Original file line number Diff line number Diff line change
@@ -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;
}
33 changes: 33 additions & 0 deletions android/app/src/main/java/com/hexnext/themer/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
114 changes: 114 additions & 0 deletions android/app/src/main/java/com/hexnext/themer/shizuku/ShizukuManager.kt
Original file line number Diff line number Diff line change
@@ -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>(ShizukuState.NotRunning)
val state: StateFlow<ShizukuState> = _state.asStateFlow()

private var service: IThemeService? = null
private var pending: CompletableDeferred<IThemeService>? = 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<IThemeService>().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")
}
}
}
Loading
Loading