Add USB/IP support

This commit is contained in:
世界
2026-07-06 21:15:31 +08:00
parent ffd37880cf
commit a58f32a595
24 changed files with 1969 additions and 135 deletions
+9
View File
@@ -7,10 +7,15 @@
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
@@ -142,6 +147,10 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service
android:name=".bg.USBIPService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<receiver
android:name=".bg.BootReceiver"
@@ -0,0 +1,146 @@
package io.nekohasekai.sfa.bg
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.content.pm.ServiceInfo
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.MainActivity
import io.nekohasekai.sfa.usbip.USBIPManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class USBIPService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var collectJob: Job? = null
private var receiverRegistered = false
private val detachReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) ?: return
USBIPManager.detachByDevice(device)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
USBIPManager.shutdown()
stopSelf()
return START_NOT_STICKY
}
createChannel()
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(USBIPManager.state.value.devices.size),
foregroundServiceType(),
)
registerDetachReceiver()
if (collectJob == null) {
collectJob =
scope.launch {
USBIPManager.state.collect { state ->
if (state.devices.isEmpty()) {
stopSelf()
} else {
Application.notificationManager.notify(NOTIFICATION_ID, buildNotification(state.devices.size))
}
}
}
}
return START_STICKY
}
private fun registerDetachReceiver() {
if (receiverRegistered) return
ContextCompat.registerReceiver(
this,
detachReceiver,
IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
receiverRegistered = true
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
Application.notification.createNotificationChannel(
NotificationChannel(NOTIFICATION_CHANNEL, "USB/IP", NotificationManager.IMPORTANCE_LOW),
)
}
private fun buildNotification(count: Int) = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL)
.setShowWhen(false)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSmallIcon(R.drawable.ic_menu)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentTitle(getString(R.string.usbip_notification_title))
.setContentText(resources.getQuantityString(R.plurals.usbip_notification_text, count, count))
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
pendingIntentFlags(),
),
)
.addAction(
NotificationCompat.Action.Builder(
0,
getText(R.string.stop),
PendingIntent.getService(
this,
0,
Intent(this, USBIPService::class.java).setAction(ACTION_STOP),
pendingIntentFlags(),
),
).build(),
)
.build()
override fun onDestroy() {
super.onDestroy()
collectJob?.cancel()
collectJob = null
if (receiverRegistered) {
unregisterReceiver(detachReceiver)
receiverRegistered = false
}
scope.cancel()
USBIPManager.onServiceDestroyed()
}
companion object {
const val ACTION_STOP = "io.nekohasekai.sfa.action.USBIP_STOP"
private const val NOTIFICATION_ID = 2
private const val NOTIFICATION_CHANNEL = "usbip"
private fun pendingIntentFlags() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
private fun foregroundServiceType() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE else 0
}
}
@@ -118,6 +118,7 @@ import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHSharedViewModel
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
import io.nekohasekai.sfa.compose.screen.usbip.USBIPStatusViewModel
import io.nekohasekai.sfa.compose.theme.SFATheme
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarController
@@ -768,6 +769,13 @@ class MainActivity :
null
}
val usbIPStatusViewModel: USBIPStatusViewModel? =
if (isToolsRoute) {
viewModel()
} else {
null
}
val showGroupsInNav = dashboardUiState.hasGroups
val showConnectionsInNav =
if (isRemote) {
@@ -895,6 +903,7 @@ class MainActivity :
connectionsViewModel = connectionsViewModel,
tailscaleStatusViewModel = tailscaleStatusViewModel,
tailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel,
usbIPStatusViewModel = usbIPStatusViewModel,
modifier = Modifier.fillMaxSize(),
)
if (!useNavigationRail) {
@@ -58,6 +58,9 @@ import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHSharedViewModel
import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHTerminalScreen
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen
import io.nekohasekai.sfa.compose.screen.usbip.USBIPDeviceDetailScreen
import io.nekohasekai.sfa.compose.screen.usbip.USBIPServerScreen
import io.nekohasekai.sfa.compose.screen.usbip.USBIPStatusViewModel
import io.nekohasekai.sfa.constant.Status
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
@@ -91,6 +94,7 @@ fun SFANavHost(
connectionsViewModel: ConnectionsViewModel? = null,
tailscaleStatusViewModel: TailscaleStatusViewModel? = null,
tailscaleSSHSharedViewModel: TailscaleSSHSharedViewModel? = null,
usbIPStatusViewModel: USBIPStatusViewModel? = null,
modifier: Modifier = Modifier,
) {
NavHost(
@@ -240,7 +244,8 @@ fun SFANavHost(
composable(Screen.Tools.route) {
val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel()
val sshSharedViewModel: TailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel ?: viewModel()
ToolsScreen(navController = navController, serviceStatus = serviceStatus, tailscaleViewModel = tailscaleViewModel, sshSharedViewModel = sshSharedViewModel)
val usbIPViewModel: USBIPStatusViewModel = usbIPStatusViewModel ?: viewModel()
ToolsScreen(navController = navController, serviceStatus = serviceStatus, tailscaleViewModel = tailscaleViewModel, sshSharedViewModel = sshSharedViewModel, usbIPViewModel = usbIPViewModel)
}
// Tools subscreens with slide animations
@@ -264,6 +269,36 @@ fun SFANavHost(
STUNTestScreen(navController = navController, serviceStatus = serviceStatus)
}
composable(
route = "tools/usbip/{serverTag}",
arguments = listOf(navArgument("serverTag") { type = NavType.StringType }),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val serverTag = Uri.decode(backStackEntry.arguments?.getString("serverTag") ?: return@composable)
val usbIPViewModel: USBIPStatusViewModel = usbIPStatusViewModel ?: viewModel()
USBIPServerScreen(navController = navController, viewModel = usbIPViewModel, serverTag = serverTag)
}
composable(
route = "tools/usbip/{serverTag}/device/{deviceKey}",
arguments = listOf(
navArgument("serverTag") { type = NavType.StringType },
navArgument("deviceKey") { type = NavType.StringType },
),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val serverTag = Uri.decode(backStackEntry.arguments?.getString("serverTag") ?: return@composable)
val deviceKey = Uri.decode(backStackEntry.arguments?.getString("deviceKey") ?: return@composable)
val usbIPViewModel: USBIPStatusViewModel = usbIPStatusViewModel ?: viewModel()
USBIPDeviceDetailScreen(navController = navController, viewModel = usbIPViewModel, serverTag = serverTag, deviceKey = deviceKey)
}
composable(
route = "tools/outbound_picker/{selectedOutbound}",
arguments = listOf(navArgument("selectedOutbound") { type = NavType.StringType }),
@@ -2,11 +2,15 @@ package io.nekohasekai.sfa.compose.screen.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -14,6 +18,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -28,21 +33,30 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.RemoteConnectionOptions
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.theme.ServiceError
import io.nekohasekai.sfa.compose.theme.ServiceRunning
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.RemoteServer
import io.nekohasekai.sfa.database.RemoteServerManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private enum class ProbeState { Idle, Checking, Available, Unavailable }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
@@ -80,6 +94,35 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
var secretVisible by remember { mutableStateOf(false) }
var urlError by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(!isNewServer) }
var probeState by remember { mutableStateOf(ProbeState.Idle) }
var userEdited by remember { mutableStateOf(false) }
LaunchedEffect(url, secret) {
if (RemoteServer.validateURL(url) == null) {
probeState = ProbeState.Idle
return@LaunchedEffect
}
probeState = ProbeState.Checking
if (userEdited) {
delay(300)
}
val reachable = withContext(Dispatchers.IO) {
var client: io.nekohasekai.libbox.CommandClient? = null
try {
val options = RemoteConnectionOptions()
options.setURL(RemoteServer.connectURL(url))
options.secret = secret
client = Libbox.newStandaloneRemoteCommandClient(options)
client.getStartedAt()
true
} catch (_: Exception) {
false
} finally {
runCatching { client?.disconnect() }
}
}
probeState = if (reachable) ProbeState.Available else ProbeState.Unavailable
}
LaunchedEffect(serverId) {
if (!isNewServer) {
@@ -90,7 +133,7 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
}
origin = server
name = server.name
url = server.url
url = RemoteServer.normalizeURL(server.url)
secret = server.secret
isLoading = false
}
@@ -123,6 +166,7 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
onValueChange = {
url = it
urlError = false
userEdited = true
},
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.profile_url)) },
@@ -140,7 +184,10 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
OutlinedTextField(
value = secret,
onValueChange = { secret = it },
onValueChange = {
secret = it
userEdited = true
},
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.remote_secret)) },
placeholder = { Text(stringResource(R.string.remote_optional)) },
@@ -167,6 +214,51 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
if (probeState != ProbeState.Idle) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (probeState == ProbeState.Checking) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
} else {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(
if (probeState == ProbeState.Available) {
ServiceRunning
} else {
ServiceError
},
),
)
}
Text(
text =
stringResource(
when (probeState) {
ProbeState.Available -> R.string.remote_available
ProbeState.Checking -> R.string.remote_checking
else -> R.string.remote_unavailable
},
),
color =
when (probeState) {
ProbeState.Available -> ServiceRunning
ProbeState.Unavailable -> ServiceError
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
style = MaterialTheme.typography.bodySmall,
)
}
}
Button(
onClick = {
val validatedURL = RemoteServer.validateURL(url)
@@ -5,9 +5,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -16,7 +19,9 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.SettingsRemote
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
@@ -43,6 +48,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
@@ -98,6 +104,34 @@ fun RemoteControlScreen(navController: NavController) {
}
}
if (servers.isEmpty()) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Outlined.SettingsRemote,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.remote_no_servers),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
return
}
Column(
modifier =
Modifier
@@ -106,134 +140,125 @@ fun RemoteControlScreen(navController: NavController) {
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Text(
text = stringResource(R.string.remote_servers),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
servers.forEachIndexed { index, server ->
val shape =
when {
servers.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == servers.size - 1 ->
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
if (servers.isEmpty()) {
Text(
text = stringResource(R.string.remote_no_servers),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp),
)
} else {
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
servers.forEachIndexed { index, server ->
val shape =
when {
servers.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == servers.size - 1 ->
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
else -> RoundedCornerShape(0.dp)
}
var showMenu by remember { mutableStateOf(false) }
Box {
ListItem(
headlineContent = {
else -> RoundedCornerShape(0.dp)
}
var showMenu by remember { mutableStateOf(false) }
Box {
ListItem(
headlineContent = {
Text(
server.displayName,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent =
if (server.name.isNotEmpty()) {
{
Text(
server.displayName,
style = MaterialTheme.typography.bodyLarge,
server.url,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
supportingContent =
if (server.name.isNotEmpty()) {
{
Text(
server.url,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
null
},
trailingContent =
if (activeRemoteServer?.id == server.id) {
{
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
modifier =
Modifier
.clip(shape)
.combinedClickable(
onClick = {
navController.navigate(
"settings/remote_control/edit/${server.id}",
)
},
onLongClick = { showMenu = true },
),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit)) },
leadingIcon = {
Icon(Icons.Outlined.Edit, contentDescription = null)
},
}
} else {
null
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent =
if (activeRemoteServer?.id == server.id) {
{
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
modifier =
Modifier
.clip(shape)
.combinedClickable(
onClick = {
showMenu = false
navController.navigate(
"settings/remote_control/edit/${server.id}",
)
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.menu_delete),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Outlined.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
showMenu = false
scope.launch(Dispatchers.IO) {
if (RemoteControlManager.remoteServer.value?.id == server.id) {
withContext(Dispatchers.Main) {
RemoteControlManager.exitRemoteControl()
}
onLongClick = { showMenu = true },
),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit)) },
leadingIcon = {
Icon(Icons.Outlined.Edit, contentDescription = null)
},
onClick = {
showMenu = false
navController.navigate(
"settings/remote_control/edit/${server.id}",
)
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.menu_delete),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Outlined.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
showMenu = false
scope.launch(Dispatchers.IO) {
if (RemoteControlManager.remoteServer.value?.id == server.id) {
withContext(Dispatchers.Main) {
RemoteControlManager.exitRemoteControl()
}
RemoteServerManager.delete(server)
}
},
)
}
RemoteServerManager.delete(server)
}
},
)
}
}
}
@@ -73,9 +73,20 @@ fun TailscaleEndpointScreen(
sshSharedViewModel: TailscaleSSHSharedViewModel,
endpointTag: String,
) {
val state by viewModel.uiState.collectAsState()
val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag }
OverrideTopBar {
TopAppBar(
title = { Text(endpointTag) },
title = {
Text(
if (state.endpoints.size <= 1) {
stringResource(R.string.tailscale)
} else {
stringResource(R.string.tailscale_with_tag, endpointTag)
},
)
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back))
@@ -84,9 +95,6 @@ fun TailscaleEndpointScreen(
)
}
val state by viewModel.uiState.collectAsState()
val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag }
if (endpoint == null) {
LaunchedEffect(Unit) {
navController.navigateUp()
@@ -16,6 +16,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Hub
import androidx.compose.material.icons.outlined.Memory
@@ -53,6 +54,7 @@ import io.nekohasekai.sfa.bg.CrashReportManager
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems
import io.nekohasekai.sfa.compose.component.rememberRemoteServers
import io.nekohasekai.sfa.compose.screen.usbip.USBIPStatusViewModel
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
@@ -66,6 +68,7 @@ fun ToolsScreen(
serviceStatus: Status = Status.Stopped,
tailscaleViewModel: TailscaleStatusViewModel,
sshSharedViewModel: TailscaleSSHSharedViewModel,
usbIPViewModel: USBIPStatusViewModel,
) {
val remoteServers by rememberRemoteServers()
@@ -101,6 +104,7 @@ fun ToolsScreen(
val crashUnreadCount by CrashReportManager.unreadCount.collectAsState()
val oomUnreadCount by OOMReportManager.unreadCount.collectAsState()
val tailscaleState by tailscaleViewModel.uiState.collectAsState()
val usbIPState by usbIPViewModel.uiState.collectAsState()
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
LaunchedEffect(remoteServer?.id) {
@@ -109,8 +113,10 @@ fun ToolsScreen(
// without tailscale leaves no active stream to error out, so the
// subscription would stay stale without an explicit cancel.
tailscaleViewModel.cancel()
usbIPViewModel.cancel()
if (remoteServer != null || serviceStatus == Status.Started) {
tailscaleViewModel.subscribe()
usbIPViewModel.subscribe()
}
}
@@ -120,8 +126,10 @@ fun ToolsScreen(
}
if (serviceStatus == Status.Started) {
tailscaleViewModel.subscribe()
usbIPViewModel.subscribe()
} else {
tailscaleViewModel.cancel()
usbIPViewModel.cancel()
}
}
@@ -246,6 +254,59 @@ fun ToolsScreen(
}
}
if (usbIPState.servers.isNotEmpty()) {
Text(
text = stringResource(R.string.title_services),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
val servers = usbIPState.servers
servers.forEachIndexed { index, server ->
val shape = when {
servers.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == servers.size - 1 -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
if (servers.size == 1) {
stringResource(R.string.title_usbip)
} else {
stringResource(R.string.usbip_with_tag, server.serverTag)
},
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Usb,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(shape)
.clickable {
navController.navigate("tools/usbip/${Uri.encode(server.serverTag)}")
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
Text(
text = stringResource(R.string.title_network),
style = MaterialTheme.typography.labelLarge,
@@ -0,0 +1,146 @@
package io.nekohasekai.sfa.compose.screen.usbip
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.usbip.bcdToVersion
import io.nekohasekai.sfa.usbip.formatVidPid
import io.nekohasekai.sfa.usbip.usbBackendLabel
import io.nekohasekai.sfa.usbip.usbClassTriplet
import io.nekohasekai.sfa.usbip.usbSpeedLabel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun USBIPDeviceDetailScreen(
navController: NavController,
viewModel: USBIPStatusViewModel,
serverTag: String,
deviceKey: String,
) {
val state by viewModel.uiState.collectAsState()
val device = state.servers.firstOrNull { it.serverTag == serverTag }?.devices?.firstOrNull { it.key == deviceKey }
OverrideTopBar {
TopAppBar(
title = {
Text(device?.product?.ifEmpty { device.busId } ?: stringResource(R.string.title_usbip))
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back))
}
},
)
}
if (device == null) {
LaunchedEffect(Unit) { navController.navigateUp() }
return
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
DetailSection(stringResource(R.string.usbip_identity)) {
if (device.product.isNotEmpty()) DataLine(stringResource(R.string.usbip_product), device.product)
DataLine("VID:PID", formatVidPid(device.vendorId, device.productId), mono = true)
if (device.serial.isNotEmpty()) DataLine(stringResource(R.string.usbip_serial), device.serial, mono = true)
if (device.bcdDevice > 0) DataLine(stringResource(R.string.usbip_version), bcdToVersion(device.bcdDevice), mono = true)
}
DetailSection(stringResource(R.string.usbip_connection)) {
if (device.busId.isNotEmpty()) DataLine(stringResource(R.string.usbip_bus_id), device.busId, mono = true)
usbBackendLabel(device.backend)?.let { DataLine(stringResource(R.string.usbip_backend), it, mono = true) }
usbSpeedLabel(device.speed)?.let { DataLine(stringResource(R.string.usbip_speed), it) }
if (device.busNum > 0 || device.devNum > 0) {
DataLine(stringResource(R.string.usbip_bus_device), "${device.busNum} · ${device.devNum}", mono = true)
}
}
DetailSection(stringResource(R.string.usbip_class_interfaces)) {
DataLine(
stringResource(R.string.usbip_device_class),
usbClassTriplet(device.deviceClass, device.deviceSubClass, device.deviceProtocol),
)
DataLine(
stringResource(R.string.usbip_configuration),
"${device.configurationValue} / ${device.numConfigurations}",
)
device.interfaces.forEachIndexed { index, iface ->
DataLine(
stringResource(R.string.usbip_interface, index),
usbClassTriplet(iface.interfaceClass, iface.interfaceSubClass, iface.interfaceProtocol),
)
}
}
}
}
@Composable
private fun DetailSection(title: String, content: @Composable () -> Unit) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
content()
}
}
}
@Composable
private fun DataLine(label: String, value: String, mono: Boolean = false) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
value,
style = MaterialTheme.typography.bodyMedium,
fontFamily = if (mono) FontFamily.Monospace else null,
)
}
}
@@ -0,0 +1,354 @@
package io.nekohasekai.sfa.compose.screen.usbip
import android.content.Context
import android.hardware.usb.UsbManager
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.UsbOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.usbip.ProvidedDevice
import io.nekohasekai.sfa.usbip.ProvidedDeviceState
import io.nekohasekai.sfa.usbip.USBIPManager
import io.nekohasekai.sfa.usbip.formatVidPid
private enum class Tone { GOOD, MEDIUM, BAD, NEUTRAL }
private data class UsbDeviceRow(
val key: String,
val name: String,
val vidPid: String?,
val busId: String?,
val error: String?,
val backendState: Int?,
val providedState: ProvidedDeviceState?,
val deviceData: UsbSharedDeviceData?,
val providedDeviceId: String?,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun USBIPServerScreen(
navController: NavController,
viewModel: USBIPStatusViewModel,
serverTag: String,
) {
val state by viewModel.uiState.collectAsState()
val providerState by USBIPManager.state.collectAsState()
val server = state.servers.firstOrNull { it.serverTag == serverTag }
val provided = providerState.devices.filter { it.serverTag == serverTag }
val endError = providerState.endErrors[serverTag]
val attach = rememberUsbAttacher(serverTag)
OverrideTopBar {
TopAppBar(
title = {
Text(
if (state.servers.size <= 1) {
stringResource(R.string.title_usbip)
} else {
stringResource(R.string.usbip_with_tag, serverTag)
},
)
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back))
}
},
actions = {
if (server != null) {
AddDeviceMenu(provided = provided, onPick = attach)
}
},
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
if (server == null) {
Text(
text = stringResource(R.string.usbip_no_server),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 24.dp),
)
return@Column
}
if (endError != null) {
Text(
text = endError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
}
val rows = mergeRows(server, provided)
if (rows.isEmpty()) {
Text(
text = stringResource(R.string.usbip_no_devices),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 24.dp),
)
} else {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column {
for (row in rows) {
DeviceItem(
row = row,
onOpen = {
if (row.deviceData != null) {
navController.navigate("tools/usbip/${Uri.encode(serverTag)}/device/${Uri.encode(row.key)}")
}
},
onDetach = { id -> USBIPManager.detach(id) },
)
}
}
}
}
}
}
@Composable
private fun AddDeviceMenu(provided: List<ProvidedDevice>, onPick: (android.hardware.usb.UsbDevice) -> Unit) {
val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.usbip_connect_device))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val devices = usbManager.deviceList.values.toList()
val attachedNames = provided.mapNotNull { it.usbDeviceName }.toSet()
if (devices.isEmpty()) {
DropdownMenuItem(
enabled = false,
text = { Text(stringResource(R.string.usbip_no_usb_devices)) },
onClick = {},
)
}
for (device in devices) {
val attached = device.deviceName in attachedNames
DropdownMenuItem(
enabled = !attached,
leadingIcon = {
if (attached) {
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp))
}
},
text = {
Column {
Text(device.productName ?: formatVidPid(device.vendorId, device.productId))
Text(
formatVidPid(device.vendorId, device.productId),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = {
expanded = false
onPick(device)
},
)
}
}
}
}
@Composable
private fun DeviceItem(row: UsbDeviceRow, onOpen: () -> Unit, onDetach: (String) -> Unit) {
val (label, tone) = stateInfo(row)
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(enabled = row.deviceData != null, onClick = onOpen)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(toneColor(tone)),
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${row.name}:",
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.padding(start = 4.dp),
)
}
if (row.error != null) {
Text(row.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
}
}
if (row.providedDeviceId != null) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(52.dp)
.clickable { onDetach(row.providedDeviceId) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.UsbOff,
contentDescription = stringResource(R.string.usbip_detach),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun stateInfo(row: UsbDeviceRow): Pair<String, Tone> {
row.backendState?.let { backend ->
return when (backend) {
Libbox.USBDeviceStateIdle -> stringResource(R.string.usbip_state_idle) to Tone.GOOD
Libbox.USBDeviceStateAttached -> stringResource(R.string.usbip_state_attached) to Tone.MEDIUM
Libbox.USBDeviceStateUnavailable -> stringResource(R.string.usbip_state_unavailable) to Tone.BAD
else -> "" to Tone.NEUTRAL
}
}
return when (row.providedState) {
ProvidedDeviceState.ATTACHING -> stringResource(R.string.usbip_state_attaching) to Tone.MEDIUM
ProvidedDeviceState.READY -> stringResource(R.string.usbip_state_ready) to Tone.GOOD
ProvidedDeviceState.ERROR -> stringResource(R.string.usbip_state_error) to Tone.BAD
null -> "" to Tone.NEUTRAL
}
}
@Composable
private fun toneColor(tone: Tone): Color = when (tone) {
Tone.GOOD -> Color(0xFF4CAF50)
Tone.MEDIUM -> MaterialTheme.colorScheme.primary
Tone.BAD -> MaterialTheme.colorScheme.error
Tone.NEUTRAL -> Color.Gray
}
private fun mergeRows(server: UsbipServerData, provided: List<ProvidedDevice>): List<UsbDeviceRow> {
val providedByBusId =
provided.filter { it.state == ProvidedDeviceState.READY && it.busId != null }.associateBy { it.busId!! }
val matched = HashSet<String>()
val rows = mutableListOf<UsbDeviceRow>()
for (device in server.devices) {
val providedDevice = providedByBusId[device.busId]
if (providedDevice != null) matched.add(providedDevice.deviceId)
rows.add(
UsbDeviceRow(
key = device.key,
name = device.product.ifEmpty { formatVidPid(device.vendorId, device.productId) },
vidPid = formatVidPid(device.vendorId, device.productId),
busId = device.busId,
error = null,
backendState = device.state,
providedState = null,
deviceData = device,
providedDeviceId = providedDevice?.deviceId,
),
)
}
for (device in provided) {
if (matched.contains(device.deviceId)) continue
rows.add(
UsbDeviceRow(
key = "provided-${device.deviceId}",
name = device.label,
vidPid = formatVidPid(device.vendorId, device.productId),
busId = if (device.state == ProvidedDeviceState.READY) device.busId else null,
error = if (device.state == ProvidedDeviceState.ERROR) device.error else null,
backendState = null,
providedState = device.state,
deviceData = null,
providedDeviceId = device.deviceId,
),
)
}
return rows
}
@@ -0,0 +1,164 @@
package io.nekohasekai.sfa.compose.screen.usbip
import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.USBIPServerStatus
import io.nekohasekai.libbox.USBIPServerStatusHandler
import io.nekohasekai.libbox.USBIPServerStatusSubscription
import io.nekohasekai.libbox.USBIPServerStatusUpdate
import io.nekohasekai.libbox.USBSharedDevice
import io.nekohasekai.sfa.compose.base.BaseViewModel
import io.nekohasekai.sfa.utils.CommandTarget
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
data class UsbSharedInterfaceData(
val interfaceClass: Int,
val interfaceSubClass: Int,
val interfaceProtocol: Int,
)
data class UsbSharedDeviceData(
val busId: String,
val stableId: String,
val backend: Int,
val state: Int,
val deviceId: String,
val busNum: Int,
val devNum: Int,
val speed: Int,
val vendorId: Int,
val productId: Int,
val bcdDevice: Int,
val deviceClass: Int,
val deviceSubClass: Int,
val deviceProtocol: Int,
val configurationValue: Int,
val numConfigurations: Int,
val serial: String,
val product: String,
val interfaces: List<UsbSharedInterfaceData>,
) {
val key: String get() = stableId.ifEmpty { busId }
}
data class UsbipServerData(
val serverTag: String,
val devices: List<UsbSharedDeviceData>,
)
data class USBIPStatusState(
val servers: List<UsbipServerData> = emptyList(),
val isSubscribed: Boolean = false,
)
class USBIPStatusViewModel : BaseViewModel<USBIPStatusState, Nothing>() {
private var subscription: USBIPServerStatusSubscription? = null
override fun createInitialState() = USBIPStatusState()
fun subscribe() {
if (currentState.isSubscribed) return
updateState { copy(isSubscribed = true) }
viewModelScope.launch(Dispatchers.IO) {
try {
subscription =
CommandTarget.standaloneClient().subscribeUSBIPServerStatus(
object : USBIPServerStatusHandler {
override fun onStatusUpdate(status: USBIPServerStatusUpdate) {
val servers = convertUpdate(status)
viewModelScope.launch {
if (!currentState.isSubscribed) return@launch
updateState { copy(servers = servers) }
}
}
override fun onError(message: String) {
viewModelScope.launch {
if (!currentState.isSubscribed) return@launch
updateState { copy(servers = emptyList(), isSubscribed = false) }
subscription = null
sendErrorMessage(message)
}
}
},
)
} catch (_: Exception) {
viewModelScope.launch {
updateState { copy(servers = emptyList(), isSubscribed = false) }
subscription = null
}
}
}
}
fun cancel() {
try {
subscription?.close()
} catch (_: Exception) {
}
subscription = null
updateState { copy(servers = emptyList(), isSubscribed = false) }
}
fun server(tag: String): UsbipServerData? = currentState.servers.firstOrNull { it.serverTag == tag }
override fun onCleared() {
cancel()
super.onCleared()
}
private fun convertUpdate(status: USBIPServerStatusUpdate): List<UsbipServerData> {
val servers = mutableListOf<UsbipServerData>()
val iterator = status.servers()
while (iterator.hasNext()) {
servers.add(convertServer(iterator.next()))
}
return servers
}
private fun convertServer(server: USBIPServerStatus): UsbipServerData {
val devices = mutableListOf<UsbSharedDeviceData>()
val iterator = server.devices()
while (iterator.hasNext()) {
devices.add(convertDevice(iterator.next()))
}
return UsbipServerData(serverTag = server.serverTag, devices = devices)
}
private fun convertDevice(device: USBSharedDevice): UsbSharedDeviceData {
val interfaces = mutableListOf<UsbSharedInterfaceData>()
val iterator = device.interfaces()
while (iterator.hasNext()) {
val iface = iterator.next()
interfaces.add(
UsbSharedInterfaceData(
interfaceClass = iface.interfaceClass,
interfaceSubClass = iface.interfaceSubClass,
interfaceProtocol = iface.interfaceProtocol,
),
)
}
return UsbSharedDeviceData(
busId = device.busID,
stableId = device.stableID,
backend = device.backend,
state = device.state,
deviceId = device.deviceID,
busNum = device.busNum,
devNum = device.devNum,
speed = device.speed,
vendorId = device.vendorID,
productId = device.productID,
bcdDevice = device.getBCDDevice(),
deviceClass = device.deviceClass,
deviceSubClass = device.deviceSubClass,
deviceProtocol = device.deviceProtocol,
configurationValue = device.configurationValue,
numConfigurations = device.numConfigurations,
serial = device.serial,
product = device.product,
interfaces = interfaces,
)
}
}
@@ -0,0 +1,63 @@
package io.nekohasekai.sfa.compose.screen.usbip
import android.app.PendingIntent
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 androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import io.nekohasekai.sfa.usbip.USBIPManager
private const val ACTION_USB_PERMISSION = "io.nekohasekai.sfa.action.USB_PERMISSION"
@Composable
fun rememberUsbAttacher(serverTag: String): (UsbDevice) -> Unit {
val context = LocalContext.current
val usbManager = remember { context.getSystemService(Context.USB_SERVICE) as UsbManager }
DisposableEffect(serverTag) {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(received: Context, intent: Intent) {
if (intent.action != ACTION_USB_PERMISSION) return
val device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice::class.java) ?: return
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
USBIPManager.attach(received, serverTag, device)
}
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(ACTION_USB_PERMISSION),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
onDispose { context.unregisterReceiver(receiver) }
}
return remember(serverTag) {
{ device: UsbDevice ->
if (usbManager.hasPermission(device)) {
USBIPManager.attach(context, serverTag, device)
} else {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val pendingIntent =
PendingIntent.getBroadcast(
context,
0,
Intent(ACTION_USB_PERMISSION).setPackage(context.packageName),
flags,
)
usbManager.requestPermission(device, pendingIntent)
}
}
}
}
@@ -22,20 +22,35 @@ class RemoteServer(
var secret: String = "",
) : Parcelable {
val displayName: String
get() = name.ifEmpty { url }
get() = name.ifEmpty { hostPort(url) }
companion object {
fun validateURL(urlString: String): String? {
var trimmed = urlString.trim()
if (trimmed.isEmpty()) {
return null
private val schemePrefix = Regex("^https?://", RegexOption.IGNORE_CASE)
private val httpPrefix = Regex("^http://", RegexOption.IGNORE_CASE)
// The stored form: scheme-less for http (default), keeping an explicit https.
fun normalizeURL(urlString: String): String = urlString.trim().trimEnd('/').replaceFirst(httpPrefix, "")
// The form passed to libbox: a scheme is required, defaulting to http.
fun connectURL(urlString: String): String {
val value = urlString.trim().trimEnd('/')
if (value.isEmpty()) {
return ""
}
if (!trimmed.contains("://")) {
trimmed = "http://$trimmed"
if (value.contains(schemePrefix)) {
return value
}
return "http://$value"
}
fun validateURL(urlString: String): String? {
val connectURL = connectURL(urlString)
if (connectURL.isEmpty()) {
return null
}
val uri =
try {
URI(trimmed)
URI(connectURL)
} catch (_: Exception) {
return null
}
@@ -46,7 +61,18 @@ class RemoteServer(
if (uri.host.isNullOrEmpty()) {
return null
}
return trimmed
return normalizeURL(urlString)
}
private fun hostPort(urlString: String): String {
val uri =
try {
URI(connectURL(urlString))
} catch (_: Exception) {
return urlString
}
val host = uri.host ?: return urlString
return if (uri.port != -1) "$host:${uri.port}" else host
}
}
@@ -0,0 +1,20 @@
package io.nekohasekai.sfa.usbip
enum class ProvidedDeviceState { ATTACHING, READY, ERROR }
data class ProvidedDevice(
val deviceId: String,
val serverTag: String,
val label: String,
val vendorId: Int,
val productId: Int,
val state: ProvidedDeviceState,
val busId: String? = null,
val error: String? = null,
val usbDeviceName: String? = null,
)
data class USBIPProviderState(
val devices: List<ProvidedDevice> = emptyList(),
val endErrors: Map<String, String> = emptyMap(),
)
@@ -0,0 +1,255 @@
package io.nekohasekai.sfa.usbip
import android.content.Context
import android.content.Intent
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import androidx.core.content.ContextCompat
import io.nekohasekai.libbox.USBProviderHandler
import io.nekohasekai.libbox.USBProviderSession
import io.nekohasekai.libbox.USBURBRequest
import io.nekohasekai.libbox.USBURBResponse
import io.nekohasekai.sfa.bg.USBIPService
import io.nekohasekai.sfa.utils.CommandTarget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
object USBIPManager {
private val access = Any()
private val sessions = HashMap<String, ServerSession>()
private val endErrors = HashMap<String, String>()
private val counter = AtomicInteger(0)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var serviceStarted = false
private val _state = MutableStateFlow(USBIPProviderState())
val state: StateFlow<USBIPProviderState> = _state.asStateFlow()
private class ServerSession(val serverTag: String, val session: USBProviderSession) {
val bridges = HashMap<String, UsbDeviceBridge>()
val devices = HashMap<String, ProvidedDevice>()
@Volatile
var closed = false
}
fun attach(context: Context, serverTag: String, device: UsbDevice) {
val appContext = context.applicationContext
scope.launch { attachInternal(appContext, serverTag, device) }
}
fun detach(deviceId: String) {
scope.launch { detachInternal(deviceId) }
}
fun detachByDevice(device: UsbDevice) {
val deviceId =
synchronized(access) {
sessions.values.flatMap { it.devices.values }.firstOrNull { it.usbDeviceName == device.deviceName }?.deviceId
} ?: return
detach(deviceId)
}
fun shutdown() {
scope.launch {
val current = synchronized(access) { sessions.values.toList().also { sessions.clear() } }
for (session in current) {
session.closed = true
for (bridge in session.bridges.values) bridge.close()
runCatching { session.session.close() }
}
synchronized(access) { endErrors.clear() }
publish()
}
}
fun onServiceDestroyed() {
serviceStarted = false
}
private fun attachInternal(context: Context, serverTag: String, device: UsbDevice) {
val deviceId = "dev-${counter.incrementAndGet()}"
try {
val session = ensureSession(serverTag)
synchronized(access) { session.devices[deviceId] = providedDevice(deviceId, serverTag, device) }
publish()
startServiceIfNeeded(context)
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val (bridge, descriptor) =
UsbDeviceBridge.open(deviceId, serverTag, manager, device) { response -> sendResponse(session, response) }
val kept =
synchronized(access) {
if (session.devices.containsKey(deviceId)) {
session.bridges[deviceId] = bridge
true
} else {
false
}
}
if (!kept) {
bridge.close()
return
}
session.session.attachDevice(descriptor)
} catch (e: Throwable) {
markError(serverTag, deviceId, e.message ?: "attach failed")
}
}
private fun detachInternal(deviceId: String) {
val session = synchronized(access) { sessions.values.firstOrNull { it.devices.containsKey(deviceId) } } ?: return
val bridge = synchronized(access) {
session.devices.remove(deviceId)
session.bridges.remove(deviceId)
}
runCatching { session.session.detachDevice(deviceId) }
bridge?.close()
publish()
closeSessionIfEmpty(session)
}
// The libbox session is opened outside the lock: provideUSBDevices dials the daemon
// and would otherwise block URB dispatch for already-open sessions.
private fun ensureSession(serverTag: String): ServerSession {
synchronized(access) { sessions[serverTag]?.let { return it } }
val libboxSession = CommandTarget.standaloneClient().provideUSBDevices(handler(serverTag))
synchronized(access) {
sessions[serverTag]?.let {
runCatching { libboxSession.close() }
return it
}
val session = ServerSession(serverTag, libboxSession)
sessions[serverTag] = session
return session
}
}
private fun providedDevice(deviceId: String, serverTag: String, device: UsbDevice) = ProvidedDevice(
deviceId = deviceId,
serverTag = serverTag,
label = device.productName ?: formatVidPid(device.vendorId, device.productId),
vendorId = device.vendorId,
productId = device.productId,
state = ProvidedDeviceState.ATTACHING,
usbDeviceName = device.deviceName,
)
private fun handler(serverTag: String): USBProviderHandler = object : USBProviderHandler {
override fun onReady(deviceID: String, busID: String) = onDeviceReady(serverTag, deviceID, busID)
override fun onURBRequest(request: USBURBRequest) = dispatchUrb(serverTag, request)
override fun onAbort(deviceID: String, endpoint: Int) {}
override fun onError(deviceID: String, message: String) = onProviderError(serverTag, deviceID, message)
}
private fun sendResponse(session: ServerSession, response: USBURBResponse) {
if (session.closed) return
runCatching { session.session.sendURBResponse(response) }
}
private fun dispatchUrb(serverTag: String, request: USBURBRequest) {
val bridge = synchronized(access) { sessions[serverTag]?.bridges?.get(request.deviceID) } ?: return
bridge.submit(request)
}
private fun onDeviceReady(serverTag: String, deviceId: String, busId: String) {
synchronized(access) {
val session = sessions[serverTag] ?: return@synchronized
val device = session.devices[deviceId] ?: return@synchronized
session.devices[deviceId] = device.copy(state = ProvidedDeviceState.READY, busId = busId, error = null)
}
publish()
}
private fun onProviderError(serverTag: String, deviceId: String, message: String) {
val session = synchronized(access) { sessions[serverTag] } ?: return
if (session.closed) return
if (deviceId.isNotEmpty()) {
markError(serverTag, deviceId, message)
val bridge = synchronized(access) { session.bridges.remove(deviceId) }
bridge?.close()
return
}
session.closed = true
val bridges =
synchronized(access) {
endErrors[serverTag] = message
session.devices.keys.forEach { id ->
val device = session.devices.getValue(id)
if (device.state != ProvidedDeviceState.ERROR) {
session.devices[id] = device.copy(state = ProvidedDeviceState.ERROR, error = message)
}
}
session.bridges.values.toList().also { session.bridges.clear() }
}
for (bridge in bridges) bridge.close()
runCatching { session.session.close() }
synchronized(access) { sessions.remove(serverTag) }
publish()
stopServiceIfIdle()
}
private fun markError(serverTag: String, deviceId: String, message: String) {
synchronized(access) {
val session = sessions[serverTag]
val device = session?.devices?.get(deviceId)
if (session != null && device != null) {
session.devices[deviceId] = device.copy(state = ProvidedDeviceState.ERROR, error = message)
} else {
endErrors[serverTag] = message
}
}
publish()
}
private fun closeSessionIfEmpty(session: ServerSession) {
val empty =
synchronized(access) {
if (session.devices.isEmpty()) {
session.closed = true
sessions.remove(session.serverTag)
endErrors.remove(session.serverTag)
true
} else {
false
}
}
if (empty) {
runCatching { session.session.close() }
publish()
stopServiceIfIdle()
}
}
private fun publish() {
val snapshot =
synchronized(access) {
USBIPProviderState(
devices = sessions.values.flatMap { it.devices.values }.sortedBy { it.deviceId },
endErrors = HashMap(endErrors),
)
}
_state.value = snapshot
}
private fun startServiceIfNeeded(context: Context) {
if (serviceStarted) return
serviceStarted = true
ContextCompat.startForegroundService(context, Intent(context, USBIPService::class.java))
}
private fun stopServiceIfIdle() {
val idle = synchronized(access) { sessions.isEmpty() }
if (idle) serviceStarted = false
}
}
@@ -0,0 +1,278 @@
package io.nekohasekai.sfa.usbip
import android.hardware.usb.UsbConfiguration
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.USBDeviceDescriptor
import io.nekohasekai.libbox.USBURBRequest
import io.nekohasekai.libbox.USBURBResponse
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class UsbDeviceBridge private constructor(
val deviceId: String,
private val device: UsbDevice,
private val connection: UsbDeviceConnection,
private val send: (USBURBResponse) -> Unit,
) {
private val claimedInterfaces = ArrayList<UsbInterface>()
private val endpoints = ConcurrentHashMap<Int, UsbEndpoint>()
private val executors = ConcurrentHashMap<Int, ExecutorService>()
@Volatile
private var closed = false
private fun start(serverTag: String): USBDeviceDescriptor {
val configuration = if (device.configurationCount > 0) device.getConfiguration(0) else null
if (configuration != null) {
claimConfiguration(configuration)
}
return buildDescriptor(serverTag, configuration)
}
private fun claimConfiguration(configuration: UsbConfiguration) {
releaseInterfaces()
endpoints.clear()
for (index in 0 until configuration.interfaceCount) {
val iface = configuration.getInterface(index)
if (connection.claimInterface(iface, true)) {
claimedInterfaces.add(iface)
indexEndpoints(iface)
}
}
}
private fun indexEndpoints(iface: UsbInterface) {
for (index in 0 until iface.endpointCount) {
val endpoint = iface.getEndpoint(index)
endpoints[endpoint.endpointNumber or endpoint.direction] = endpoint
}
}
private fun buildDescriptor(serverTag: String, configuration: UsbConfiguration?): USBDeviceDescriptor {
val raw = connection.rawDescriptors
val (busNum, devNum) = parseBusDevice(device.deviceName)
val descriptor = Libbox.newUSBDeviceDescriptor(serverTag, deviceId)
descriptor.setBusNum(busNum)
descriptor.setDevNum(devNum)
descriptor.setSpeed(inferSpeed(readWord(raw, 2)))
descriptor.setVendorID(device.vendorId)
descriptor.setProductID(device.productId)
descriptor.setBCDDevice(readWord(raw, 12))
descriptor.setDeviceClass(device.deviceClass)
descriptor.setDeviceSubClass(device.deviceSubclass)
descriptor.setDeviceProtocol(device.deviceProtocol)
descriptor.setConfigurationValue(configuration?.id ?: 0)
descriptor.setNumConfigurations(device.configurationCount)
descriptor.setSerial(serialNumber())
descriptor.setProduct(device.productName ?: "")
if (configuration != null) {
val seen = HashSet<Int>()
for (index in 0 until configuration.interfaceCount) {
val iface = configuration.getInterface(index)
if (seen.add(iface.id)) {
descriptor.addInterface(iface.interfaceClass, iface.interfaceSubclass, iface.interfaceProtocol)
}
}
}
return descriptor
}
private fun serialNumber(): String = try {
device.serialNumber ?: ""
} catch (_: SecurityException) {
""
}
fun submit(request: USBURBRequest) {
if (closed) return
val endpointNumber = request.endpoint and 0x0f
executors.getOrPut(endpointNumber) { Executors.newSingleThreadExecutor() }
.execute { execute(request, endpointNumber) }
}
private fun execute(request: USBURBRequest, endpointNumber: Int) {
if (closed) return
val response = Libbox.newUSBURBResponse(deviceId, request.seq)
try {
when {
endpointNumber == 0 -> executeControl(request, response)
request.numberOfPackets > 0 -> response.setStatus(URB_EPROTO)
else -> executeBulk(request, endpointNumber, response)
}
} catch (e: Throwable) {
response.setStatus(URB_EPROTO)
}
if (!closed) send(response)
}
private fun executeControl(request: USBURBRequest, response: USBURBResponse) {
val setup = UsbSetup(request.setup)
if (!setup.directionIn && setup.isStandard && handleManagedControl(setup)) {
response.setStatus(URB_OK)
response.setActualLength(0)
return
}
if (setup.directionIn) {
val buffer = ByteArray(request.transferBufferLength)
val transferred =
connection.controlTransfer(
setup.requestType,
setup.request,
setup.value,
setup.index,
buffer,
buffer.size,
CONTROL_TIMEOUT_MS,
)
applyResult(response, transferred, buffer)
} else {
val out = request.outData
val transferred =
connection.controlTransfer(
setup.requestType,
setup.request,
setup.value,
setup.index,
out,
out.size,
CONTROL_TIMEOUT_MS,
)
applyResult(response, transferred, null)
}
}
private fun handleManagedControl(setup: UsbSetup): Boolean {
when (setup.request) {
USB_REQUEST_SET_CONFIGURATION -> {
val configuration = findConfiguration(setup.value and 0xff) ?: return false
connection.setConfiguration(configuration)
claimConfiguration(configuration)
return true
}
USB_REQUEST_SET_INTERFACE -> {
val iface = findInterface(setup.index and 0xff, setup.value and 0xff) ?: return false
connection.setInterface(iface)
indexEndpoints(iface)
return true
}
}
return false
}
private fun executeBulk(request: USBURBRequest, endpointNumber: Int, response: USBURBResponse) {
val direction = if (request.directionIn) USB_DIR_IN else 0
val endpoint = endpoints[endpointNumber or direction]
if (endpoint == null) {
response.setStatus(URB_EPIPE)
return
}
if (request.directionIn) {
val buffer = ByteArray(request.transferBufferLength)
val transferred = connection.bulkTransfer(endpoint, buffer, buffer.size, TRANSFER_TIMEOUT_MS)
applyResult(response, transferred, buffer)
} else {
val out = request.outData
val transferred = connection.bulkTransfer(endpoint, out, out.size, TRANSFER_TIMEOUT_MS)
applyResult(response, transferred, null)
}
}
private fun applyResult(response: USBURBResponse, transferred: Int, inData: ByteArray?) {
if (transferred < 0) {
response.setStatus(URB_EPROTO)
return
}
response.setStatus(URB_OK)
response.setActualLength(transferred)
if (inData != null) {
response.setInData(if (transferred == inData.size) inData else inData.copyOf(transferred))
}
}
private fun findConfiguration(value: Int): UsbConfiguration? {
for (index in 0 until device.configurationCount) {
val configuration = device.getConfiguration(index)
if (configuration.id == value) return configuration
}
return null
}
private fun findInterface(interfaceNumber: Int, altSetting: Int): UsbInterface? {
for (index in 0 until device.interfaceCount) {
val iface = device.getInterface(index)
if (iface.id == interfaceNumber && iface.alternateSetting == altSetting) return iface
}
return null
}
private fun releaseInterfaces() {
for (iface in claimedInterfaces) {
try {
connection.releaseInterface(iface)
} catch (_: Throwable) {
}
}
claimedInterfaces.clear()
}
fun close() {
if (closed) return
closed = true
for (executor in executors.values) executor.shutdownNow()
executors.clear()
releaseInterfaces()
try {
connection.close()
} catch (_: Throwable) {
}
}
companion object {
fun open(
deviceId: String,
serverTag: String,
manager: UsbManager,
device: UsbDevice,
send: (USBURBResponse) -> Unit,
): Pair<UsbDeviceBridge, USBDeviceDescriptor> {
val connection = manager.openDevice(device) ?: throw IllegalStateException("open device failed")
val bridge = UsbDeviceBridge(deviceId, device, connection, send)
try {
return bridge to bridge.start(serverTag)
} catch (e: Throwable) {
bridge.close()
throw e
}
}
// UsbDevice.getDeviceName() is the usbfs node "/dev/bus/usb/BBB/DDD".
private fun parseBusDevice(name: String): Pair<Int, Int> {
val parts = name.split("/")
val devNum = parts.getOrNull(parts.size - 1)?.toIntOrNull() ?: 0
val busNum = parts.getOrNull(parts.size - 2)?.toIntOrNull() ?: 0
return busNum to devNum
}
private fun readWord(raw: ByteArray?, offset: Int): Int {
if (raw == null || raw.size < offset + 2) return 0
return (raw[offset].toInt() and 0xff) or ((raw[offset + 1].toInt() and 0xff) shl 8)
}
private fun inferSpeed(bcdUSB: Int): Int = when {
(bcdUSB shr 8) and 0xff >= 3 -> 5
(bcdUSB shr 8) and 0xff >= 2 -> 3
else -> 2
}
private const val CONTROL_TIMEOUT_MS = 5000
// Block until the endpoint completes; detach closes the fd to unblock waiters.
private const val TRANSFER_TIMEOUT_MS = 0
}
}
@@ -0,0 +1,62 @@
package io.nekohasekai.sfa.usbip
private val USB_CLASS_NAMES =
mapOf(
0x01 to "Audio",
0x02 to "CDC Control",
0x03 to "HID",
0x05 to "Physical",
0x06 to "Image",
0x07 to "Printer",
0x08 to "Mass Storage",
0x09 to "Hub",
0x0a to "CDC Data",
0x0b to "Smart Card",
0x0d to "Content Security",
0x0e to "Video",
0x0f to "Personal Healthcare",
0x10 to "Audio/Video",
0x11 to "Billboard",
0x12 to "USB-C Bridge",
0xdc to "Diagnostic",
0xe0 to "Wireless",
0xef to "Miscellaneous",
0xfe to "Application Specific",
0xff to "Vendor Specific",
)
private fun hex2(value: Int): String = "0x" + (value and 0xff).toString(16).padStart(2, '0')
fun usbClassName(code: Int): String? = if (code == 0) null else USB_CLASS_NAMES[code] ?: hex2(code)
fun usbClassTriplet(cls: Int, sub: Int, proto: Int): String {
val name = usbClassName(cls) ?: hex2(cls)
return if (sub > 0 || proto > 0) "$name · ${hex2(sub)} · ${hex2(proto)}" else name
}
private val USB_SPEED_LABELS =
mapOf(
1 to "Low Speed",
2 to "Full Speed",
3 to "High Speed",
4 to "Wireless",
5 to "SuperSpeed",
6 to "SuperSpeed+",
)
fun usbSpeedLabel(code: Int): String? = USB_SPEED_LABELS[code]
fun bcdToVersion(bcd: Int): String = "${(bcd shr 8) and 0xff}.${(bcd shr 4) and 0x0f}${bcd and 0x0f}"
fun formatVidPid(vendorId: Int, productId: Int): String {
fun hex4(value: Int) = (value and 0xffff).toString(16).padStart(4, '0')
return "${hex4(vendorId)}:${hex4(productId)}"
}
fun usbBackendLabel(backend: Int): String? = when (backend) {
1 -> "linux-sysfs"
2 -> "dynamic"
3 -> "darwin-iokit"
4 -> "windows-vboxusb"
else -> null
}
@@ -0,0 +1,29 @@
package io.nekohasekai.sfa.usbip
// Linux URB completion status conventions (negative errno) the usbip-server
// translates back to the remote client.
const val URB_OK = 0
const val URB_EPIPE = -32 // stalled endpoint
const val URB_EOVERFLOW = -75 // babble / buffer overrun
const val URB_EPROTO = -71 // transport or protocol failure
// Standard control requests the importing kernel issues during enumeration. Android
// forwards control transfers raw, but SET_CONFIGURATION / SET_INTERFACE must also go
// through UsbDeviceConnection so the host kernel's claimed-interface state stays in sync.
const val USB_REQUEST_SET_CONFIGURATION = 0x09
const val USB_REQUEST_SET_INTERFACE = 0x0b
const val USB_TYPE_STANDARD = 0x00
const val USB_TYPE_MASK = 0x60
const val USB_DIR_IN = 0x80
class UsbSetup(setup: ByteArray) {
val requestType: Int = setup[0].toInt() and 0xff
val request: Int = setup[1].toInt() and 0xff
val value: Int = (setup[2].toInt() and 0xff) or ((setup[3].toInt() and 0xff) shl 8)
val index: Int = (setup[4].toInt() and 0xff) or ((setup[5].toInt() and 0xff) shl 8)
val length: Int = (setup[6].toInt() and 0xff) or ((setup[7].toInt() and 0xff) shl 8)
val directionIn: Boolean get() = requestType and USB_DIR_IN != 0
val isStandard: Boolean get() = requestType and USB_TYPE_MASK == USB_TYPE_STANDARD
}
@@ -34,7 +34,7 @@ object CommandTarget {
fun libboxOptions(server: RemoteServer): RemoteConnectionOptions {
val options = RemoteConnectionOptions()
options.setURL(server.url)
options.setURL(RemoteServer.connectURL(server.url))
options.secret = server.secret
return options
}
+4
View File
@@ -457,6 +457,7 @@
<!-- STUN Test -->
<!-- Tailscale -->
<string name="tailscale_endpoints">نقاط اتصال</string>
<string name="title_services">سرویس‌ها</string>
<string name="tailscale_status">وضعیت</string>
<string name="tailscale_state">وضعیت</string>
@@ -611,4 +612,7 @@
<string name="remote_invalid_url">نشانی سرور نامعتبر است: %1$s، قالب مورد انتظار host:port، http://host:port یا https://host:port است</string>
<string name="remote_connect_failed">اتصال به سرور راه دور %1$s ناموفق بود</string>
<string name="remote_disconnected_from">اتصال با سرور راه دور %1$s قطع شد</string>
<string name="remote_checking">در حال بررسی…</string>
<string name="remote_available">در دسترس</string>
<string name="remote_unavailable">در دسترس نیست</string>
</resources>
@@ -463,6 +463,7 @@
<!-- STUN Test -->
<!-- Tailscale -->
<string name="tailscale_endpoints">Точки подключения</string>
<string name="title_services">Службы</string>
<string name="tailscale_status">Статус</string>
<string name="tailscale_state">Состояние</string>
@@ -617,4 +618,7 @@
<string name="remote_invalid_url">Неверный URL сервера: %1$s, ожидается host:port, http://host:port или https://host:port</string>
<string name="remote_connect_failed">Не удалось подключиться к удаленному серверу %1$s</string>
<string name="remote_disconnected_from">Отключено от удаленного сервера %1$s</string>
<string name="remote_checking">Проверка…</string>
<string name="remote_available">Доступно</string>
<string name="remote_unavailable">Недоступно</string>
</resources>
@@ -454,6 +454,7 @@
<!-- STUN Test -->
<!-- Tailscale -->
<string name="tailscale_endpoints">端点</string>
<string name="title_services">服务</string>
<string name="tailscale_status">状态</string>
<string name="tailscale_state">状态</string>
@@ -607,4 +608,7 @@
<string name="remote_invalid_url">无效的服务器 URL: %1$s, 应为 host:port、http://host:port 或 https://host:port</string>
<string name="remote_connect_failed">无法连接到远程服务器 %1$s</string>
<string name="remote_disconnected_from">已断开与远程服务器 %1$s 的连接</string>
<string name="remote_checking">检查中…</string>
<string name="remote_available">可用</string>
<string name="remote_unavailable">不可用</string>
</resources>
@@ -457,6 +457,7 @@
<!-- STUN Test -->
<!-- Tailscale -->
<string name="tailscale_endpoints">端點</string>
<string name="title_services">服務</string>
<string name="tailscale_status">狀態</string>
<string name="tailscale_state">狀態</string>
@@ -610,4 +611,7 @@
<string name="remote_invalid_url">無效的伺服器 URL: %1$s, 應為 host:port、http://host:port 或 https://host:port</string>
<string name="remote_connect_failed">無法連接到遠端伺服器 %1$s</string>
<string name="remote_disconnected_from">已斷開與遠端伺服器 %1$s 的連接</string>
<string name="remote_checking">檢查中…</string>
<string name="remote_available">可用</string>
<string name="remote_unavailable">不可用</string>
</resources>
+36
View File
@@ -618,4 +618,40 @@
<string name="remote_invalid_url">Invalid server URL: %1$s, expected host:port, http://host:port or https://host:port</string>
<string name="remote_connect_failed">Failed to connect to remote server %1$s</string>
<string name="remote_disconnected_from">Disconnected from remote server %1$s</string>
<string name="remote_checking">Checking…</string>
<string name="remote_available">Available</string>
<string name="remote_unavailable">Unavailable</string>
<string name="title_services">Services</string>
<string name="title_usbip">USB/IP</string>
<string name="usbip_with_tag">USB/IP: %1$s</string>
<string name="usbip_no_server">No usbip-server found</string>
<string name="usbip_no_devices">Pick a USB device to share it through this usbip-server.</string>
<string name="usbip_no_usb_devices">No USB devices connected</string>
<string name="usbip_connect_device">Connect USB device</string>
<string name="usbip_detach">Detach</string>
<string name="usbip_state_attaching">Attaching…</string>
<string name="usbip_state_ready">Ready</string>
<string name="usbip_state_error">Error</string>
<string name="usbip_state_idle">Idle</string>
<string name="usbip_state_attached">Attached</string>
<string name="usbip_state_unavailable">Unavailable</string>
<string name="usbip_identity">Identity</string>
<string name="usbip_connection">Connection</string>
<string name="usbip_class_interfaces">Class &amp; Interfaces</string>
<string name="usbip_product">Product</string>
<string name="usbip_serial">Serial number</string>
<string name="usbip_version">Version</string>
<string name="usbip_bus_id">Bus ID</string>
<string name="usbip_backend">Backend</string>
<string name="usbip_speed">Speed</string>
<string name="usbip_bus_device">Bus / Device</string>
<string name="usbip_device_class">Device class</string>
<string name="usbip_configuration">Configuration</string>
<string name="usbip_interface">Interface %1$d</string>
<string name="usbip_notification_title">USB/IP sharing</string>
<plurals name="usbip_notification_text">
<item quantity="one">Sharing %1$d USB device</item>
<item quantity="other">Sharing %1$d USB devices</item>
</plurals>
</resources>