From a58f32a5951de1d96ce01088f7b0484e4d1654ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 17 Jun 2026 19:06:22 +0800 Subject: [PATCH] Add USB/IP support --- app/src/main/AndroidManifest.xml | 9 + .../io/nekohasekai/sfa/bg/USBIPService.kt | 146 ++++++++ .../nekohasekai/sfa/compose/MainActivity.kt | 9 + .../sfa/compose/navigation/SFANavigation.kt | 37 +- .../screen/settings/EditRemoteServerScreen.kt | 96 ++++- .../screen/settings/RemoteControlScreen.kt | 261 +++++++------ .../screen/tools/TailscaleEndpointScreen.kt | 16 +- .../sfa/compose/screen/tools/ToolsScreen.kt | 61 +++ .../screen/usbip/USBIPDeviceDetailScreen.kt | 146 ++++++++ .../compose/screen/usbip/USBIPServerScreen.kt | 354 ++++++++++++++++++ .../screen/usbip/USBIPStatusViewModel.kt | 164 ++++++++ .../sfa/compose/screen/usbip/UsbAttach.kt | 63 ++++ .../nekohasekai/sfa/database/RemoteServer.kt | 44 ++- .../nekohasekai/sfa/usbip/ProvidedDevice.kt | 20 + .../io/nekohasekai/sfa/usbip/USBIPManager.kt | 255 +++++++++++++ .../nekohasekai/sfa/usbip/UsbDeviceBridge.kt | 278 ++++++++++++++ .../java/io/nekohasekai/sfa/usbip/UsbInfo.kt | 62 +++ .../java/io/nekohasekai/sfa/usbip/UsbUrb.kt | 29 ++ .../io/nekohasekai/sfa/utils/CommandTarget.kt | 2 +- app/src/main/res/values-fa/strings.xml | 4 + app/src/main/res/values-ru-rRU/strings.xml | 4 + app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values-zh-rTW/strings.xml | 4 + app/src/main/res/values/strings.xml | 36 ++ 24 files changed, 1969 insertions(+), 135 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/USBIPService.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPDeviceDetailScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPServerScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPStatusViewModel.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/UsbAttach.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/usbip/ProvidedDevice.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/usbip/USBIPManager.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/usbip/UsbDeviceBridge.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/usbip/UsbInfo.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/usbip/UsbUrb.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5d9d8ff..b4b1a30 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,10 +7,15 @@ android:name="android.hardware.camera" android:required="false" /> + + + @@ -142,6 +147,10 @@ android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="proxy" /> + + 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 + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 10db892..1af987a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -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) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index afd5239..c286036 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -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 }), diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt index 957aa71..7758890 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt @@ -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) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt index b026933..0ffca66 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt @@ -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) + } + }, + ) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt index bb34a12..147971a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt @@ -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() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt index bdc2a5c..72dc367 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt @@ -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, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPDeviceDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPDeviceDetailScreen.kt new file mode 100644 index 0000000..2e4a20c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPDeviceDetailScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPServerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPServerScreen.kt new file mode 100644 index 0000000..63e876e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPServerScreen.kt @@ -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, 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 { + 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): List { + val providedByBusId = + provided.filter { it.state == ProvidedDeviceState.READY && it.busId != null }.associateBy { it.busId!! } + val matched = HashSet() + val rows = mutableListOf() + + 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 +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPStatusViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPStatusViewModel.kt new file mode 100644 index 0000000..3f7b697 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/USBIPStatusViewModel.kt @@ -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, +) { + val key: String get() = stableId.ifEmpty { busId } +} + +data class UsbipServerData( + val serverTag: String, + val devices: List, +) + +data class USBIPStatusState( + val servers: List = emptyList(), + val isSubscribed: Boolean = false, +) + +class USBIPStatusViewModel : BaseViewModel() { + 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 { + val servers = mutableListOf() + val iterator = status.servers() + while (iterator.hasNext()) { + servers.add(convertServer(iterator.next())) + } + return servers + } + + private fun convertServer(server: USBIPServerStatus): UsbipServerData { + val devices = mutableListOf() + 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() + 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, + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/UsbAttach.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/UsbAttach.kt new file mode 100644 index 0000000..d58b1bd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/usbip/UsbAttach.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt index 6e4eb25..411afcd 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt @@ -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 } } diff --git a/app/src/main/java/io/nekohasekai/sfa/usbip/ProvidedDevice.kt b/app/src/main/java/io/nekohasekai/sfa/usbip/ProvidedDevice.kt new file mode 100644 index 0000000..7b94e67 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/usbip/ProvidedDevice.kt @@ -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 = emptyList(), + val endErrors: Map = emptyMap(), +) diff --git a/app/src/main/java/io/nekohasekai/sfa/usbip/USBIPManager.kt b/app/src/main/java/io/nekohasekai/sfa/usbip/USBIPManager.kt new file mode 100644 index 0000000..b55e712 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/usbip/USBIPManager.kt @@ -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() + private val endErrors = HashMap() + 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 = _state.asStateFlow() + + private class ServerSession(val serverTag: String, val session: USBProviderSession) { + val bridges = HashMap() + val devices = HashMap() + + @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 + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/usbip/UsbDeviceBridge.kt b/app/src/main/java/io/nekohasekai/sfa/usbip/UsbDeviceBridge.kt new file mode 100644 index 0000000..486ee7e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/usbip/UsbDeviceBridge.kt @@ -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() + private val endpoints = ConcurrentHashMap() + private val executors = ConcurrentHashMap() + + @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() + 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 { + 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 { + 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 + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/usbip/UsbInfo.kt b/app/src/main/java/io/nekohasekai/sfa/usbip/UsbInfo.kt new file mode 100644 index 0000000..ab164c6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/usbip/UsbInfo.kt @@ -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 +} diff --git a/app/src/main/java/io/nekohasekai/sfa/usbip/UsbUrb.kt b/app/src/main/java/io/nekohasekai/sfa/usbip/UsbUrb.kt new file mode 100644 index 0000000..206e987 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/usbip/UsbUrb.kt @@ -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 +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt index 8187f09..2132fd7 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt @@ -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 } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 22d699b..8d02cca 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -457,6 +457,7 @@ نقاط اتصال + سرویس‌ها وضعیت وضعیت @@ -611,4 +612,7 @@ نشانی سرور نامعتبر است: %1$s، قالب مورد انتظار host:port، http://host:port یا https://host:port است اتصال به سرور راه دور %1$s ناموفق بود اتصال با سرور راه دور %1$s قطع شد + در حال بررسی… + در دسترس + در دسترس نیست diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 69b9b8e..bdf0ee5 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -463,6 +463,7 @@ Точки подключения + Службы Статус Состояние @@ -617,4 +618,7 @@ Неверный URL сервера: %1$s, ожидается host:port, http://host:port или https://host:port Не удалось подключиться к удаленному серверу %1$s Отключено от удаленного сервера %1$s + Проверка… + Доступно + Недоступно diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e48d8b2..80126bf 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -454,6 +454,7 @@ 端点 + 服务 状态 状态 @@ -607,4 +608,7 @@ 无效的服务器 URL: %1$s, 应为 host:port、http://host:port 或 https://host:port 无法连接到远程服务器 %1$s 已断开与远程服务器 %1$s 的连接 + 检查中… + 可用 + 不可用 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 611aaf5..242913b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -457,6 +457,7 @@ 端點 + 服務 狀態 狀態 @@ -610,4 +611,7 @@ 無效的伺服器 URL: %1$s, 應為 host:port、http://host:port 或 https://host:port 無法連接到遠端伺服器 %1$s 已斷開與遠端伺服器 %1$s 的連接 + 檢查中… + 可用 + 不可用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e68738..3afe185 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -618,4 +618,40 @@ Invalid server URL: %1$s, expected host:port, http://host:port or https://host:port Failed to connect to remote server %1$s Disconnected from remote server %1$s + Checking… + Available + Unavailable + + Services + USB/IP + USB/IP: %1$s + No usbip-server found + Pick a USB device to share it through this usbip-server. + No USB devices connected + Connect USB device + Detach + Attaching… + Ready + Error + Idle + Attached + Unavailable + Identity + Connection + Class & Interfaces + Product + Serial number + Version + Bus ID + Backend + Speed + Bus / Device + Device class + Configuration + Interface %1$d + USB/IP sharing + + Sharing %1$d USB device + Sharing %1$d USB devices +