An Android library for executing arbitrary code in a privileged root process via Binder IPC, with first-class support for both Java (Future, ExecutorService) and Kotlin (coroutines, Flow, DSL).
- RootThread
┌─────────────────────────────────┐ ┌──────────────────────────────────┐
│ App Process │ │ Root Process │
│ │ │ │
│ RootCallable ──► Kryo ──► pipe ├──────► │ pipe ──► Kryo ──► RootCallable │
│ │ IPC │ │ │
│ result ◄── Kryo ◄── pipe ◄┤ │ call() │
│ │ │ │ │
│ │ │ result ──► Kryo ──► pipe ──► │
└─────────────────────────────────┘ └──────────────────────────────────┘
- The caller serializes a
RootCallablevia Kryo into aParcelFileDescriptorwrite pipe. - The read-end of that pipe and the write-end of a result pipe are handed to
RootThreadServiceover Binder. - The root service deserializes and executes the callable on a daemon thread.
- The result is serialized back into the result pipe.
- The caller reads the result pipe and resumes.
Parcelable objects are serialized using Android's own Parcel mechanism instead of Kryo to avoid cross-process reference-ID divergence.
Add the JitPack repository to your settings file:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
}
}// build.gradle.kts
dependencies {
implementation("com.github.MMRLApp.RootThread:thread:<version>")
// KSP code generation (optional — see KSP Code Generation below)
ksp("com.github.MMRLApp.RootThread:thread-ksp:<version>")
}The simplest setup — attach the observer once in onCreate:
// Kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addRootThread(this) // binds onStart, unbinds onStop
}
}// Java
public class MainActivity extends ComponentActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getLifecycle().addObserver(new RootThreadLifecycleObserver(this));
}
}The optional thread-ksp artifact provides a KSP processor that generates boilerplate-free RootCallable wrappers from annotated functions.
// build.gradle.kts
plugins {
id("com.google.devtools.ksp") version "<ksp-version>"
}
dependencies {
implementation("com.github.MMRLApp.RootThread:thread:<version>")
ksp("com.github.MMRLApp.RootThread:thread-ksp:<version>")
}Annotate any top-level function or companion object function that should run in the root process. If the function accepts a RootOptions parameter, it is automatically injected from call(options) at runtime and excluded from the constructor.
// Top-level
@RootFunction
fun loadModules(): List<Module> {
return File("/data/adb/modules").listFiles()
?.filter { it.isDirectory }
?.mapNotNull { parseModule(it) }
.orEmpty()
}
// With RootOptions (if you use RootOptions, always place it first)
@RootFunction
fun readFile(options: RootOptions, path: String): String {
return File(path).readText()
}
// Inside a companion object
class ModulesRepository {
companion object {
@RootFunction
fun loadModules(): List<Module> { ... }
}
}For each annotated function the processor generates a file under dev.mmrlx.threading:
// Generated: dev/mmrlx/threading/RootedLoadModules.kt
public class RootedLoadModules : RootCallable<List<Module>>, Serializable {
override fun call(options: RootOptions): List<Module> = loadModules()
}
// Extension on RootScope — the public API surface
fun RootScope.loadModules(): RootCallable<List<Module>> = RootedLoadModules()Usage:
// Suspend call via companion
val modules = RootedLoadModules().asThread()
// As a Flow via companion (if you compose)
val modules by RootedLoadModules().asFlow().collectAsState(emptyList())RootCallable<T> is a @FunctionalInterface (usable as a lambda in both Java and Kotlin) that represents work to execute in the root process.
val callable = RootCallable<String> {
File("/proc/version").readText()
}RootCallable<String> callable = options -> new File("/proc/version").readText();RootConsumer<T, R> is a receiver-scoped variant — it receives a typed object from the caller's process and returns a result. Used by the rootBlocking receiver extension and RootThreadExtensions.rootBlocking.
val consumer = RootConsumer<PackageManager, List<PackageInfo>> { pm ->
pm.getInstalledPackages(0)
}Because callables are serialized across a process boundary, they must be Kryo-compatible:
| ✅ Safe to capture | ❌ Never capture |
|---|---|
Primitives (Int, Boolean, String, …) |
Context / Activity / Fragment |
Parcelable objects |
View or any UI object |
| Plain data classes | Non-serialisable lambdas or anonymous classes |
| Enums | ViewModel, LiveData, Flow |
Serializable objects |
Binder objects (other than via Parcel) |
Suspends the coroutine, executes the block in the root process, and resumes with the result. Dispatches onto Dispatchers.IO automatically.
// In any suspend function:
val kernel = rootThread { File("/proc/version").readText() }
val hasSu = rootThread { File("/system/bin/su").exists() }Signature:
suspend fun <T> rootThread(block: RootCallable<T>): TThrows: IOException on IPC or remote failure.
Receiver-scoped variant. Passes this into the root process as the first argument of the callable.
val packages = packageManager.rootThread { pm ->
pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
}Signature:
suspend fun <T, R> T.rootThread(block: RootConsumer<T, R>): RThrows: IOException on IPC or remote failure.
Launch-style wrappers for use inside a CoroutineScope. Exceptions propagate through the scope's job like any other coroutine failure.
// Fire and forget
viewModelScope.rootLaunch {
Runtime.getRuntime().exec("chmod 777 /data/local/tmp/file")
}
// With a result via Deferred
val deferred = viewModelScope.rootAsync { readRootDatabase() }
val rows = deferred.await()Signatures:
fun CoroutineScope.rootLaunch(block: RootCallable<Unit>): Job
fun <T> CoroutineScope.rootAsync(block: RootCallable<T>): Deferred<T>Returns a cold Flow<T> that executes the callable on each collection and emits a single value.
rootFlow { File("/proc/version").readText() }
.onEach { version -> textView.text = version }
.launchIn(lifecycleScope)
// Combine with other operators
rootFlow { getPrivilegedData() }
.map { it.transform() }
.catch { e -> showError(e) }
.flowOn(Dispatchers.IO)
.collect { result -> updateUi(result) }Signature:
fun <T> rootFlow(block: RootCallable<T>): Flow<T>Executes the block in the root process, blocking the calling thread. Must not be called on the main thread.
// On a background thread / Worker / HandlerThread:
val exists = rootBlocking { File("/system/bin/su").exists() }Signature:
@Throws(IOException::class, InterruptedException::class)
fun <T> rootBlocking(block: RootCallable<T>): T?Receiver-scoped blocking variant.
val packages = packageManager.rootBlocking { pm ->
pm.getInstalledPackages(0)
}Signature:
@Throws(IOException::class, InterruptedException::class)
fun <T, R> T.rootBlocking(block: RootConsumer<T, R>): R?Blocking execution with a deadline. Throws TimeoutException if the root process does not respond in time.
val result = rootBlocking(5, TimeUnit.SECONDS) { readHeavyRootFile() }Signature:
@Throws(IOException::class, InterruptedException::class, TimeoutException::class)
fun <T> rootBlocking(timeout: Long, unit: TimeUnit, block: RootCallable<T>): T?Groups multiple root calls into a structured block. Each exec { } call is an independent IPC round-trip but they share a readable sequential scope.
val data = rootBlock {
val hasSu = exec { File("/system/bin/su").exists() }
val kernel = exec { File("/proc/version").readText() }
val modules = exec { File("/data/adb/modules").listFiles()?.size ?: 0 }
mapOf(
"hasSu" to hasSu,
"kernel" to kernel,
"modules" to modules,
)
}Signatures:
suspend fun <T> rootBlock(block: suspend RootBlockScope.() -> T): T
class RootBlockScope {
suspend fun <T> exec(block: RootCallable<T>): T
}Result-wrapped variants for railway-oriented error handling. Never throw — failures are delivered as Result.failure.
// Suspend
rootThreadCatching { riskyRootOperation() }
.onSuccess { result -> updateUi(result) }
.onFailure { error -> Log.e(TAG, "Root failed", error) }
// Blocking (off main thread)
val result = rootBlockingCatching { File("/proc/version").readText() }
if (result.isSuccess) {
textView.text = result.getOrNull()
}Signatures:
suspend fun <T> rootThreadCatching(block: RootCallable<T>): Result<T>
fun <T> rootBlockingCatching(block: RootCallable<T>): Result<T>Syntactic sugar allowing RootThread to be called like a function inside any suspend context.
// Equivalent to rootThread { ... }
val result = RootThread { doPrivilegedWork() }Suspends a coroutine until a Future<T> (returned by RootThread.submit()) completes.
Implemented with suspendCancellableCoroutine — no kotlinx-coroutines-jdk8 dependency required.
- Runs
Future.get()onDispatchers.IOso the main thread is never blocked. - Cancels the
Futureif the coroutine is cancelled. - Unwraps
ExecutionExceptionso callers see the real cause.
val future = RootThread.submit<String> { readPrivilegedFile() }
// Cancel if needed:
future.cancel(true)
// Or await in a coroutine:
val result = future.awaitRoot()Signature:
suspend fun <T> Future<T>.awaitRoot(): TSubmits a callable to the root process and returns a Future<T> immediately. The future resolves with the result or fails with an IOException.
Future<Boolean> future = RootThread.submit(() ->
new File("/system/bin/su").exists()
);
// Optional cancellation
future.cancel(true);
// Join elsewhere (not on main thread)
boolean result = future.get(5, TimeUnit.SECONDS);Signature:
public static <T> Future<T> submit(@NonNull RootCallable<T> callable)Submits a callable and blocks the calling thread until the result is available. Must not be called on the main thread.
executorService.execute(() -> {
try {
String kernel = RootThread.executeBlocking(
() -> new String(Files.readAllBytes(Paths.get("/proc/version")))
);
runOnUiThread(() -> textView.setText(kernel));
} catch (IOException | InterruptedException e) {
Log.e(TAG, "Root IPC failed", e);
}
});Signature:
public static <T> T executeBlocking(@NonNull RootCallable<T> callable)
throws IOException, InterruptedExceptiontry {
Boolean exists = RootThread.executeBlocking(
() -> new File("/system/bin/su").exists(),
5, TimeUnit.SECONDS
);
} catch (TimeoutException e) {
Log.e(TAG, "Root process timed out");
} catch (IOException | InterruptedException e) {
Log.e(TAG, "IPC error", e);
}Signature:
public static <T> T executeBlocking(
@NonNull RootCallable<T> callable,
long timeout,
@NonNull TimeUnit unit
) throws IOException, InterruptedException, TimeoutExceptionAsync fire-and-forget with an optional callback delivered on a specified Executor (or the main thread by default).
// Callback on main thread (default)
RootThreadExtensions.rootLaunch(
() -> readRootData(),
new RootThreadExtensions.RootCallback<String>() {
@Override public void onSuccess(String result) {
textView.setText(result); // main thread
}
@Override public void onFailure(Throwable error) {
Log.e(TAG, "Failed", error);
}
}
);
// Callback on a custom executor
Executor dbExecutor = Executors.newSingleThreadExecutor();
RootThreadExtensions.rootLaunch(
() -> readRootDatabase(),
new RootThreadExtensions.RootCallback<List<Row>>() {
@Override public void onSuccess(List<Row> rows) {
dao.insertAll(rows); // already on dbExecutor
}
@Override public void onFailure(Throwable e) { /* handle */ }
},
dbExecutor
);Signatures:
// Callback on main thread
public static <T> Future<T> rootLaunch(
@NonNull RootCallable<T> callable,
@Nullable RootCallback<T> callback
)
// Callback on custom executor
public static <T> Future<T> rootLaunch(
@NonNull RootCallable<T> callable,
@Nullable RootCallback<T> callback,
@NonNull Executor executor
)
// Fire and forget, no callback
public static Future<Void> rootLaunch(@NonNull RootCallable<Void> callable)Receiver-scoped blocking execution. Equivalent to the Kotlin T.rootBlocking { } extension.
PackageManager pm = getPackageManager();
executorService.execute(() -> {
try {
List<PackageInfo> packages = RootThreadExtensions.rootBlocking(
pm,
manager -> manager.getInstalledPackages(PackageManager.GET_PERMISSIONS)
);
runOnUiThread(() -> adapter.setData(packages));
} catch (IOException | InterruptedException e) {
Log.e(TAG, "Failed", e);
}
});Signatures:
public static <T, R> R rootBlocking(
@NonNull T receiver,
@NonNull RootConsumer<T, R> block
) throws IOException, InterruptedException
public static <T, R> R rootBlocking(
@NonNull T receiver,
@NonNull RootConsumer<T, R> block,
long timeout,
@NonNull TimeUnit unit
) throws IOException, InterruptedException, TimeoutExceptionLifecycle-aware bind/unbind as a static method (Java equivalent of the Kotlin extension).
RootThreadExtensions.addRootThread(this, context);The preferred Java approach. Stores applicationContext internally to prevent leaks.
// Activity
getLifecycle().addObserver(new RootThreadLifecycleObserver(this));
// Fragment
getViewLifecycleOwner().getLifecycle()
.addObserver(new RootThreadLifecycleObserver(requireContext()));// Kotlin extension — equivalent one-liner
addRootThread(requireContext())For cases where lifecycle integration is not appropriate (services, background components):
RootThread.bind(context); // call when ready
RootThread.unbind(); // call when doneRootThread.bind(context)
RootThread.unbind()| Layer | Thread |
|---|---|
| Caller (Kotlin) | Any — dispatched to Dispatchers.IO internally |
| Caller (Java async) | RootThread cached executor (RootThread-IPC threads) |
| Caller (Java blocking) | Caller's thread — must not be main thread |
| Root service | Binder thread (returns immediately); work on RootThread-Worker daemon thread |
The root service spawns a new named daemon thread per call so the Binder thread is never parked, eliminating ANR risk.
createPipe() → [callableRead, callableWrite]
createPipe() → [resultRead, resultWrite ]
Caller:
write callable → callableWrite → (AutoCloseOutputStream closes it, sends EOF)
svc.execute(callableRead, resultWrite) ← service owns these two from here
read result ← resultRead ← caller owns this until done
On error before execute():
caller closes all four FDs
KryoManager is a pre-configured Kryo instance:
| Setting | Value |
|---|---|
| Registration required | false (class names are written to the stream) |
| References | true (handles cyclic graphs in non-Parcelable objects) |
| Instantiation strategy | DefaultInstantiatorStrategy + StdInstantiatorStrategy (no-arg constructor not required) |
Parcelable serialiser |
Custom ParcelableSerializer — uses Parcel.marshall() / unmarshall() |
A fresh KryoManager instance is used for each write and each read, keeping reference tables completely independent across the pipe boundary.
| Error scenario | Behaviour |
|---|---|
| Remote callable throws | Exception is serialised and re-thrown as IOException("Remote exception", cause) |
| IPC write fails | IOException("IPC write/execute failed", cause) |
| Deserialisation fails in root | IOException("Deserialisation failed in root process", cause) |
| Root service disconnects | CompletableFuture is replaced; next call blocks until reconnect |
| Coroutine cancelled | Future.cancel(true) is called; CancellationException propagates normally |
InterruptedException |
Thread interrupt flag is restored; wrapped as CancellationException in coroutine context |
Serialisation
RootCallableandRootConsumerlambdas must be Kryo-serializable. Do not captureContext,View, or any non-serializable object.- Prefer capturing primitive values or
Parcelableobjects. For complex objects, pass them as the receiver viaT.rootThread { }orrootBlocking(receiver) { }.
Threading
- Never call
executeBlockingorrootBlockingon the main thread — they block the calling thread. - Prefer
rootThread { }(Kotlin suspend) orrootLaunch(Java async) in UI code.
Lifecycle
- Always use
RootThreadLifecycleObserveroraddRootThread()to ensure the service is unbound when the component stops. Failing to unbind leaks the root process connection. RootThreadLifecycleObserverstoresapplicationContextinternally — passing an Activity context is safe.
Cancellation
rootLaunch/rootAsyncrespect coroutine cancellation: the underlyingFutureis canceled and the root worker thread is interrupted.rootFlowis cold — collection starts a new IPC round-trip each time.