Startup methods
Every startup method starts the same app-owned Privileged Server and converges on the same Binder handoff. The difference is how the initial privileged process is created.
Start with Privilege UI
Most applications should use priv-ui. Embed PrivilegeScaffold to get the Root, Wireless Debugging, TCP/IP, Manual, and external-provider flows with their status, permissions, pairing, confirmations, and error presentation already coordinated. See Privilege UI for setup and configuration.
The APIs below are for applications that replace the supplied authorization page.
Build a custom interface with priv-core
When an application uses priv-core directly, the host owns every permission prompt, pairing input, confirmation surface, polling loop, and error state. priv-core provides the runtime and transport operations only.
Observe the connection state
Privilege.serverState is a process-wide StateFlow<PrivilegeServerInfo?>. A non-null value means the Privileged Server is connected; null means it is disconnected. Every new collector immediately receives the current value.
PrivilegeServerInfo is a data class with a diagnostic-friendly toString. Its selinuxContext contains the SELinux context read and cached once per server process, or null when the platform cannot provide it. Owner reconnects reuse the cached diagnostic value. Core constructs connection snapshots and keeps both the constructor and copy internal.
Application-wide observation
When connection changes must trigger work for the lifetime of the application process, collect from an application-owned coroutine scope:
val appScope = MainScope()
class App : Application() {
override fun onCreate() {
super.onCreate()
appScope.launch {
Privilege.serverState.collect { serverInfo ->
if (serverInfo != null) {
// Connected. Initialize application-level privileged features.
} else {
// Disconnected. Suspend features that require the server.
}
}
}
}
}Screen-local rendering
When a Compose screen only needs to render the current state, use a lifecycle-aware collector:
val serverInfo by Privilege.serverState.collectAsStateWithLifecycle()Coordinate an owner-process restart
When the application deliberately restarts its owner process, schedule that restart first and then notify the connected server immediately before terminating the process:
scheduleApplicationRestart()
Privilege.prepareOwnerRestart(
passiveReconnectTimeoutMillis = 10_000,
)
Process.killProcess(Process.myPid())The call returns after the server acknowledges the plan. If the owner dies within five seconds, the server waits passively for the requested interval so the application's restart policy remains the only process-start source. Give the interval enough headroom for the scheduled restart. If the owner does not reconnect before it expires, configured active reconnect resumes for the remainder of PrivilegeConfig.followDeathDelayMillis. A plan expires without effect when the owner does not die within five seconds, and a matched planned restart does not count toward the crash-loop circuit.
Inspect denied server permissions
After the server connects, a custom host can inspect permissions declared by packages associated with the server UID but denied to the server process:
val deniedPermissions = withContext(Dispatchers.IO) {
Privilege.getDeniedServerPermissions()
}The result is a distinct list sorted by permission name. Root servers return an empty list. Failure to resolve package metadata for a non-root server is reported instead of being treated as an unrestricted result.
This inventory covers Android permission grants only. It does not inspect AppOps, SELinux policy, or authorization enforced inside individual system services, so an empty list does not guarantee that every operation will succeed. Use Privilege.checkServerPermission(permission) when the host needs to test one known permission directly.
Root
val serverInfo = Privilege.startRoot()Root startup checks the available su path, launches the shared server command, and waits for the normal Binder handoff.
ADB
ADB supports two modes: Wireless Debugging and a static TCP/IP port.
Wireless Debugging
Wireless Debugging requires Android 11 or later. Priv Kit stores one ADB key for the application. The device must authorize that key before it can start a Privileged Server.
Pair the application
Ask the user to open Developer options > Wireless debugging > Pair device with pairing code. While the pairing screen is open, pass its six-digit code to PrivilegeAdbManager.pair():
val adbManager = Privilege.createAdbManager()
val pairingResult = adbManager.pair(
pairingCode = pairingCode,
)pair() discovers the Wireless Debugging pairing port by default. A host that already discovered the port can make the endpoint explicit:
val pairingPort = adbManager.discoverPairingPort()
adbManager.pair(
pairingCode = pairingCode,
port = pairingPort,
)Use checkPairing() to check whether the persisted application key is already authorized:
val pairing = adbManager.checkPairing()
if (!pairing.paired) {
// Show the pairing flow before starting.
}For repeated status checks, openPairingCheckSession() keeps one connection alive between calls. Close the session when polling stops.
Start with Wireless Debugging
val serverInfo = Privilege.startAdb()Pairing and startup are separate operations, so a successful pair() does not start the server.
When the host declares and already holds WRITE_SECURE_SETTINGS, the default PrivilegeAdbWirelessDebuggingControl.IF_AVAILABLE policy can temporarily enable Wireless Debugging, discover the connect port, and disable Wireless Debugging after the start attempt. NEVER leaves the setting untouched. REQUIRE makes managed Wireless Debugging a startup prerequisite.
After the Privileged Server connects, the runtime attempts to grant WRITE_SECURE_SETTINGS to the owner app when the permission remains declared and the server is root or holds android.permission.GRANT_RUNTIME_PERMISSIONS. Without this capability, the user must turn on Wireless Debugging before discovery.
On Android releases that require runtime approval for local network access, a host must request ACCESS_LOCAL_NETWORK before pairing or startup.
TCP/IP startup
A TCP/IP start connects to a fixed local ADB port instead of discovering the dynamic Wireless Debugging connect port. The default static port is available as PRIVILEGE_ADB_DEFAULT_TCP_PORT.
Open or restore the static port
If the port is not configured yet, switch an already authorized ADB endpoint to TCP/IP mode:
val tcpPort = PRIVILEGE_ADB_DEFAULT_TCP_PORT
val adbManager = Privilege.createAdbManager()
adbManager.switchToTcp(tcpPort = tcpPort)switchToTcp() needs an authorized Wireless Debugging or existing TCP connection from which it can issue adb tcpip. Starting or restarting this endpoint affects other ADB-backed processes, so a custom host presents its own confirmation before calling it. When the source connection port is already known, pass it through options = PrivilegeAdbConnectionOptions(port = sourcePort).
When a static port was configured earlier, prepare it before startup:
val authorization = adbManager.prepareTcpForStart(tcpPort = tcpPort)prepareTcpForStart() probes the port. If a persisted port matches but adbd is no longer listening, Priv Kit can restore the core ADB service when the application has managed ADB capability. It does not enable Wireless Debugging.
If the result is PrivilegeAdbAuthorizationStatus.UNAUTHORIZED, request authorization and wait for the user to accept the system prompt:
val request = adbManager.requestTcpAuthorization(tcpPort = tcpPort)
check(request.authorized) {
request.failureMessage ?: "ADB authorization was not completed"
}Start on the static port
Pass the port explicitly. A non-null port skips Wireless Debugging discovery:
val serverInfo = Privilege.startAdb(
options = PrivilegeAdbConnectionOptions(
port = tcpPort,
),
)Use checkTcpAuthorization() when the host only needs a one-shot status check. Use openTcpAuthorizationCheckSession() for repeated polling. stopTcp(tcpPort) stops the static listener. restartTcp(tcpPort) restarts it on the same port. Both controls can accept PrivilegeAdbConnectionOptions; they try the static endpoint first and only fall back before the control command is dispatched.
Manual
val nativeStarterCommand = withContext(Dispatchers.IO) {
Privilege.nativeStarterCommand
}
YourApp.showCommandToUser("adb shell $nativeStarterCommand")priv-core returns a device-side command. On Android 10 and later it can use the platform linker to run the starter directly from an APK, or execute the installed SO when legacy packaging extracted it. The host adds adb shell when presenting the command for a development machine. The starter accepts root (UID 0), system (UID 1000), and shell (UID 2000). See native library packaging for the Android-version requirements. Resolve the command off the main thread because first access inspects the installed APKs. User 0 uses the default owner scope without an owner-user environment variable. A non-primary Android user is embedded explicitly, so the command remains correctly scoped when it is executed while the application process is not running.
Running the starter again first sends SIGKILL to the exact owner-scoped server process and verifies that it exited. Its readable name ends with <package>:priv-server for user 0 and adds -u<ownerUserId> only for a non-primary user. An internal package/user token keeps discovery scoped even when only /proc/<pid>/comm is readable. The replacement starts after that verification. A failed stop leaves the old server in place. Packages installed for other Android users have different scoped process names and stay outside the match.
With modern packaging on Android 10 or later, a rendered command can look like:
adb shell /system/bin/linker64 '/data/app/.../base.apk!/lib/arm64-v8a/libprivkitstarter.so'With legacy packaging it looks like:
adb shell /data/app/~~-YKUdRFBwGAwYBVzJRt7pA==/priv.kit.sample.debug-A-2guZlsvRZ-9e6xF-K0kQ==/lib/arm64/libprivkitstarter.soExternal
External startup is an extension of Manual. Instead of asking the user to run the native starter command, the application uses an external authorizer to execute the same command inside its privileged process. Both methods enter the same Privileged Server startup and Binder handoff path.
The external authorizer can be any app-owned privileged entry capable of running that command as root, system, or shell. The application owns third-party authorization, binding, and access control, while priv-core supplies the command bridge. The following example assumes Shizuku is already available and authorized. Its key step is a Shizuku UserService that exposes the Priv Kit startup bridge.
Define the UserService
The app-owned AIDL carries the command, output pipes, and completion receiver into the Shizuku UserService:
interface IPrivilegeShizukuStartService {
void start(
String commandLine,
in ParcelFileDescriptor stdout,
in ParcelFileDescriptor stderr,
in ResultReceiver resultReceiver
) = 1;
void destroy() = 16777114;
}Implement that endpoint with PrivilegeExternalStartupHost:
class PrivilegeShizukuStartService @Keep constructor() :
IPrivilegeShizukuStartService.Stub() {
private val host = PrivilegeExternalStartupHost()
override fun start(
commandLine: String,
stdout: ParcelFileDescriptor,
stderr: ParcelFileDescriptor,
resultReceiver: ResultReceiver,
) {
host.start(commandLine, stdout, stderr, resultReceiver)
}
override fun destroy() {
host.close()
exitProcess(0)
}
}Bind the UserService and start
Describe the Shizuku UserService with a stable tag and a version that changes when its implementation or AIDL contract becomes incompatible:
val args = Shizuku.UserServiceArgs(
ComponentName(
context.packageName,
PrivilegeShizukuStartService::class.java.name,
),
)
.daemon(false)
.tag("priv-kit-external-start")
.processNameSuffix("priv-kit-shizuku-start")
.version(1)
Shizuku.bindUserService(args, serviceConnection)After ServiceConnection returns the AIDL interface, pass its start() method to the Priv Kit bridge:
val nativeStarterCommand = withContext(Dispatchers.IO) {
Privilege.nativeStarterCommand
}
PrivilegeExternalStartup.runThroughBridge(
commandLine = nativeStarterCommand,
bridge = { commandLine, stdout, stderr, resultReceiver ->
shizukuService.start(commandLine, stdout, stderr, resultReceiver)
},
)Close the connection with Shizuku.unbindUserService(...) when the request is finished. A complete implementation is available in the sample: starter, privileged endpoint, and AIDL contract.