Add USB/IP support
This commit is contained in:
@@ -7,10 +7,15 @@
|
|||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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_SYSTEM_EXEMPTED" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
@@ -142,6 +147,10 @@
|
|||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
android:value="proxy" />
|
android:value="proxy" />
|
||||||
</service>
|
</service>
|
||||||
|
<service
|
||||||
|
android:name=".bg.USBIPService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".bg.BootReceiver"
|
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.log.LogViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHSharedViewModel
|
import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHSharedViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
|
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.theme.SFATheme
|
||||||
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
|
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
|
||||||
import io.nekohasekai.sfa.compose.topbar.TopBarController
|
import io.nekohasekai.sfa.compose.topbar.TopBarController
|
||||||
@@ -768,6 +769,13 @@ class MainActivity :
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val usbIPStatusViewModel: USBIPStatusViewModel? =
|
||||||
|
if (isToolsRoute) {
|
||||||
|
viewModel()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
val showGroupsInNav = dashboardUiState.hasGroups
|
val showGroupsInNav = dashboardUiState.hasGroups
|
||||||
val showConnectionsInNav =
|
val showConnectionsInNav =
|
||||||
if (isRemote) {
|
if (isRemote) {
|
||||||
@@ -895,6 +903,7 @@ class MainActivity :
|
|||||||
connectionsViewModel = connectionsViewModel,
|
connectionsViewModel = connectionsViewModel,
|
||||||
tailscaleStatusViewModel = tailscaleStatusViewModel,
|
tailscaleStatusViewModel = tailscaleStatusViewModel,
|
||||||
tailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel,
|
tailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel,
|
||||||
|
usbIPStatusViewModel = usbIPStatusViewModel,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
if (!useNavigationRail) {
|
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.TailscaleSSHTerminalScreen
|
||||||
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
|
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen
|
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
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
|
||||||
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
|
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
|
||||||
@@ -91,6 +94,7 @@ fun SFANavHost(
|
|||||||
connectionsViewModel: ConnectionsViewModel? = null,
|
connectionsViewModel: ConnectionsViewModel? = null,
|
||||||
tailscaleStatusViewModel: TailscaleStatusViewModel? = null,
|
tailscaleStatusViewModel: TailscaleStatusViewModel? = null,
|
||||||
tailscaleSSHSharedViewModel: TailscaleSSHSharedViewModel? = null,
|
tailscaleSSHSharedViewModel: TailscaleSSHSharedViewModel? = null,
|
||||||
|
usbIPStatusViewModel: USBIPStatusViewModel? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
@@ -240,7 +244,8 @@ fun SFANavHost(
|
|||||||
composable(Screen.Tools.route) {
|
composable(Screen.Tools.route) {
|
||||||
val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel()
|
val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel()
|
||||||
val sshSharedViewModel: TailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel ?: 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
|
// Tools subscreens with slide animations
|
||||||
@@ -264,6 +269,36 @@ fun SFANavHost(
|
|||||||
STUNTestScreen(navController = navController, serviceStatus = serviceStatus)
|
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(
|
composable(
|
||||||
route = "tools/outbound_picker/{selectedOutbound}",
|
route = "tools/outbound_picker/{selectedOutbound}",
|
||||||
arguments = listOf(navArgument("selectedOutbound") { type = NavType.StringType }),
|
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -28,21 +33,30 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import io.nekohasekai.libbox.Libbox
|
||||||
|
import io.nekohasekai.libbox.RemoteConnectionOptions
|
||||||
import io.nekohasekai.sfa.R
|
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.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.database.RemoteServer
|
import io.nekohasekai.sfa.database.RemoteServer
|
||||||
import io.nekohasekai.sfa.database.RemoteServerManager
|
import io.nekohasekai.sfa.database.RemoteServerManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private enum class ProbeState { Idle, Checking, Available, Unavailable }
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
|
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 secretVisible by remember { mutableStateOf(false) }
|
||||||
var urlError by remember { mutableStateOf(false) }
|
var urlError by remember { mutableStateOf(false) }
|
||||||
var isLoading by remember { mutableStateOf(!isNewServer) }
|
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) {
|
LaunchedEffect(serverId) {
|
||||||
if (!isNewServer) {
|
if (!isNewServer) {
|
||||||
@@ -90,7 +133,7 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
|
|||||||
}
|
}
|
||||||
origin = server
|
origin = server
|
||||||
name = server.name
|
name = server.name
|
||||||
url = server.url
|
url = RemoteServer.normalizeURL(server.url)
|
||||||
secret = server.secret
|
secret = server.secret
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
@@ -123,6 +166,7 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
|
|||||||
onValueChange = {
|
onValueChange = {
|
||||||
url = it
|
url = it
|
||||||
urlError = false
|
urlError = false
|
||||||
|
userEdited = true
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text(stringResource(R.string.profile_url)) },
|
label = { Text(stringResource(R.string.profile_url)) },
|
||||||
@@ -140,7 +184,10 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
|
|||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = {
|
||||||
|
secret = it
|
||||||
|
userEdited = true
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text(stringResource(R.string.remote_secret)) },
|
label = { Text(stringResource(R.string.remote_secret)) },
|
||||||
placeholder = { Text(stringResource(R.string.remote_optional)) },
|
placeholder = { Text(stringResource(R.string.remote_optional)) },
|
||||||
@@ -167,6 +214,51 @@ fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
|
|||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val validatedURL = RemoteServer.validateURL(url)
|
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.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.outlined.Add
|
import androidx.compose.material.icons.outlined.Add
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
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.Edit
|
||||||
|
import androidx.compose.material.icons.outlined.SettingsRemote
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
@@ -43,6 +48,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import io.nekohasekai.sfa.R
|
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(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -106,134 +140,125 @@ fun RemoteControlScreen(navController: NavController) {
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Card(
|
||||||
text = stringResource(R.string.remote_servers),
|
modifier =
|
||||||
style = MaterialTheme.typography.labelLarge,
|
Modifier
|
||||||
color = MaterialTheme.colorScheme.primary,
|
.fillMaxWidth()
|
||||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
.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()) {
|
else -> RoundedCornerShape(0.dp)
|
||||||
Text(
|
}
|
||||||
text = stringResource(R.string.remote_no_servers),
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
Box {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
ListItem(
|
||||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp),
|
headlineContent = {
|
||||||
)
|
Text(
|
||||||
} else {
|
server.displayName,
|
||||||
Card(
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier =
|
)
|
||||||
Modifier
|
},
|
||||||
.fillMaxWidth()
|
supportingContent =
|
||||||
.padding(horizontal = 16.dp),
|
if (server.name.isNotEmpty()) {
|
||||||
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 = {
|
|
||||||
Text(
|
Text(
|
||||||
server.displayName,
|
server.url,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
supportingContent =
|
} else {
|
||||||
if (server.name.isNotEmpty()) {
|
null
|
||||||
{
|
},
|
||||||
Text(
|
leadingContent = {
|
||||||
server.url,
|
Icon(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
imageVector = Icons.Outlined.Dns,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
contentDescription = null,
|
||||||
)
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
}
|
)
|
||||||
} else {
|
},
|
||||||
null
|
trailingContent =
|
||||||
},
|
if (activeRemoteServer?.id == server.id) {
|
||||||
trailingContent =
|
{
|
||||||
if (activeRemoteServer?.id == server.id) {
|
Icon(
|
||||||
{
|
imageVector = Icons.Default.CheckCircle,
|
||||||
Icon(
|
contentDescription = null,
|
||||||
imageVector = Icons.Default.CheckCircle,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
contentDescription = null,
|
)
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
}
|
||||||
)
|
} else {
|
||||||
}
|
null
|
||||||
} else {
|
},
|
||||||
null
|
modifier =
|
||||||
},
|
Modifier
|
||||||
modifier =
|
.clip(shape)
|
||||||
Modifier
|
.combinedClickable(
|
||||||
.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)
|
|
||||||
},
|
|
||||||
onClick = {
|
onClick = {
|
||||||
showMenu = false
|
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
"settings/remote_control/edit/${server.id}",
|
"settings/remote_control/edit/${server.id}",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
onLongClick = { showMenu = true },
|
||||||
DropdownMenuItem(
|
),
|
||||||
text = {
|
colors =
|
||||||
Text(
|
ListItemDefaults.colors(
|
||||||
stringResource(R.string.menu_delete),
|
containerColor = Color.Transparent,
|
||||||
color = MaterialTheme.colorScheme.error,
|
),
|
||||||
)
|
)
|
||||||
},
|
DropdownMenu(
|
||||||
leadingIcon = {
|
expanded = showMenu,
|
||||||
Icon(
|
onDismissRequest = { showMenu = false },
|
||||||
Icons.Outlined.Delete,
|
) {
|
||||||
contentDescription = null,
|
DropdownMenuItem(
|
||||||
tint = MaterialTheme.colorScheme.error,
|
text = { Text(stringResource(R.string.edit)) },
|
||||||
)
|
leadingIcon = {
|
||||||
},
|
Icon(Icons.Outlined.Edit, contentDescription = null)
|
||||||
onClick = {
|
},
|
||||||
showMenu = false
|
onClick = {
|
||||||
scope.launch(Dispatchers.IO) {
|
showMenu = false
|
||||||
if (RemoteControlManager.remoteServer.value?.id == server.id) {
|
navController.navigate(
|
||||||
withContext(Dispatchers.Main) {
|
"settings/remote_control/edit/${server.id}",
|
||||||
RemoteControlManager.exitRemoteControl()
|
)
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
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,
|
sshSharedViewModel: TailscaleSSHSharedViewModel,
|
||||||
endpointTag: String,
|
endpointTag: String,
|
||||||
) {
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag }
|
||||||
|
|
||||||
OverrideTopBar {
|
OverrideTopBar {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(endpointTag) },
|
title = {
|
||||||
|
Text(
|
||||||
|
if (state.endpoints.size <= 1) {
|
||||||
|
stringResource(R.string.tailscale)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.tailscale_with_tag, endpointTag)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.navigateUp() }) {
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back))
|
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) {
|
if (endpoint == null) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
navController.navigateUp()
|
navController.navigateUp()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Terminal
|
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.BugReport
|
||||||
import androidx.compose.material.icons.outlined.Hub
|
import androidx.compose.material.icons.outlined.Hub
|
||||||
import androidx.compose.material.icons.outlined.Memory
|
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.bg.OOMReportManager
|
||||||
import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems
|
import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems
|
||||||
import io.nekohasekai.sfa.compose.component.rememberRemoteServers
|
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.compose.topbar.OverrideTopBar
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import io.nekohasekai.sfa.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
@@ -66,6 +68,7 @@ fun ToolsScreen(
|
|||||||
serviceStatus: Status = Status.Stopped,
|
serviceStatus: Status = Status.Stopped,
|
||||||
tailscaleViewModel: TailscaleStatusViewModel,
|
tailscaleViewModel: TailscaleStatusViewModel,
|
||||||
sshSharedViewModel: TailscaleSSHSharedViewModel,
|
sshSharedViewModel: TailscaleSSHSharedViewModel,
|
||||||
|
usbIPViewModel: USBIPStatusViewModel,
|
||||||
) {
|
) {
|
||||||
val remoteServers by rememberRemoteServers()
|
val remoteServers by rememberRemoteServers()
|
||||||
|
|
||||||
@@ -101,6 +104,7 @@ fun ToolsScreen(
|
|||||||
val crashUnreadCount by CrashReportManager.unreadCount.collectAsState()
|
val crashUnreadCount by CrashReportManager.unreadCount.collectAsState()
|
||||||
val oomUnreadCount by OOMReportManager.unreadCount.collectAsState()
|
val oomUnreadCount by OOMReportManager.unreadCount.collectAsState()
|
||||||
val tailscaleState by tailscaleViewModel.uiState.collectAsState()
|
val tailscaleState by tailscaleViewModel.uiState.collectAsState()
|
||||||
|
val usbIPState by usbIPViewModel.uiState.collectAsState()
|
||||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(remoteServer?.id) {
|
LaunchedEffect(remoteServer?.id) {
|
||||||
@@ -109,8 +113,10 @@ fun ToolsScreen(
|
|||||||
// without tailscale leaves no active stream to error out, so the
|
// without tailscale leaves no active stream to error out, so the
|
||||||
// subscription would stay stale without an explicit cancel.
|
// subscription would stay stale without an explicit cancel.
|
||||||
tailscaleViewModel.cancel()
|
tailscaleViewModel.cancel()
|
||||||
|
usbIPViewModel.cancel()
|
||||||
if (remoteServer != null || serviceStatus == Status.Started) {
|
if (remoteServer != null || serviceStatus == Status.Started) {
|
||||||
tailscaleViewModel.subscribe()
|
tailscaleViewModel.subscribe()
|
||||||
|
usbIPViewModel.subscribe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +126,10 @@ fun ToolsScreen(
|
|||||||
}
|
}
|
||||||
if (serviceStatus == Status.Started) {
|
if (serviceStatus == Status.Started) {
|
||||||
tailscaleViewModel.subscribe()
|
tailscaleViewModel.subscribe()
|
||||||
|
usbIPViewModel.subscribe()
|
||||||
} else {
|
} else {
|
||||||
tailscaleViewModel.cancel()
|
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(
|
||||||
text = stringResource(R.string.title_network),
|
text = stringResource(R.string.title_network),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
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 = "",
|
var secret: String = "",
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
val displayName: String
|
val displayName: String
|
||||||
get() = name.ifEmpty { url }
|
get() = name.ifEmpty { hostPort(url) }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun validateURL(urlString: String): String? {
|
private val schemePrefix = Regex("^https?://", RegexOption.IGNORE_CASE)
|
||||||
var trimmed = urlString.trim()
|
private val httpPrefix = Regex("^http://", RegexOption.IGNORE_CASE)
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
return null
|
// 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("://")) {
|
if (value.contains(schemePrefix)) {
|
||||||
trimmed = "http://$trimmed"
|
return value
|
||||||
|
}
|
||||||
|
return "http://$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateURL(urlString: String): String? {
|
||||||
|
val connectURL = connectURL(urlString)
|
||||||
|
if (connectURL.isEmpty()) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
val uri =
|
val uri =
|
||||||
try {
|
try {
|
||||||
URI(trimmed)
|
URI(connectURL)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -46,7 +61,18 @@ class RemoteServer(
|
|||||||
if (uri.host.isNullOrEmpty()) {
|
if (uri.host.isNullOrEmpty()) {
|
||||||
return null
|
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 {
|
fun libboxOptions(server: RemoteServer): RemoteConnectionOptions {
|
||||||
val options = RemoteConnectionOptions()
|
val options = RemoteConnectionOptions()
|
||||||
options.setURL(server.url)
|
options.setURL(RemoteServer.connectURL(server.url))
|
||||||
options.secret = server.secret
|
options.secret = server.secret
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,7 @@
|
|||||||
<!-- STUN Test -->
|
<!-- STUN Test -->
|
||||||
<!-- Tailscale -->
|
<!-- Tailscale -->
|
||||||
<string name="tailscale_endpoints">نقاط اتصال</string>
|
<string name="tailscale_endpoints">نقاط اتصال</string>
|
||||||
|
<string name="title_services">سرویسها</string>
|
||||||
|
|
||||||
<string name="tailscale_status">وضعیت</string>
|
<string name="tailscale_status">وضعیت</string>
|
||||||
<string name="tailscale_state">وضعیت</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_invalid_url">نشانی سرور نامعتبر است: %1$s، قالب مورد انتظار host:port، http://host:port یا https://host:port است</string>
|
||||||
<string name="remote_connect_failed">اتصال به سرور راه دور %1$s ناموفق بود</string>
|
<string name="remote_connect_failed">اتصال به سرور راه دور %1$s ناموفق بود</string>
|
||||||
<string name="remote_disconnected_from">اتصال با سرور راه دور %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>
|
</resources>
|
||||||
|
|||||||
@@ -463,6 +463,7 @@
|
|||||||
<!-- STUN Test -->
|
<!-- STUN Test -->
|
||||||
<!-- Tailscale -->
|
<!-- Tailscale -->
|
||||||
<string name="tailscale_endpoints">Точки подключения</string>
|
<string name="tailscale_endpoints">Точки подключения</string>
|
||||||
|
<string name="title_services">Службы</string>
|
||||||
|
|
||||||
<string name="tailscale_status">Статус</string>
|
<string name="tailscale_status">Статус</string>
|
||||||
<string name="tailscale_state">Состояние</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_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_connect_failed">Не удалось подключиться к удаленному серверу %1$s</string>
|
||||||
<string name="remote_disconnected_from">Отключено от удаленного сервера %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>
|
</resources>
|
||||||
|
|||||||
@@ -454,6 +454,7 @@
|
|||||||
<!-- STUN Test -->
|
<!-- STUN Test -->
|
||||||
<!-- Tailscale -->
|
<!-- Tailscale -->
|
||||||
<string name="tailscale_endpoints">端点</string>
|
<string name="tailscale_endpoints">端点</string>
|
||||||
|
<string name="title_services">服务</string>
|
||||||
|
|
||||||
<string name="tailscale_status">状态</string>
|
<string name="tailscale_status">状态</string>
|
||||||
<string name="tailscale_state">状态</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_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_connect_failed">无法连接到远程服务器 %1$s</string>
|
||||||
<string name="remote_disconnected_from">已断开与远程服务器 %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>
|
</resources>
|
||||||
|
|||||||
@@ -457,6 +457,7 @@
|
|||||||
<!-- STUN Test -->
|
<!-- STUN Test -->
|
||||||
<!-- Tailscale -->
|
<!-- Tailscale -->
|
||||||
<string name="tailscale_endpoints">端點</string>
|
<string name="tailscale_endpoints">端點</string>
|
||||||
|
<string name="title_services">服務</string>
|
||||||
|
|
||||||
<string name="tailscale_status">狀態</string>
|
<string name="tailscale_status">狀態</string>
|
||||||
<string name="tailscale_state">狀態</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_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_connect_failed">無法連接到遠端伺服器 %1$s</string>
|
||||||
<string name="remote_disconnected_from">已斷開與遠端伺服器 %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>
|
</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_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_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_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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user