Fix HID transfers, harden the tunnel, add E2E crypto and direct peers
The HID failure came down to the endpoint type map being indexed by endpoint number without the direction bit. A composite device can have endpoint 1 as both interrupt IN (0x81) and bulk OUT (0x01); the last one read won, so interrupt URBs were submitted as bulk and the kernel rejected them. The device attached and stayed silent. Endpoint data now comes from the raw descriptors read from /dev/bus/usb rather than sysfs, which only ever exposes the active alternate setting — a webcam's isochronous endpoints are invisible there because they only exist after SET_INTERFACE. Two sysfs parsing bugs fell out of that too: the numeric endpoint attributes are hex without a prefix (wMaxPacketSize "0040" was read as 40, not 64), and bInterval was never read at all. Reliability: three places could freeze the whole process. The share path fed io.Pipe from the WebSocket read loop, so one slow USB transfer stalled every tunnel and the keepalives with them. The relay wrote to client sockets while holding the hub lock, so one peer that stopped reading blocked routing and registration for everyone. Control transfers ran inline in the protocol loop behind a 5s timeout. Also fixed: a use-after- free where a discarded URB's memory could be collected while the kernel still owned it, a reap loop that spun at 100% CPU on ioctl errors, a missing attach timeout, a double close(done) panic, and Hash[:8] in the relay's log line, which let a client with a short hash take the server down. Adds mode "both", so one client can offer and consume devices at once. The tunnel and client-left callbacks became multicast for it: as plain fields the second manager to register silently unhooked the first. Tunnel traffic is now AES-256-GCM end to end, on the relay path as well as directly. The key is derived from the three tokens, not from the group hash — the relay is told the hash, so a key derived from it would protect nothing from the one party in the middle. Group IDs are unchanged, so existing setups keep working; only clients configured without the tokens drop to unencrypted, relay-only operation. Peers now try to connect directly, with the relay supplying the public address neither side can determine for itself. Candidates are raced because an unreachable address hangs until timeout rather than refusing. Falling back to the relay is not an error. Platform reach: cross-compiled targets for ARM, MIPS and RISC-V (the Linux client needed no code changes — usbdevfs is not architecture specific), multi-arch Docker images, an Android bridge that accepts devices over SCM_RIGHTS because apps cannot open /dev/bus/usb, and macOS builds via system_profiler enumeration. Adds a Windows KMDF filter driver under driver/windows with its Go side. UNTESTED: it has never been compiled or run, needs the WDK to build and an EV certificate to distribute. Treat it as a starting point. Adds "usb-client diag": says per machine whether sharing and using are possible, what stands in the way, and what fixes it. Reports can be uploaded to a relay to get them off machines that are awkward to copy from. 96 tests, all green under -race. Builds for linux, windows and darwin on amd64 and arm64. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
app/src/main/jniLibs/*/libusbclient.so
|
||||
@@ -0,0 +1,70 @@
|
||||
# Android-Share
|
||||
|
||||
Android ist Linux, und das `usb-client`-Binary laeuft dort unveraendert
|
||||
(`GOARCH=arm64`). Der Haken sitzt woanders: eine App darf `/dev/bus/usb` nicht
|
||||
oeffnen und sysfs nicht durchsuchen. Geraetezugriff laeuft ausschliesslich
|
||||
ueber das Framework, das einen Berechtigungsdialog zeigt und einen **bereits
|
||||
geoeffneten Dateideskriptor** zurueckgibt.
|
||||
|
||||
Diese App tut deshalb genau drei Dinge:
|
||||
|
||||
1. Geraete ueber `UsbManager` auflisten und Berechtigung erfragen
|
||||
2. Deskriptor und Rohdeskriptoren an das Go-Binary uebergeben
|
||||
3. Das Binary als Kindprozess starten und am Leben halten
|
||||
|
||||
Das Go-Binary macht danach alles Weitere selbst — es spricht dieselben
|
||||
usbdevfs-ioctls wie auf jedem anderen Linux, nur der Weg zum Dateideskriptor
|
||||
ist ein anderer.
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ App (Kotlin) │
|
||||
│ UsbManager │
|
||||
│ → Berechtigungsdialog │
|
||||
│ → openDevice() │
|
||||
│ → getRawDescriptors() │
|
||||
└───────────┬──────────────┘
|
||||
│ Unix-Socket, fd per SCM_RIGHTS
|
||||
┌───────────▼──────────────┐
|
||||
│ usb-client (Go, arm64) │
|
||||
│ usbdevfs-ioctls auf fd │
|
||||
│ → Relay / Direkttunnel │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Der Go-seitige Teil ist fertig und getestet (`internal/bridge`). Was hier
|
||||
liegt, ist die App-Seite als **Referenzimplementierung**: der Code ist
|
||||
vollstaendig, aber ich konnte ihn nicht bauen oder auf einem Geraet laufen
|
||||
lassen. Er braucht Android Studio, ein Geraet mit USB-OTG und vermutlich ein
|
||||
paar Korrekturen. Die Protokollseite ist der verlaessliche Teil — sie hat
|
||||
Tests.
|
||||
|
||||
## Bauen
|
||||
|
||||
```bash
|
||||
# 1. Go-Binary fuer Android bauen und in die App legen
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" \
|
||||
-o android/app/src/main/jniLibs/arm64-v8a/libusbclient.so ./cmd/usb-client/
|
||||
|
||||
# 2. App bauen
|
||||
cd android && ./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Die Endung `.so` ist kein Versehen: Android extrahiert nur Dateien aus
|
||||
`jniLibs`, die so heissen, und nur die duerfen ausgefuehrt werden. Ein
|
||||
normales Binary in den Assets bekaeme kein Ausfuehrungsrecht.
|
||||
|
||||
## Grenzen
|
||||
|
||||
- **Nur Share.** Der Use-Modus braucht `vhci-hcd`, und das ist in
|
||||
Android-Kerneln praktisch nie aktiviert. Ein Telefon kann seine Geraete
|
||||
also anbieten, aber keine fremden empfangen.
|
||||
- **Berechtigung pro Geraet.** Der Dialog erscheint fuer jedes Geraet
|
||||
einzeln; ohne Bestaetigung gibt es keinen Deskriptor.
|
||||
- **Der Kernel muss OTG unterstuetzen.** Ohne USB-Host-Modus gibt es nichts
|
||||
zu teilen.
|
||||
- **Doze.** Android schlaefert Hintergrundprozesse ein. Die App laeuft
|
||||
deshalb als Foreground-Service mit Notification; ohne das beendet das
|
||||
System den Tunnel nach kurzer Zeit.
|
||||
@@ -0,0 +1,38 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.usbserver.bridge"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.usbserver.bridge"
|
||||
// USB host APIs need 12+; the foreground service type needs 29+.
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1"
|
||||
}
|
||||
|
||||
// The usb-client binary ships as libusbclient.so in jniLibs. It must stay
|
||||
// uncompressed and be extracted at install time, or it cannot be executed.
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- USB host mode. required="true" keeps the app off devices without an
|
||||
OTG port, where it would have nothing to share. -->
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:label="USB Server"
|
||||
android:icon="@android:drawable/stat_sys_data_bluetooth"
|
||||
android:allowBackup="false"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- extractNativeLibs is what makes the bundled usb-client binary
|
||||
executable: Android only grants execute permission to files
|
||||
unpacked into the native library directory. -->
|
||||
<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>
|
||||
|
||||
<!-- Offer to start when a device is plugged in. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
||||
android:resource="@xml/device_filter" />
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".ShareService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.usbserver.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Runs the usb-client binary as a child process.
|
||||
*
|
||||
* The binary ships in jniLibs as libusbclient.so. That naming is not
|
||||
* cosmetic: Android extracts and grants execute permission only to files in
|
||||
* the native library directory, so a binary placed in assets could not be run
|
||||
* at all on modern releases.
|
||||
*/
|
||||
class ClientProcess(private val context: Context) {
|
||||
|
||||
private var process: Process? = null
|
||||
private var logThread: Thread? = null
|
||||
|
||||
val socketPath: String get() = UsbBridge.defaultSocketPath(context)
|
||||
|
||||
/** Writes the config the client reads on startup. */
|
||||
fun writeConfig(relayAddr: String, tokens: Triple<String, String, String>, name: String) {
|
||||
val config = JSONObject().apply {
|
||||
put("relay_addr", relayAddr)
|
||||
put("hash", hashOf(tokens))
|
||||
// The tokens themselves must be present, not just the hash:
|
||||
// without them the client cannot derive the tunnel key and falls
|
||||
// back to unencrypted, relay-only operation.
|
||||
put("token1", tokens.first)
|
||||
put("token2", tokens.second)
|
||||
put("token3", tokens.third)
|
||||
// Only share: Android kernels have no vhci-hcd, so this device
|
||||
// can offer its USB hardware but not receive anyone else's.
|
||||
put("mode", "share")
|
||||
put("name", name)
|
||||
put("bridge_socket", socketPath)
|
||||
// The web UI would be reachable by any app on the device.
|
||||
put("web_port", 0)
|
||||
}
|
||||
|
||||
configFile().writeText(config.toString())
|
||||
}
|
||||
|
||||
fun start(): Result<Unit> {
|
||||
if (process?.isAlive == true) {
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
val binary = File(context.applicationInfo.nativeLibraryDir, "libusbclient.so")
|
||||
if (!binary.exists()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("libusbclient.so is missing; build it into jniLibs first")
|
||||
)
|
||||
}
|
||||
|
||||
// A socket left over from a previous run would stop the client binding.
|
||||
File(socketPath).delete()
|
||||
|
||||
return try {
|
||||
val started = ProcessBuilder(
|
||||
binary.absolutePath,
|
||||
"share",
|
||||
"--config", configFile().absolutePath,
|
||||
"--no-gui",
|
||||
)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
process = started
|
||||
logThread = Thread { drainLog(started) }.apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
|
||||
Log.i(TAG, "usb-client started")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
process?.destroy()
|
||||
process = null
|
||||
File(socketPath).delete()
|
||||
}
|
||||
|
||||
val isRunning: Boolean get() = process?.isAlive == true
|
||||
|
||||
/**
|
||||
* Forwards the client's output to logcat.
|
||||
*
|
||||
* Without this the process's diagnostics are simply lost, which makes any
|
||||
* failure — a wrong relay address, a rejected device — invisible.
|
||||
*/
|
||||
private fun drainLog(process: Process) {
|
||||
try {
|
||||
process.inputStream.bufferedReader().forEachLine { line ->
|
||||
Log.i(TAG, line)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "log stream ended: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun configFile() = File(context.filesDir, "config.json")
|
||||
|
||||
/** SHA-256 over the three tokens joined by colons — must match token.Hash. */
|
||||
private fun hashOf(tokens: Triple<String, String, String>): String {
|
||||
val combined = "${tokens.first}:${tokens.second}:${tokens.third}"
|
||||
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(combined.toByteArray())
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UsbClientProcess"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.usbserver.bridge
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.app.Activity
|
||||
|
||||
/**
|
||||
* Minimal setup screen: relay address, the three tokens, start and stop.
|
||||
*
|
||||
* Deliberately plain — the interesting part of this app is [UsbBridge], which
|
||||
* gets the file descriptor across to the client. Anything nicer belongs in a
|
||||
* proper UI layer and is not needed to make sharing work.
|
||||
*/
|
||||
class MainActivity : Activity() {
|
||||
|
||||
private lateinit var relayField: EditText
|
||||
private lateinit var token1Field: EditText
|
||||
private lateinit var token2Field: EditText
|
||||
private lateinit var token3Field: EditText
|
||||
private lateinit var statusView: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
|
||||
val root = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(48, 48, 48, 48)
|
||||
}
|
||||
|
||||
fun field(hint: String, key: String): EditText =
|
||||
EditText(this).apply {
|
||||
this.hint = hint
|
||||
setText(prefs.getString(key, ""))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
}.also { root.addView(it) }
|
||||
|
||||
relayField = field("ws://relay:8443", "relay")
|
||||
token1Field = field("Token 1", "token1")
|
||||
token2Field = field("Token 2", "token2")
|
||||
token3Field = field("Token 3", "token3")
|
||||
|
||||
statusView = TextView(this).apply { text = "Stopped" }
|
||||
|
||||
root.addView(Button(this).apply {
|
||||
text = "Start sharing"
|
||||
setOnClickListener {
|
||||
prefs.edit()
|
||||
.putString("relay", relayField.text.toString())
|
||||
.putString("token1", token1Field.text.toString())
|
||||
.putString("token2", token2Field.text.toString())
|
||||
.putString("token3", token3Field.text.toString())
|
||||
.apply()
|
||||
|
||||
val intent = Intent(this@MainActivity, ShareService::class.java).apply {
|
||||
putExtra(ShareService.EXTRA_RELAY, relayField.text.toString())
|
||||
putExtra(ShareService.EXTRA_TOKEN1, token1Field.text.toString())
|
||||
putExtra(ShareService.EXTRA_TOKEN2, token2Field.text.toString())
|
||||
putExtra(ShareService.EXTRA_TOKEN3, token3Field.text.toString())
|
||||
putExtra(ShareService.EXTRA_NAME, Build.MODEL)
|
||||
}
|
||||
startForegroundService(intent)
|
||||
statusView.text = "Running — see the notification"
|
||||
}
|
||||
})
|
||||
|
||||
root.addView(Button(this).apply {
|
||||
text = "Stop"
|
||||
setOnClickListener {
|
||||
stopService(Intent(this@MainActivity, ShareService::class.java))
|
||||
statusView.text = "Stopped"
|
||||
}
|
||||
})
|
||||
|
||||
root.addView(statusView)
|
||||
root.addView(TextView(this).apply {
|
||||
text = "\nThe same three tokens must be configured on every client " +
|
||||
"in the group. Without them traffic is neither encrypted nor " +
|
||||
"able to bypass the relay.\n\n" +
|
||||
"This device can only share its own USB devices. Receiving " +
|
||||
"remote ones needs the vhci-hcd kernel module, which Android " +
|
||||
"kernels do not include."
|
||||
})
|
||||
|
||||
setContentView(root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package de.usbserver.bridge
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the client running and offers devices to it.
|
||||
*
|
||||
* A foreground service with a visible notification is not optional here:
|
||||
* Android's Doze and background limits would otherwise suspend or kill the
|
||||
* process, and a suspended process means a USB device that silently stops
|
||||
* responding for whoever is using it remotely.
|
||||
*/
|
||||
class ShareService : Service() {
|
||||
|
||||
private lateinit var clientProcess: ClientProcess
|
||||
private lateinit var bridge: UsbBridge
|
||||
private lateinit var usbManager: UsbManager
|
||||
|
||||
/**
|
||||
* Receives permission results and unplug events.
|
||||
*
|
||||
* Unplug matters: the descriptor dies with the device, and the client has
|
||||
* to be told, or it keeps advertising a device that is no longer there.
|
||||
*/
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_USB_PERMISSION -> {
|
||||
val device = intent.usbDevice() ?: return
|
||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
shareDevice(device)
|
||||
} else {
|
||||
Log.i(TAG, "permission refused for ${device.deviceName}")
|
||||
}
|
||||
}
|
||||
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
intent.usbDevice()?.let { requestPermission(it) }
|
||||
}
|
||||
|
||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
||||
intent.usbDevice()?.let { device ->
|
||||
bridge.unshare(device).onFailure {
|
||||
Log.w(TAG, "withdrawing ${device.deviceName} failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun Intent.usbDevice(): UsbDevice? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
|
||||
} else {
|
||||
getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
clientProcess = ClientProcess(this)
|
||||
bridge = UsbBridge(this, clientProcess.socketPath)
|
||||
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Starting…"))
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_USB_PERMISSION)
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(receiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val relay = intent?.getStringExtra(EXTRA_RELAY) ?: return START_NOT_STICKY
|
||||
val t1 = intent.getStringExtra(EXTRA_TOKEN1) ?: return START_NOT_STICKY
|
||||
val t2 = intent.getStringExtra(EXTRA_TOKEN2) ?: return START_NOT_STICKY
|
||||
val t3 = intent.getStringExtra(EXTRA_TOKEN3) ?: return START_NOT_STICKY
|
||||
val name = intent.getStringExtra(EXTRA_NAME) ?: Build.MODEL
|
||||
|
||||
clientProcess.writeConfig(relay, Triple(t1, t2, t3), name)
|
||||
|
||||
clientProcess.start()
|
||||
.onSuccess {
|
||||
updateNotification("Connected to $relay")
|
||||
// Give the client a moment to bind its socket before offering
|
||||
// anything to it.
|
||||
Thread {
|
||||
Thread.sleep(500)
|
||||
askForAllDevices()
|
||||
}.start()
|
||||
}
|
||||
.onFailure {
|
||||
Log.e(TAG, "starting the client failed", it)
|
||||
updateNotification("Failed: ${it.message}")
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
// START_STICKY so the service comes back if the system reclaims it.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
runCatching { unregisterReceiver(receiver) }
|
||||
bridge.closeAll()
|
||||
clientProcess.stop()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/** Asks for permission on every device currently attached. */
|
||||
private fun askForAllDevices() {
|
||||
usbManager.deviceList.values.forEach { device ->
|
||||
if (usbManager.hasPermission(device)) {
|
||||
shareDevice(device)
|
||||
} else {
|
||||
requestPermission(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermission(device: UsbDevice) {
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val intent = PendingIntent.getBroadcast(
|
||||
this, 0, Intent(ACTION_USB_PERMISSION).setPackage(packageName), flags
|
||||
)
|
||||
usbManager.requestPermission(device, intent)
|
||||
}
|
||||
|
||||
private fun shareDevice(device: UsbDevice) {
|
||||
bridge.share(device)
|
||||
.onSuccess {
|
||||
updateNotification("Sharing ${bridge.let { device.productName ?: device.deviceName }}")
|
||||
}
|
||||
.onFailure {
|
||||
Log.w(TAG, "sharing ${device.deviceName} failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"USB Sharing",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Keeps shared USB devices reachable"
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification =
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("USB Server")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, buildNotification(text))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UsbShareService"
|
||||
private const val CHANNEL_ID = "usb_sharing"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
const val ACTION_USB_PERMISSION = "de.usbserver.bridge.USB_PERMISSION"
|
||||
|
||||
const val EXTRA_RELAY = "relay"
|
||||
const val EXTRA_TOKEN1 = "token1"
|
||||
const val EXTRA_TOKEN2 = "token2"
|
||||
const val EXTRA_TOKEN3 = "token3"
|
||||
const val EXTRA_NAME = "name"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package de.usbserver.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.LocalSocket
|
||||
import android.net.LocalSocketAddress
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Hands USB devices to the usb-client process.
|
||||
*
|
||||
* An Android app cannot open /dev/bus/usb, so the client cannot find devices
|
||||
* by itself. This class obtains the file descriptor through UsbManager — which
|
||||
* is what the permission dialog is for — and passes it over a Unix socket
|
||||
* using ancillary data, along with the raw descriptors the client needs to
|
||||
* understand the device.
|
||||
*
|
||||
* Connections are kept open per handover: the descriptor stays valid only as
|
||||
* long as the UsbDeviceConnection is alive, so this class holds on to them.
|
||||
*/
|
||||
class UsbBridge(
|
||||
private val context: Context,
|
||||
private val socketPath: String,
|
||||
) {
|
||||
private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** Open connections, keyed by bus ID. Closing one invalidates its descriptor. */
|
||||
private val connections = mutableMapOf<String, UsbDeviceConnection>()
|
||||
|
||||
/**
|
||||
* Offers a device to the client.
|
||||
*
|
||||
* The caller must already hold permission for it — see
|
||||
* [UsbManager.requestPermission]. Without permission openDevice returns
|
||||
* null and this fails.
|
||||
*/
|
||||
fun share(device: UsbDevice): Result<Unit> {
|
||||
if (!usbManager.hasPermission(device)) {
|
||||
return Result.failure(
|
||||
IllegalStateException("no permission for ${device.deviceName}; request it first")
|
||||
)
|
||||
}
|
||||
|
||||
val connection = usbManager.openDevice(device)
|
||||
?: return Result.failure(IllegalStateException("could not open ${device.deviceName}"))
|
||||
|
||||
val busId = busIdOf(device)
|
||||
|
||||
return try {
|
||||
val request = JSONObject().apply {
|
||||
put("action", "add")
|
||||
put("bus_id", busId)
|
||||
// getRawDescriptors returns exactly what a usbdevfs read
|
||||
// returns: device descriptor followed by all configurations.
|
||||
put("descriptors", android.util.Base64.encodeToString(
|
||||
connection.rawDescriptors, android.util.Base64.NO_WRAP))
|
||||
put("bus_num", busNumberOf(device))
|
||||
put("dev_num", deviceNumberOf(device))
|
||||
put("speed", speedOf(device))
|
||||
put("config_value", configValueOf(device))
|
||||
put("manufacturer", device.manufacturerName ?: "")
|
||||
put("product", device.productName ?: "")
|
||||
put("serial", serialOf(device, connection))
|
||||
}
|
||||
|
||||
sendRequest(request, connection.fileDescriptor)
|
||||
|
||||
// Keep the connection open: closing it would close the descriptor
|
||||
// the client is now using.
|
||||
connections[busId]?.close()
|
||||
connections[busId] = connection
|
||||
|
||||
Log.i(TAG, "shared $busId (${device.manufacturerName} ${device.productName})")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
connection.close()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Withdraws a device and closes its connection. */
|
||||
fun unshare(device: UsbDevice): Result<Unit> {
|
||||
val busId = busIdOf(device)
|
||||
|
||||
return try {
|
||||
val request = JSONObject().apply {
|
||||
put("action", "remove")
|
||||
put("bus_id", busId)
|
||||
}
|
||||
sendRequest(request, fd = -1)
|
||||
|
||||
connections.remove(busId)?.close()
|
||||
Log.i(TAG, "withdrew $busId")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
connections.remove(busId)?.close()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes every open connection. Call when the service stops. */
|
||||
fun closeAll() {
|
||||
connections.values.forEach { it.close() }
|
||||
connections.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one request, attaching fd as ancillary data when it is valid.
|
||||
*
|
||||
* LocalSocket's setFileDescriptorsForSend is Android's SCM_RIGHTS: the
|
||||
* descriptor is duplicated into the receiving process, which is the only
|
||||
* way to give the client access to a device it cannot open itself.
|
||||
*/
|
||||
private fun sendRequest(request: JSONObject, fd: Int) {
|
||||
LocalSocket().use { socket ->
|
||||
socket.connect(LocalSocketAddress(socketPath, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||
|
||||
if (fd >= 0) {
|
||||
socket.setFileDescriptorsForSend(arrayOf(fdToFileDescriptor(fd)))
|
||||
}
|
||||
|
||||
socket.outputStream.write(request.toString().toByteArray())
|
||||
socket.outputStream.flush()
|
||||
|
||||
val buffer = ByteArray(4096)
|
||||
val n = socket.inputStream.read(buffer)
|
||||
if (n <= 0) {
|
||||
throw IllegalStateException("client closed the connection without replying")
|
||||
}
|
||||
|
||||
val response = JSONObject(String(buffer, 0, n))
|
||||
if (!response.optBoolean("ok", false)) {
|
||||
throw IllegalStateException(response.optString("error", "client rejected the device"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a raw descriptor number in a FileDescriptor.
|
||||
*
|
||||
* FileDescriptor's int constructor is not public API, so this goes through
|
||||
* reflection. It is the same approach every library that needs to pass
|
||||
* descriptors on Android takes; if a future release blocks it, the
|
||||
* alternative is a small JNI shim.
|
||||
*/
|
||||
private fun fdToFileDescriptor(fd: Int): java.io.FileDescriptor {
|
||||
val descriptor = java.io.FileDescriptor()
|
||||
val field = java.io.FileDescriptor::class.java.getDeclaredField("descriptor")
|
||||
field.isAccessible = true
|
||||
field.setInt(descriptor, fd)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable bus ID.
|
||||
*
|
||||
* Android device names look like "/dev/bus/usb/001/002". The client uses
|
||||
* this string to identify the device to peers, so it has to stay the same
|
||||
* for as long as the device is plugged in.
|
||||
*/
|
||||
private fun busIdOf(device: UsbDevice): String {
|
||||
val parts = device.deviceName.trim('/').split("/")
|
||||
return if (parts.size >= 2) {
|
||||
val bus = parts[parts.size - 2].trimStart('0').ifEmpty { "0" }
|
||||
val dev = parts[parts.size - 1].trimStart('0').ifEmpty { "0" }
|
||||
"$bus-$dev"
|
||||
} else {
|
||||
device.deviceId.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun busNumberOf(device: UsbDevice): Int =
|
||||
device.deviceName.trim('/').split("/").let { parts ->
|
||||
parts.getOrNull(parts.size - 2)?.toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
private fun deviceNumberOf(device: UsbDevice): Int =
|
||||
device.deviceName.trim('/').split("/").lastOrNull()?.toIntOrNull() ?: 0
|
||||
|
||||
/** Maps Android's speed constants onto the USB/IP speed codes. */
|
||||
private fun speedOf(device: UsbDevice): Int {
|
||||
// UsbDevice exposes no speed before API 29, and even then only
|
||||
// indirectly. High speed is the safe assumption: it is what almost
|
||||
// everything an OTG port sees actually runs at, and the value is only
|
||||
// advisory on the receiving end.
|
||||
return 3
|
||||
}
|
||||
|
||||
private fun configValueOf(device: UsbDevice): Int {
|
||||
// The framework activates configuration 1 on open; devices with more
|
||||
// than one configuration are vanishingly rare in practice.
|
||||
return 1
|
||||
}
|
||||
|
||||
private fun serialOf(device: UsbDevice, connection: UsbDeviceConnection): String =
|
||||
try {
|
||||
connection.serial ?: ""
|
||||
} catch (e: SecurityException) {
|
||||
// Reading the serial needs permission the app may not hold; it is
|
||||
// cosmetic, so carry on without it.
|
||||
""
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UsbBridge"
|
||||
|
||||
/** Default socket path inside the app's private directory. */
|
||||
fun defaultSocketPath(context: Context): String =
|
||||
File(context.filesDir, "bridge.sock").absolutePath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Matches any USB device. Narrow this to specific vendor/product IDs if the
|
||||
app should only offer to start for particular hardware. -->
|
||||
<resources>
|
||||
<usb-device />
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.20" apply false
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "usb-server-android"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user