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