Add Porter support
Porter runs a service at the ADB shell or root identity and hands your app a Binder to it. Your app asks for permission once, the user approves it, and from then on it can run commands, run its own code or forward Binder transactions at that identity.
Contributions and integrations from other app developers are welcome.
1. Add the SDK
Add JitPack to your repositories in settings.gradle.kts, restricted to the Porter SDK group:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://jitpack.io") {
content { includeGroup("com.github.d4rken-org.porter-api") }
}
}
}
Then add the SDK:
implementation("com.github.d4rken-org.porter-api:sdk-extras:+")
The examples below use sdk-extras, which brings sdk with it and adds shell commands,
PorterSystemServices and typed system property getters. Depend on sdk alone if you need none of
those. Pin an exact version from the
releases page rather than +, so your build
does not move under you.
The SDK needs Android 7.0 (API 24) or newer. It is coroutines and Flow throughout; use from Java
is not supported.
There is nothing to add to your manifest; the SDK brings its own entries.
To connect to an original Shizuku server as well, see Shizuku. If your app already
depends on dev.rikka.shizuku:provider, do not add shizuku-compat.
2. Wait for the connection
Your app gets a connection once Porter’s service is running, and a new one whenever the user
restarts Porter while your app is alive. Porter.state is a StateFlow<PorterConnectionState>:
Connected with the connection to use, Incompatible while a running service and your app’s SDK
share no protocol version, and Disconnected otherwise. Collect it rather than reading it once.
class MyActivity : ComponentActivity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
Porter.state.collect { connectionState ->
when (connectionState) {
is PorterConnectionState.Connected -> onPorterReady(connectionState.connection)
is PorterConnectionState.Incompatible -> showIncompatible(connectionState.incompatibility)
PorterConnectionState.Disconnected -> showDisconnected()
}
}
}
}
}
}
Handle Disconnected: Porter can stop at any time. After a restart, use the new connection from
the flow rather than the old one. Incompatible says which side needs an update; section 3 shows
how to word it. Porter.connection is the same connection on its own, as a
StateFlow<PorterConnection?>, for code that needs nothing else.
Every call on a connection that reaches the server suspends and is safe on the main thread. A failed
call throws a PorterException: PorterSecurityException when the server refused it, usually
because your app has no grant, and PorterRemoteException when the Binder call itself failed.
Cancelling a call returns at once, so withTimeout works against a server that stopped answering.
3. Tell the user why nothing happened
Porter.state reports an incompatible service as soon as it arrives. Porter.availability(context)
also distinguishes the cases behind a connection that never arrives:
lifecycleScope.launch {
when (val availability = Porter.availability(this@MyActivity)) {
is PorterAvailability.Connected -> Unit // a connection is held and answers
is PorterAvailability.InstalledNotConnected -> offerToOpen(availability.packageName, "Start the service")
PorterAvailability.NotInstalled -> promptUser("Install Porter")
is PorterAvailability.InstalledUnrecognized -> promptUser("An unrecognized app owns that permission")
is PorterAvailability.Incompatible -> if (availability.incompatibility.serverTooOld) {
promptUser("Update Porter")
} else {
promptUser("This app needs an update to work with this Porter")
}
}
}
InstalledNotConnected is the normal state before the user starts Porter; its packageName is
the manager app to open. InstalledUnrecognized means a package this SDK does not recognize owns
Porter’s permission; do not present it as the manager.
4. Ask for permission
A connection is not access. The user sees a dialog, so the request suspends until they answer.
suspend fun onPorterReady(connection: PorterConnection) {
var state = connection.checkPermission()
if (state is PermissionState.Denied) {
if (state.permanentlyDenied) {
// The user denied it and asked not to be asked again. Explain why you need it
// and point them at Porter's own screen.
explainWhyWeNeedIt()
return
}
state = try {
connection.requestPermission()
} catch (e: PorterConnectionLostException) {
return // Porter restarted while the dialog was up; the next connection arrives on the flow
}
}
if (state is PermissionState.Granted) doPrivilegedWork(connection)
}
Catch PorterConnectionLostException: uncaught, it ends the coroutine collecting
Porter.state, and the replacement connection is never handled.
connection.permission is a StateFlow<PermissionState> a screen can observe. The user can revoke
access at any time, so handle a refused call rather than trusting an earlier check.
5. Do privileged work
A shell command
sdk-extras runs a command at Porter’s identity and returns its exit code and output:
val result = connection.exec("sh", "-c", "pm list packages -3")
if (result.exitCode == 0) show(result.output) else log(result.errors)
There is nothing to declare or implement for this. A command that is not found exits with 127, as
in a shell. A working directory that does not exist, or a shell service that stopped answering,
throws PorterShellException, which is not a PorterException; a missing grant throws
PorterSecurityException. Cancelling exec kills the command, so withTimeout bounds one that
hangs.
For input, binary output or a command that runs until you stop it, connection.startProcess(...)
returns a PorterShellProcess, a java.lang.Process whose streams are pipes to the command. Read
its output as it comes, or the command blocks once a pipe is full:
val logcat = connection.startProcess("logcat", "-v", "brief")
withContext(Dispatchers.IO) {
try {
logcat.inputStream.bufferedReader().useLines { lines -> lines.take(100).forEach(::show) }
} finally {
logcat.destroy()
}
}
A started command is yours to stop. destroy() sends SIGKILL, which gives it no chance to clean up;
a command that has to finish something, such as screenrecord, gets a signal(OsConstants.SIGINT)
once it is running, and you wait for it to exit before cleaning up. A running command also dies with
the app process that started it. Once it has exited, neither destroy() nor the app’s death reaches
what it left running, so stop those yourself.
For structured results or many calls in a row, run your own service instead.
Your own service, at Porter’s identity
Define an AIDL interface, implement it, and let Porter run it in a process with shell or root identity. This needs no hidden API and no platform stubs.
// IMyService.aidl
interface IMyService {
void destroy() = 16777114; // Porter sends this to stop the service
String readFile(String path) = 1;
}
class MyService : IMyService.Stub() {
override fun destroy() = exitProcess(0)
override fun readFile(path: String): String = File(path).readText()
}
val args = UserServiceArgs(
componentName = ComponentName(this, MyService::class.java),
processNameSuffix = "my-service",
tag = "my-service",
version = 1,
)
connection.userService(args).collect { binder ->
val service = IMyService.Stub.asInterface(binder)
service.readFile("/proc/net/tcp") // readable as shell, not from your app's own process
}
userService(args) is a cold Flow<IBinder>: collecting it starts the service if needed and emits
its Binder. The flow completes when the service dies, and when its connection is replaced or dies.
Collect it again on the same connection after the service died, or on the next one after a restart.
To keep one service for the whole app:
val myService: StateFlow<IMyService?> = Porter.connection
.flatMapLatest { connection ->
if (connection == null) return@flatMapLatest flowOf(null)
connection.permission.flatMapLatest { permission ->
if (permission !is PermissionState.Granted) return@flatMapLatest flowOf(null)
flow {
while (true) {
emitAll(
connection.userService(args)
.map { IMyService.Stub.asInterface(it) }
.catch { e -> if (e !is PorterException) throw e },
)
emit(null) // the service died or could not be bound; try again
delay(1_000)
}
}
}
}
.stateIn(appScope, SharingStarted.WhileSubscribed(30_000), null)
Implement destroy to clean up and exit: stopUserService(args) calls it, and Porter kills a
process still running a few seconds later. Cancelling the collection does not stop the process. A
service ends with the last app process still collecting it, unless you set daemon = true. One you
stopped collecting runs on until you stop it, your app loses its permission or is uninstalled, or
Porter stops.
Bump version whenever the service code changes, so Porter replaces a running instance. Porter
identifies a service by its tag, or its class name when no tag is set, so set a stable tag if the
class is obfuscated.
connection.uid is 2000 for ADB and 0 for root, and shell can do far less than root: it cannot
read other apps’ data, and its Android permissions are those of the
Shell package.
The service process is not an Android application process: a Context there cannot register
receivers or reach a content resolver.
Forwarding calls to a system service
connection.wrap(binder) wraps a system service’s Binder so Porter re-issues every transaction on it
at its own identity. PorterSystemServices from sdk-extras does the lookup:
val binder = PorterSystemServices.getSystemService("package") ?: return
val pm = IPackageManager.Stub.asInterface(connection.wrap(binder))
pm.getInstalledPackages(0, 0)
These calls block like any Binder call, so make them off the main thread. A refusal arrives as the
platform’s SecurityException, not as a PorterException.
Interfaces like IPackageManager are platform-internal, so this route needs compile-time stubs and a
way past the non-SDK interface restrictions, such as
HiddenApiRefinePlugin and
AndroidHiddenApiBypass. For file or process
access, a shell command or your own service needs neither.
Apps with several processes
Only one process receives the connection. In every other process, call:
PorterApiProvider.requestBinderForNonProviderProcess(context)
The connection then arrives on Porter.connection and Porter.state there too. Calling it in the
process that receives the connection does nothing, so one call site for every process is fine. The
first call also listens for each connection that process receives later, so a process that started
before Porter still gets one. A refused service is not forwarded: while the receiving process
holds only a refusal, a process that fetches its connection this way stays Disconnected, and its
Porter.availability does not report Incompatible either.
Shizuku
The SDK can also take a Binder from an original Shizuku server. An app without upstream’s
dev.rikka.shizuku:provider adds shizuku-compat and this block in its manifest; without the
permission and the meta-data, a Shizuku server refuses your app:
implementation("com.github.d4rken-org.porter-api:shizuku-compat:+")
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
<application>
<meta-data android:name="moe.shizuku.client.V3_SUPPORT" android:value="true" />
<provider
android:name="eu.darken.porter.sdk.PorterShizukuApiProvider"
android:authorities="${applicationId}.shizuku"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
</application>
Declaring that provider without moe.shizuku.api.BinderContainer on the classpath, which
shizuku-compat ships, crashes your app on launch, whether or not Porter or Shizuku is installed.
dev.rikka.shizuku:provider ships the same class, so the two cannot both be in one app, including
through another library. Without both the artifact and the provider, a Shizuku-only device reads
NotInstalled.
When both are installed, the SDK uses Porter, even when it is stopped, so tell those users to start Porter.
Uninstalling Porter ends your app if the user allowed it in Porter. Porter’s permission is a runtime permission, and Android kills an app when it revokes one the app holds. Your app reaches Shizuku the next time it starts. A process that holds no Porter permission, because the user denied it or was never asked, keeps running and switches to Shizuku once Porter’s server has exited.
An app that keeps upstream’s dev.rikka.shizuku:api and :provider, for example for a library
built on them, can use this SDK for Porter alone. Leave out shizuku-compat and
keep upstream’s ShizukuProvider. Each client then gets its own server’s Binder, and
Porter.availability(context) answers only about Porter.
Code written against Shizuku-API
Code written against upstream’s dev.rikka.shizuku:api, including libraries built on it, can run on
Porter unchanged through shizuku-bridge. It brings sdk, sdk-extras and
dev.rikka.shizuku:api:
implementation("com.github.d4rken-org.porter-api:shizuku-bridge:+")
Start it once per process, with a scope that lives as long as the process:
class App : Application() {
override fun onCreate() {
super.onCreate()
PorterShizukuBridge.start(appScope)
}
}
From then on each Porter connection reaches rikka.shizuku.Shizuku as its server: permission
requests go through Porter’s dialog, and Binder forwarding, system properties and shell processes
run at Porter’s identity. Upstream’s user services do not work through the bridge; use Porter’s own.
A Porter connection takes the place of a Shizuku server’s Binder, so an app that wants the Shizuku
server instead does not start the bridge. The
API reference
lists what the bridge answers and its limits.
Versions
SDK release numbers are independent of Porter’s own app version. While the SDK is 0.x:
- A minor release can add API and change behaviour this guide documents. Read the release notes before bumping.
- An app built against SDK 0.7.0 or a later
0.xkeeps working with a newer Porter, and a newer SDK does not require a newer Porter unless a release note says so. An app built against 0.1.0 does not connect to Porter 0.7.0 or newer; move it to the current SDK. - Compatibility is at source level only: a library compiled against an earlier
0.xhas to be recompiled against the new SDK.
Before you ship
- Porter installed and running: approval, a real privileged operation and your user service all work.
- Porter installed but stopped: your app says so and offers to open Porter.
- Porter not installed: your app offers to install it.
- Access denied, and denied with “don’t ask again”: privileged work stops and your UI reflects it.
- Access revoked while running: the next privileged call is refused and your UI recovers.
- Porter restarted while your app is alive: your collector picks up the new connection.
- Your activity recreated: the new collector sees the held connection at once.
- Secondary processes: each one obtains the connection, even one that started before Porter.
- Your user service: replaced when you bump its version, and gone after
stopUserService.
Link users to the setup guide. Keep instructions for your app’s own settings, and which of your app versions support Porter, in your own documentation.
For the full Kotlin surface, see the API reference.