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
+