Add remote control
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "5e9ed567b06755e1a22c503a7390dea5",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "profiles",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `icon` TEXT DEFAULT NULL, `typed` BLOB NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userOrder",
|
||||
"columnName": "userOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon",
|
||||
"affinity": "TEXT",
|
||||
"defaultValue": "NULL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "typed",
|
||||
"columnName": "typed",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "remote_servers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `secret` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userOrder",
|
||||
"columnName": "userOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "secret",
|
||||
"columnName": "secret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5e9ed567b06755e1a22c503a7390dea5')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class ServiceNotification(private val status: MutableLiveData<Status>, private v
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private val commandClient =
|
||||
CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this)
|
||||
CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this, localOnly = true)
|
||||
private var receiverRegistered = false
|
||||
|
||||
private val notificationBuilder by lazy {
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.UnfoldLess
|
||||
@@ -97,6 +98,7 @@ import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat
|
||||
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.component.RemoteStatusBar
|
||||
import io.nekohasekai.sfa.compose.component.ServiceStatusBar
|
||||
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
|
||||
import io.nekohasekai.sfa.compose.component.UptimeText
|
||||
@@ -128,6 +130,7 @@ import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.hasPermission
|
||||
import io.nekohasekai.sfa.ktx.launchCustomTab
|
||||
import io.nekohasekai.sfa.update.UpdateState
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import io.nekohasekai.sfa.vendor.Vendor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -199,6 +202,7 @@ class MainActivity :
|
||||
enableEdgeToEdge()
|
||||
|
||||
connection.reconnect()
|
||||
RemoteControlManager.restore()
|
||||
|
||||
UpdateState.loadFromCache()
|
||||
if (Settings.checkUpdateEnabled) {
|
||||
@@ -695,6 +699,11 @@ class MainActivity :
|
||||
)
|
||||
}
|
||||
|
||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
val remoteConnected by RemoteControlManager.isConnected.collectAsState()
|
||||
val remoteStartedAt by RemoteControlManager.startedAt.collectAsState()
|
||||
val isRemote = remoteServer != null
|
||||
|
||||
// Initialize the dashboard view model and store reference
|
||||
val dashboardViewModel: DashboardViewModel = viewModel()
|
||||
if (!::dashboardViewModel.isInitialized) {
|
||||
@@ -761,7 +770,11 @@ class MainActivity :
|
||||
|
||||
val showGroupsInNav = dashboardUiState.hasGroups
|
||||
val showConnectionsInNav =
|
||||
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
|
||||
if (isRemote) {
|
||||
remoteConnected
|
||||
} else {
|
||||
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
|
||||
}
|
||||
|
||||
val railScreens =
|
||||
buildList {
|
||||
@@ -840,6 +853,12 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
is UiEvent.Navigate -> {
|
||||
navController.navigate(event.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
is UiEvent.ApplyServiceChange -> enqueueApplyServiceChange(event.mode)
|
||||
}
|
||||
}
|
||||
@@ -855,11 +874,12 @@ class MainActivity :
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
// Service Status Bar (shown when service is running or stopping)
|
||||
// Service Status Bar (shown when service is running or stopping);
|
||||
// remote control replaces it with the remote session bar.
|
||||
val serviceRunning =
|
||||
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
|
||||
val showStatusBar = serviceRunning || currentServiceStatus == Status.Stopping
|
||||
val showStartFab = !serviceRunning && dashboardUiState.selectedProfileId != -1L
|
||||
val showStatusBar = isRemote || serviceRunning || currentServiceStatus == Status.Stopping
|
||||
val showStartFab = !isRemote && !serviceRunning && dashboardUiState.selectedProfileId != -1L
|
||||
|
||||
SFANavHost(
|
||||
navController = navController,
|
||||
@@ -878,18 +898,34 @@ class MainActivity :
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (!useNavigationRail) {
|
||||
ServiceStatusBar(
|
||||
visible = showStatusBar && !isSubScreen,
|
||||
serviceStatus = currentServiceStatus,
|
||||
startTime = dashboardUiState.serviceStartTime,
|
||||
groupsCount = dashboardUiState.groupsCount,
|
||||
hasGroups = dashboardUiState.hasGroups,
|
||||
onGroupsClick = { showGroupsSheet = true },
|
||||
connectionsCount = dashboardUiState.connectionsCount,
|
||||
onConnectionsClick = { showConnectionsSheet = true },
|
||||
onStopClick = { dashboardViewModel.toggleService() },
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
if (isRemote) {
|
||||
RemoteStatusBar(
|
||||
visible = !isSubScreen,
|
||||
serverName = remoteServer?.displayName ?: "",
|
||||
isConnected = remoteConnected,
|
||||
startTime = remoteStartedAt,
|
||||
groupsCount = dashboardUiState.groupsCount,
|
||||
hasGroups = dashboardUiState.hasGroups,
|
||||
onGroupsClick = { showGroupsSheet = true },
|
||||
connectionsCount = dashboardUiState.connectionsCount,
|
||||
onConnectionsClick = { showConnectionsSheet = true },
|
||||
onDisconnectClick = { RemoteControlManager.exitRemoteControl() },
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
} else {
|
||||
ServiceStatusBar(
|
||||
visible = showStatusBar && !isSubScreen,
|
||||
serviceStatus = currentServiceStatus,
|
||||
startTime = dashboardUiState.serviceStartTime,
|
||||
groupsCount = dashboardUiState.groupsCount,
|
||||
hasGroups = dashboardUiState.hasGroups,
|
||||
onGroupsClick = { showGroupsSheet = true },
|
||||
connectionsCount = dashboardUiState.connectionsCount,
|
||||
onConnectionsClick = { showConnectionsSheet = true },
|
||||
onStopClick = { dashboardViewModel.toggleService() },
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val showPadFab = useNavigationRail && !isSubScreen && (showStartFab || showStatusBar)
|
||||
@@ -905,7 +941,35 @@ class MainActivity :
|
||||
val isRunning =
|
||||
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
|
||||
val isStopping = currentServiceStatus == Status.Stopping
|
||||
if (currentServiceStatus == Status.Stopped) {
|
||||
if (isRemote) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { RemoteControlManager.exitRemoteControl() },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LinkOff,
|
||||
contentDescription = stringResource(R.string.remote_disconnect),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (remoteConnected && remoteStartedAt != null) {
|
||||
UptimeText(startTime = remoteStartedAt!!)
|
||||
} else {
|
||||
Text(
|
||||
text =
|
||||
if (remoteConnected) {
|
||||
remoteServer?.displayName ?: ""
|
||||
} else {
|
||||
stringResource(R.string.remote_connecting)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.height(64.dp),
|
||||
)
|
||||
} else if (currentServiceStatus == Status.Stopped) {
|
||||
FloatingActionButton(
|
||||
onClick = { startService() },
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
@@ -981,7 +1045,8 @@ class MainActivity :
|
||||
} else {
|
||||
// Start FAB (shown when service is stopped and a profile is selected)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = currentServiceStatus == Status.Stopped &&
|
||||
visible = !isRemote &&
|
||||
currentServiceStatus == Status.Stopped &&
|
||||
dashboardUiState.selectedProfileId != -1L &&
|
||||
!isSubScreen,
|
||||
enter = scaleIn(),
|
||||
@@ -1007,7 +1072,8 @@ class MainActivity :
|
||||
|
||||
val crashReportUnreadCount by CrashReportManager.unreadCount.collectAsState()
|
||||
val oomReportUnreadCount by OOMReportManager.unreadCount.collectAsState()
|
||||
val toolsUnreadCount = crashReportUnreadCount + oomReportUnreadCount
|
||||
// The crash/OOM report entries are hidden in remote control mode.
|
||||
val toolsUnreadCount = if (isRemote) 0 else crashReportUnreadCount + oomReportUnreadCount
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -15,6 +15,8 @@ sealed class UiEvent {
|
||||
|
||||
data class EditProfile(val profileId: Long) : UiEvent()
|
||||
|
||||
data class Navigate(val route: String) : UiEvent()
|
||||
|
||||
object RequestStartService : UiEvent()
|
||||
|
||||
object RequestReconnectService : UiEvent()
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package io.nekohasekai.sfa.compose.component
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.database.RemoteServer
|
||||
import io.nekohasekai.sfa.database.RemoteServerManager
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
const val REMOTE_CONTROL_ROUTE = "settings/remote_control"
|
||||
|
||||
@Composable
|
||||
fun rememberRemoteServers(): State<List<RemoteServer>> {
|
||||
val scope = rememberCoroutineScope()
|
||||
val servers = remember { mutableStateOf<List<RemoteServer>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
servers.value = withContext(Dispatchers.IO) { RemoteServerManager.list() }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val callback: () -> Unit = {
|
||||
scope.launch {
|
||||
servers.value = withContext(Dispatchers.IO) { RemoteServerManager.list() }
|
||||
}
|
||||
}
|
||||
RemoteServerManager.registerCallback(callback)
|
||||
onDispose {
|
||||
RemoteServerManager.unregisterCallback(callback)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoteControlMenuItems(servers: List<RemoteServer>, onAction: () -> Unit, leadingDivider: Boolean = true) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (leadingDivider) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.remote_control),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
SelectableMenuItem(
|
||||
label = stringResource(R.string.remote_local_device),
|
||||
selected = remoteServer == null,
|
||||
onClick = {
|
||||
onAction()
|
||||
RemoteControlManager.exitRemoteControl()
|
||||
},
|
||||
)
|
||||
servers.forEach { server ->
|
||||
val isActive = remoteServer?.id == server.id
|
||||
SelectableMenuItem(
|
||||
label = server.displayName,
|
||||
selected = isActive,
|
||||
onClick = {
|
||||
onAction()
|
||||
if (!isActive) {
|
||||
RemoteControlManager.enterRemoteControl(server)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.remote_manage_servers)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Tune,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onAction()
|
||||
scope.launch {
|
||||
GlobalEventBus.emit(UiEvent.Navigate(REMOTE_CONTROL_ROUTE))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectableMenuItem(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (selected) {
|
||||
Icons.Default.RadioButtonChecked
|
||||
} else {
|
||||
Icons.Default.RadioButtonUnchecked
|
||||
},
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package io.nekohasekai.sfa.compose.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material.icons.outlined.Cable
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
// Mirrors the remote status pill of the Apple clients: server name (or
|
||||
// connecting state), groups/connections shortcuts, the remote service uptime,
|
||||
// and a disconnect button.
|
||||
@Composable
|
||||
fun RemoteStatusBar(
|
||||
visible: Boolean,
|
||||
serverName: String,
|
||||
isConnected: Boolean,
|
||||
startTime: Long?,
|
||||
groupsCount: Int,
|
||||
hasGroups: Boolean,
|
||||
onGroupsClick: () -> Unit,
|
||||
connectionsCount: Int,
|
||||
onConnectionsClick: () -> Unit,
|
||||
onDisconnectClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically { it } + fadeIn(),
|
||||
exit = slideOutVertically { it } + fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (isConnected) {
|
||||
serverName
|
||||
} else {
|
||||
stringResource(R.string.remote_connecting)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
if (isConnected) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable(onClick = onConnectionsClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = connectionsCount.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Cable,
|
||||
contentDescription = stringResource(R.string.title_connections),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
if (hasGroups) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable(onClick = onGroupsClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = groupsCount.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Folder,
|
||||
contentDescription = stringResource(R.string.title_groups),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable(onClick = onDisconnectClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (isConnected && startTime != null) {
|
||||
UptimeText(startTime = startTime)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.LinkOff,
|
||||
contentDescription = stringResource(R.string.remote_disconnect),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,11 @@ import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute
|
||||
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.EditRemoteServerScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.FDroidMirrorScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.RemoteControlScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.TailscaleFontPickerScreen
|
||||
@@ -527,6 +529,38 @@ fun SFANavHost(
|
||||
PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "settings/remote_control",
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
RemoteControlScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "settings/remote_control/new",
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
EditRemoteServerScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "settings/remote_control/edit/{serverId}",
|
||||
arguments = listOf(navArgument("serverId") { type = NavType.LongType }),
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) { backStackEntry ->
|
||||
val serverId = backStackEntry.arguments?.getLong("serverId") ?: -1L
|
||||
EditRemoteServerScreen(navController = navController, serverId = serverId)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "settings/privilege",
|
||||
enterTransition = slideInFromRight,
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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
|
||||
@@ -45,6 +46,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.model.Connection
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
|
||||
private fun Drawable.toBitmap(): Bitmap {
|
||||
if (this is BitmapDrawable) return bitmap
|
||||
@@ -82,7 +84,16 @@ private fun rememberAppInfo(packageName: String): AppInfo? {
|
||||
@Composable
|
||||
fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
val packageName = connection.processInfo?.packageNames?.firstOrNull()
|
||||
// In remote control mode the reported packages belong to the remote device,
|
||||
// so resolving them against the local package manager would be wrong.
|
||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
val isRemote = remoteServer != null
|
||||
val packageName =
|
||||
if (isRemote) {
|
||||
null
|
||||
} else {
|
||||
connection.processInfo?.packageNames?.firstOrNull()
|
||||
}
|
||||
val appInfo = packageName?.let { rememberAppInfo(it) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
@@ -101,19 +112,21 @@ fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> U
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Column 1: App icon
|
||||
if (appInfo != null) {
|
||||
Image(
|
||||
bitmap = appInfo.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (!isRemote) {
|
||||
if (appInfo != null) {
|
||||
Image(
|
||||
bitmap = appInfo.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Content column
|
||||
|
||||
+18
-6
@@ -3,7 +3,6 @@ package io.nekohasekai.sfa.compose.screen.connections
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.nekohasekai.libbox.ConnectionEvents
|
||||
import io.nekohasekai.libbox.Connections
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.compose.base.ScreenEvent
|
||||
import io.nekohasekai.sfa.compose.model.Connection
|
||||
@@ -13,6 +12,8 @@ import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.ktx.toList
|
||||
import io.nekohasekai.sfa.utils.AppLifecycleObserver
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -64,6 +65,8 @@ class ConnectionsViewModel :
|
||||
val screenOn: Boolean,
|
||||
val visibleCount: Int,
|
||||
val status: Status,
|
||||
val remoteServerId: Long?,
|
||||
val remoteConnected: Boolean,
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -73,11 +76,17 @@ class ConnectionsViewModel :
|
||||
AppLifecycleObserver.isScreenOn,
|
||||
_visibleCount,
|
||||
_serviceStatus,
|
||||
) { foreground, screenOn, visibleCount, status ->
|
||||
ConnectionState(foreground, screenOn, visibleCount, status)
|
||||
combine(
|
||||
RemoteControlManager.remoteServer,
|
||||
RemoteControlManager.isConnected,
|
||||
) { remoteServer, remoteConnected -> remoteServer?.id to remoteConnected },
|
||||
) { foreground, screenOn, visibleCount, status, (remoteServerId, remoteConnected) ->
|
||||
ConnectionState(foreground, screenOn, visibleCount, status, remoteServerId, remoteConnected)
|
||||
}.collect { state ->
|
||||
val serviceReady =
|
||||
if (state.remoteServerId != null) state.remoteConnected else state.status == Status.Started
|
||||
val shouldConnect = state.foreground && state.screenOn &&
|
||||
state.visibleCount > 0 && state.status == Status.Started
|
||||
state.visibleCount > 0 && serviceReady
|
||||
if (shouldConnect) {
|
||||
updateState { copy(isLoading = true) }
|
||||
commandClient.connect()
|
||||
@@ -98,6 +107,9 @@ class ConnectionsViewModel :
|
||||
}
|
||||
|
||||
private suspend fun handleServiceStatusChange(status: Status) {
|
||||
if (RemoteControlManager.remoteServer.value != null) {
|
||||
return
|
||||
}
|
||||
if (status != Status.Started) {
|
||||
withContext(Dispatchers.Default) {
|
||||
connectionsMutex.withLock {
|
||||
@@ -151,7 +163,7 @@ class ConnectionsViewModel :
|
||||
fun closeConnection(connectionId: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Libbox.newStandaloneCommandClient().closeConnection(connectionId)
|
||||
CommandTarget.standaloneClient().closeConnection(connectionId)
|
||||
withContext(Dispatchers.Main) {
|
||||
sendEvent(ConnectionsEvent.ConnectionClosed(connectionId))
|
||||
}
|
||||
@@ -164,7 +176,7 @@ class ConnectionsViewModel :
|
||||
fun closeAllConnections() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Libbox.newStandaloneCommandClient().closeConnections()
|
||||
CommandTarget.standaloneClient().closeConnections()
|
||||
withContext(Dispatchers.Main) {
|
||||
sendEvent(ConnectionsEvent.AllConnectionsClosed)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -23,7 +28,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -31,9 +40,12 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems
|
||||
import io.nekohasekai.sfa.compose.component.rememberRemoteServers
|
||||
import io.nekohasekai.sfa.compose.navigation.NewProfileArgs
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class CardRenderItem(val cards: List<CardGroup>, val isRow: Boolean)
|
||||
@@ -48,16 +60,46 @@ fun DashboardScreen(
|
||||
viewModel: DashboardViewModel = viewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
val remoteConnected by RemoteControlManager.isConnected.collectAsState()
|
||||
val isRemote = remoteServer != null
|
||||
val remoteServers by rememberRemoteServers()
|
||||
var showOthersMenu by remember { mutableStateOf(false) }
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_dashboard)) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.toggleCardSettingsDialog() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.title_others),
|
||||
)
|
||||
Box {
|
||||
IconButton(onClick = { showOthersMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.title_others),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showOthersMenu,
|
||||
onDismissRequest = { showOthersMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.dashboard_items)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.GridView,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showOthersMenu = false
|
||||
viewModel.toggleCardSettingsDialog()
|
||||
},
|
||||
)
|
||||
RemoteControlMenuItems(
|
||||
servers = remoteServers,
|
||||
onAction = { showOthersMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -120,6 +162,16 @@ fun DashboardScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (isRemote && !remoteConnected) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
@@ -143,8 +195,17 @@ fun DashboardScreen(
|
||||
// Filter cards based on availability
|
||||
val actuallyVisibleCards =
|
||||
uiState.visibleCards.filter { cardGroup ->
|
||||
when (cardGroup) {
|
||||
CardGroup.Profiles -> true // Profiles card is always available
|
||||
when {
|
||||
// The remote dashboard only renders cards backed by the
|
||||
// command protocol: profiles and system proxy are
|
||||
// operations on the local device.
|
||||
isRemote ->
|
||||
cardGroup != CardGroup.Profiles &&
|
||||
cardGroup != CardGroup.SystemProxy &&
|
||||
serviceRunning &&
|
||||
isCardAvailableWhenServiceRunning(cardGroup, uiState)
|
||||
|
||||
cardGroup == CardGroup.Profiles -> true // Profiles card is always available
|
||||
else -> serviceRunning && isCardAvailableWhenServiceRunning(cardGroup, uiState)
|
||||
}
|
||||
}.toSet()
|
||||
|
||||
+38
-8
@@ -14,12 +14,16 @@ import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.database.TypedProfile
|
||||
import io.nekohasekai.sfa.utils.AppLifecycleObserver
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import io.nekohasekai.sfa.utils.HTTPClient
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
@@ -156,9 +160,19 @@ class DashboardViewModel :
|
||||
ProfileManager.registerCallback(::onProfilesChanged)
|
||||
|
||||
viewModelScope.launch {
|
||||
AppLifecycleObserver.isForeground.collect { foreground ->
|
||||
if (_serviceStatus.value != Status.Started) return@collect
|
||||
if (foreground) {
|
||||
combine(
|
||||
AppLifecycleObserver.isForeground,
|
||||
RemoteControlManager.remoteServer,
|
||||
RemoteControlManager.isConnected,
|
||||
_serviceStatus,
|
||||
) { foreground, remoteServer, remoteConnected, status ->
|
||||
SessionTarget(
|
||||
connect = foreground &&
|
||||
if (remoteServer != null) remoteConnected else status == Status.Started,
|
||||
remoteServerId = remoteServer?.id,
|
||||
)
|
||||
}.distinctUntilChanged().collect { target ->
|
||||
if (target.connect) {
|
||||
commandClient.connect()
|
||||
} else {
|
||||
commandClient.disconnect()
|
||||
@@ -167,6 +181,8 @@ class DashboardViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
ProfileManager.unregisterCallback(::onProfilesChanged)
|
||||
@@ -439,7 +455,12 @@ class DashboardViewModel :
|
||||
updateState {
|
||||
copy(
|
||||
serviceStatus = status,
|
||||
isStatusVisible = status == Status.Starting || status == Status.Started,
|
||||
isStatusVisible =
|
||||
if (RemoteControlManager.remoteServer.value != null) {
|
||||
isStatusVisible
|
||||
} else {
|
||||
status == Status.Starting || status == Status.Started
|
||||
},
|
||||
)
|
||||
}
|
||||
handleServiceStatusChange(status)
|
||||
@@ -447,18 +468,21 @@ class DashboardViewModel :
|
||||
}
|
||||
|
||||
private fun handleServiceStatusChange(status: Status) {
|
||||
val isRemote = RemoteControlManager.remoteServer.value != null
|
||||
when (status) {
|
||||
Status.Started -> {
|
||||
checkDeprecatedNotes()
|
||||
if (AppLifecycleObserver.isForeground.value) {
|
||||
commandClient.connect()
|
||||
if (isRemote) {
|
||||
return
|
||||
}
|
||||
reloadSystemProxyStatus()
|
||||
reloadStartedAt()
|
||||
}
|
||||
|
||||
Status.Stopped -> {
|
||||
commandClient.disconnect()
|
||||
if (isRemote) {
|
||||
return
|
||||
}
|
||||
updateState {
|
||||
copy(
|
||||
hasGroups = false,
|
||||
@@ -545,7 +569,7 @@ class DashboardViewModel :
|
||||
fun selectClashMode(mode: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Libbox.newStandaloneCommandClient().setClashMode(mode)
|
||||
CommandTarget.standaloneClient().setClashMode(mode)
|
||||
// Update UI state directly without reconnecting
|
||||
withContext(Dispatchers.Main) {
|
||||
updateState {
|
||||
@@ -562,6 +586,12 @@ class DashboardViewModel :
|
||||
override fun onConnected() {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
updateState { copy(isStatusVisible = true) }
|
||||
// Returning from remote control skipped the local reloads that
|
||||
// normally run when the service starts.
|
||||
if (RemoteControlManager.remoteServer.value == null && _serviceStatus.value == Status.Started) {
|
||||
reloadSystemProxyStatus()
|
||||
reloadStartedAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-18
@@ -1,7 +1,6 @@
|
||||
package io.nekohasekai.sfa.compose.screen.dashboard.groups
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.OutboundGroup
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.compose.base.ScreenEvent
|
||||
@@ -11,9 +10,13 @@ import io.nekohasekai.sfa.compose.model.toList
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.utils.AppLifecycleObserver
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -54,9 +57,19 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
AppLifecycleObserver.isForeground.collect { foreground ->
|
||||
if (lastServiceStatus != Status.Started) return@collect
|
||||
if (foreground) {
|
||||
combine(
|
||||
AppLifecycleObserver.isForeground,
|
||||
RemoteControlManager.remoteServer,
|
||||
RemoteControlManager.isConnected,
|
||||
_serviceStatus,
|
||||
) { foreground, remoteServer, remoteConnected, status ->
|
||||
SessionTarget(
|
||||
connect = foreground &&
|
||||
if (remoteServer != null) remoteConnected else status == Status.Started,
|
||||
remoteServerId = remoteServer?.id,
|
||||
)
|
||||
}.distinctUntilChanged().collect { target ->
|
||||
if (target.connect) {
|
||||
if (isUsingSharedClient) {
|
||||
commandClient.addHandler(this@GroupsViewModel)
|
||||
} else {
|
||||
@@ -74,6 +87,8 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
}
|
||||
}
|
||||
|
||||
private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?)
|
||||
|
||||
override fun createInitialState() = GroupsUiState()
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -86,15 +101,10 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
}
|
||||
|
||||
private fun handleServiceStatusChange(status: Status) {
|
||||
if (status == Status.Started) {
|
||||
if (!isUsingSharedClient && AppLifecycleObserver.isForeground.value) {
|
||||
updateState { copy(isLoading = true) }
|
||||
commandClient.connect()
|
||||
}
|
||||
} else {
|
||||
if (!isUsingSharedClient) {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
if (RemoteControlManager.remoteServer.value != null) {
|
||||
return
|
||||
}
|
||||
if (status != Status.Started) {
|
||||
updateState {
|
||||
copy(
|
||||
groups = emptyList(),
|
||||
@@ -127,7 +137,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Libbox.newStandaloneCommandClient().setGroupExpand(groupTag, newExpanded)
|
||||
CommandTarget.standaloneClient().setGroupExpand(groupTag, newExpanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,7 +158,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
groups.forEach { group ->
|
||||
runCatching {
|
||||
Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, newExpanded)
|
||||
CommandTarget.standaloneClient().setGroupExpand(group.tag, newExpanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,7 +175,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Select the new outbound immediately
|
||||
Libbox.newStandaloneCommandClient().selectOutbound(groupTag, itemTag)
|
||||
CommandTarget.standaloneClient().selectOutbound(groupTag, itemTag)
|
||||
|
||||
// Update local state and show snackbar
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -193,7 +203,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
fun closeConnections() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Libbox.newStandaloneCommandClient().closeConnections()
|
||||
CommandTarget.standaloneClient().closeConnections()
|
||||
withContext(Dispatchers.Main) {
|
||||
dismissCloseConnectionsSnackbar()
|
||||
}
|
||||
@@ -215,7 +225,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) :
|
||||
fun urlTest(groupTag: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Libbox.newStandaloneCommandClient().urlTest(groupTag)
|
||||
CommandTarget.standaloneClient().urlTest(groupTag)
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
}
|
||||
|
||||
@@ -94,8 +94,11 @@ import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compat.WindowSizeClassCompat
|
||||
import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat
|
||||
import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems
|
||||
import io.nekohasekai.sfa.compose.component.rememberRemoteServers
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -124,6 +127,8 @@ fun LogScreen(
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val resolvedTitle = title ?: stringResource(R.string.title_log)
|
||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
val remoteServers by rememberRemoteServers()
|
||||
val emptyStateMessage = emptyMessage ?: stringResource(R.string.privilege_settings_hook_logs_empty)
|
||||
|
||||
OverrideTopBar {
|
||||
@@ -471,10 +476,14 @@ fun LogScreen(
|
||||
) {
|
||||
Text(
|
||||
text = if (showStatusInfo) {
|
||||
when (serviceStatus) {
|
||||
Status.Started -> stringResource(R.string.status_started)
|
||||
Status.Starting -> stringResource(R.string.status_starting)
|
||||
Status.Stopping -> stringResource(R.string.status_stopping)
|
||||
when {
|
||||
remoteServer != null && !uiState.isConnected ->
|
||||
stringResource(R.string.remote_connecting)
|
||||
|
||||
remoteServer != null -> stringResource(R.string.status_started)
|
||||
serviceStatus == Status.Started -> stringResource(R.string.status_started)
|
||||
serviceStatus == Status.Starting -> stringResource(R.string.status_starting)
|
||||
serviceStatus == Status.Stopping -> stringResource(R.string.status_stopping)
|
||||
else -> stringResource(R.string.status_default)
|
||||
}
|
||||
} else {
|
||||
@@ -828,6 +837,13 @@ fun LogScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showStatusInfo) {
|
||||
RemoteControlMenuItems(
|
||||
servers = remoteServers,
|
||||
onAction = { resolvedViewModel.toggleOptionsMenu() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package io.nekohasekai.sfa.compose.screen.log
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.LogEntry
|
||||
import io.nekohasekai.sfa.compose.util.AnsiColorUtils
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.utils.AppLifecycleObserver
|
||||
import io.nekohasekai.sfa.utils.CommandClient
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -28,12 +32,23 @@ class LogViewModel :
|
||||
handler = this,
|
||||
)
|
||||
private var lastServiceStatus: Status = Status.Stopped
|
||||
private val serviceStatusFlow = MutableStateFlow(Status.Stopped)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
AppLifecycleObserver.isForeground.collect { foreground ->
|
||||
if (lastServiceStatus != Status.Started) return@collect
|
||||
if (foreground) {
|
||||
combine(
|
||||
AppLifecycleObserver.isForeground,
|
||||
RemoteControlManager.remoteServer,
|
||||
RemoteControlManager.isConnected,
|
||||
serviceStatusFlow,
|
||||
) { foreground, remoteServer, remoteConnected, status ->
|
||||
SessionTarget(
|
||||
connect = foreground &&
|
||||
if (remoteServer != null) remoteConnected else status == Status.Started,
|
||||
remoteServerId = remoteServer?.id,
|
||||
)
|
||||
}.distinctUntilChanged().collect { target ->
|
||||
if (target.connect) {
|
||||
commandClient.connect()
|
||||
} else {
|
||||
commandClient.disconnect()
|
||||
@@ -42,6 +57,8 @@ class LogViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?)
|
||||
|
||||
private fun processLogEntry(entry: LogEntry): ProcessedLogEntry {
|
||||
val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default
|
||||
return ProcessedLogEntry(
|
||||
@@ -53,17 +70,14 @@ class LogViewModel :
|
||||
|
||||
override fun updateServiceStatus(status: Status) {
|
||||
lastServiceStatus = status
|
||||
serviceStatusFlow.value = status
|
||||
_uiState.update { it.copy(serviceStatus = status) }
|
||||
|
||||
if (RemoteControlManager.remoteServer.value != null) {
|
||||
return
|
||||
}
|
||||
when (status) {
|
||||
Status.Started -> {
|
||||
if (AppLifecycleObserver.isForeground.value) {
|
||||
commandClient.connect()
|
||||
}
|
||||
}
|
||||
|
||||
Status.Stopped, Status.Stopping -> {
|
||||
commandClient.disconnect()
|
||||
_uiState.update { it.copy(isConnected = false) }
|
||||
}
|
||||
|
||||
@@ -101,7 +115,7 @@ class LogViewModel :
|
||||
val sent =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Libbox.newStandaloneCommandClient().clearLogs()
|
||||
CommandTarget.standaloneClient().clearLogs()
|
||||
}.isSuccess
|
||||
}
|
||||
// With the service stopped there is no broadcast to clear the UI,
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package io.nekohasekai.sfa.compose.screen.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.text.KeyboardOptions
|
||||
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.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.database.RemoteServer
|
||||
import io.nekohasekai.sfa.database.RemoteServerManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) {
|
||||
val isNewServer = serverId == -1L
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isNewServer) {
|
||||
R.string.remote_new_server
|
||||
} else {
|
||||
R.string.remote_edit_server
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.content_description_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var origin by remember { mutableStateOf<RemoteServer?>(null) }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("") }
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var secretVisible by remember { mutableStateOf(false) }
|
||||
var urlError by remember { mutableStateOf(false) }
|
||||
var isLoading by remember { mutableStateOf(!isNewServer) }
|
||||
|
||||
LaunchedEffect(serverId) {
|
||||
if (!isNewServer) {
|
||||
val server = withContext(Dispatchers.IO) { RemoteServerManager.get(serverId) }
|
||||
if (server == null) {
|
||||
navController.navigateUp()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
origin = server
|
||||
name = server.name
|
||||
url = server.url
|
||||
secret = server.secret
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
placeholder = { Text(stringResource(R.string.remote_optional)) },
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = {
|
||||
url = it
|
||||
urlError = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.profile_url)) },
|
||||
placeholder = { Text(stringResource(R.string.profile_input_required)) },
|
||||
isError = urlError,
|
||||
supportingText =
|
||||
if (urlError) {
|
||||
{ Text(stringResource(R.string.remote_invalid_url, url)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.remote_secret)) },
|
||||
placeholder = { Text(stringResource(R.string.remote_optional)) },
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (secretVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { secretVisible = !secretVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (secretVisible) {
|
||||
Icons.Default.VisibilityOff
|
||||
} else {
|
||||
Icons.Default.Visibility
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val validatedURL = RemoteServer.validateURL(url)
|
||||
if (validatedURL == null) {
|
||||
urlError = true
|
||||
return@Button
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val server = origin ?: RemoteServer()
|
||||
server.name = name.trim()
|
||||
server.url = validatedURL
|
||||
server.secret = secret
|
||||
if (origin != null) {
|
||||
RemoteServerManager.update(server)
|
||||
} else {
|
||||
RemoteServerManager.create(server)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
navController.navigateUp()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package io.nekohasekai.sfa.compose.screen.settings
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
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.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.database.RemoteServer
|
||||
import io.nekohasekai.sfa.database.RemoteServerManager
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun RemoteControlScreen(navController: NavController) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.remote_control)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.content_description_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate("settings/remote_control/new") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = stringResource(R.string.remote_new_server),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var servers by remember { mutableStateOf<List<RemoteServer>>(emptyList()) }
|
||||
val activeRemoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
servers = withContext(Dispatchers.IO) { RemoteServerManager.list() }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val callback: () -> Unit = {
|
||||
scope.launch {
|
||||
servers = withContext(Dispatchers.IO) { RemoteServerManager.list() }
|
||||
}
|
||||
}
|
||||
RemoteServerManager.registerCallback(callback)
|
||||
onDispose {
|
||||
RemoteServerManager.unregisterCallback(callback)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.remote_servers),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.remote_no_servers),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp),
|
||||
)
|
||||
} else {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
servers.forEachIndexed { index, server ->
|
||||
val shape =
|
||||
when {
|
||||
servers.size == 1 -> RoundedCornerShape(12.dp)
|
||||
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
index == servers.size - 1 ->
|
||||
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
|
||||
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
server.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent =
|
||||
if (server.name.isNotEmpty()) {
|
||||
{
|
||||
Text(
|
||||
server.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
trailingContent =
|
||||
if (activeRemoteServer?.id == server.id) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(shape)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
navController.navigate(
|
||||
"settings/remote_control/edit/${server.id}",
|
||||
)
|
||||
},
|
||||
onLongClick = { showMenu = true },
|
||||
),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.edit)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Outlined.Edit, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
navController.navigate(
|
||||
"settings/remote_control/edit/${server.id}",
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.menu_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (RemoteControlManager.remoteServer.value?.id == server.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
RemoteControlManager.exitRemoteControl()
|
||||
}
|
||||
}
|
||||
RemoteServerManager.delete(server)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.outlined.Favorite
|
||||
import androidx.compose.material.icons.outlined.FilterAlt
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.SettingsRemote
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Card
|
||||
@@ -185,6 +186,29 @@ fun SettingsScreen(navController: NavController) {
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.remote_control),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SettingsRemote,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { navController.navigate("settings/remote_control") },
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
|
||||
@@ -53,6 +53,7 @@ import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -62,7 +63,11 @@ fun NetworkQualityScreen(
|
||||
viewModel: NetworkQualityViewModel = viewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val vpnRunning = serviceStatus == Status.Started
|
||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
val remoteConnected by RemoteControlManager.isConnected.collectAsState()
|
||||
val serviceAvailable = remoteServer != null || serviceStatus == Status.Started
|
||||
val vpnRunning =
|
||||
if (remoteServer != null) remoteConnected else serviceStatus == Status.Started
|
||||
val context = LocalContext.current
|
||||
|
||||
var showConfigURLDialog by remember { mutableStateOf(false) }
|
||||
@@ -107,7 +112,7 @@ fun NetworkQualityScreen(
|
||||
title = { Text(stringResource(R.string.network_quality_metered_title)) },
|
||||
text = { Text(stringResource(R.string.network_quality_metered_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.confirmMeteredStart(vpnRunning) }) {
|
||||
TextButton(onClick = { viewModel.confirmMeteredStart(serviceAvailable) }) {
|
||||
Text(stringResource(R.string.network_quality_metered_continue))
|
||||
}
|
||||
},
|
||||
@@ -299,7 +304,7 @@ fun NetworkQualityScreen(
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.requestStartTest(context, vpnRunning) },
|
||||
onClick = { viewModel.requestStartTest(context, serviceAvailable) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.network_quality_start))
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import io.nekohasekai.libbox.NetworkQualityTestHandler
|
||||
import io.nekohasekai.libbox.NetworkQualityTestSession
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -117,7 +118,7 @@ class NetworkQualityViewModel : BaseViewModel<NetworkQualityState, Nothing>() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
nqSession =
|
||||
Libbox.newStandaloneCommandClient()
|
||||
CommandTarget.standaloneClient()
|
||||
.startNetworkQualityTest(
|
||||
configURL,
|
||||
outboundTag,
|
||||
|
||||
@@ -50,6 +50,7 @@ import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -59,7 +60,11 @@ fun STUNTestScreen(
|
||||
viewModel: STUNTestViewModel = viewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val vpnRunning = serviceStatus == Status.Started
|
||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
val remoteConnected by RemoteControlManager.isConnected.collectAsState()
|
||||
val serviceAvailable = remoteServer != null || serviceStatus == Status.Started
|
||||
val vpnRunning =
|
||||
if (remoteServer != null) remoteConnected else serviceStatus == Status.Started
|
||||
|
||||
var showServerDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -191,7 +196,7 @@ fun STUNTestScreen(
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.startTest(vpnRunning) },
|
||||
onClick = { viewModel.startTest(serviceAvailable) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.stun_start))
|
||||
|
||||
@@ -7,6 +7,7 @@ import io.nekohasekai.libbox.STUNTestProgress
|
||||
import io.nekohasekai.libbox.STUNTestResult
|
||||
import io.nekohasekai.libbox.STUNTestSession
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -63,7 +64,7 @@ class STUNTestViewModel : BaseViewModel<STUNTestState, Nothing>() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
stunSession =
|
||||
Libbox.newStandaloneCommandClient()
|
||||
CommandTarget.standaloneClient()
|
||||
.startSTUNTest(server, outboundTag, handler)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.TailscalePingHandler
|
||||
import io.nekohasekai.libbox.TailscalePingResult
|
||||
import io.nekohasekai.libbox.TailscalePingSession
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -38,7 +38,7 @@ class TailscalePingViewModel : BaseViewModel<TailscalePingState, Nothing>() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
pingSession =
|
||||
Libbox.newStandaloneCommandClient()
|
||||
CommandTarget.standaloneClient()
|
||||
.startTailscalePing(
|
||||
endpointTag,
|
||||
peerIP,
|
||||
|
||||
+18
-2
@@ -4,7 +4,6 @@ import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.terminal.TerminalSessionClient
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.StringIterator
|
||||
import io.nekohasekai.libbox.TailscaleSSHHandler
|
||||
import io.nekohasekai.libbox.TailscaleSSHOptions
|
||||
@@ -12,7 +11,10 @@ import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.terminal.ManagedSession
|
||||
import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession
|
||||
import io.nekohasekai.sfa.terminal.TailscaleSSHTerminalSession
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class TailscaleSSHTerminalState(
|
||||
@@ -59,6 +61,7 @@ class TailscaleSSHTerminalViewModel : BaseViewModel<TailscaleSSHTerminalState, N
|
||||
fun removeSession(id: String) {
|
||||
val session = currentState.sessions.firstOrNull { it.id == id } ?: return
|
||||
session.terminalSession.close()
|
||||
disconnectClient(session)
|
||||
val remaining = currentState.sessions.filter { it.id != id }
|
||||
val newActiveId = if (currentState.activeSessionId == id) {
|
||||
remaining.lastOrNull()?.id
|
||||
@@ -102,7 +105,8 @@ class TailscaleSSHTerminalViewModel : BaseViewModel<TailscaleSSHTerminalState, N
|
||||
forwardAgent = false
|
||||
}
|
||||
|
||||
val commandClient = Libbox.newStandaloneCommandClient()
|
||||
val commandClient = CommandTarget.ownedStandaloneClient()
|
||||
managed.commandClient = commandClient
|
||||
val sshSession = commandClient.startTailscaleSSHSession(
|
||||
options,
|
||||
object : TailscaleSSHHandler {
|
||||
@@ -134,9 +138,21 @@ class TailscaleSSHTerminalViewModel : BaseViewModel<TailscaleSSHTerminalState, N
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnectClient(session: ManagedSession) {
|
||||
val commandClient = session.commandClient ?: return
|
||||
session.commandClient = null
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (session in currentState.sessions) {
|
||||
session.terminalSession.close()
|
||||
disconnectClient(session)
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.TailscaleStatusHandler
|
||||
import io.nekohasekai.libbox.TailscaleStatusSubscription
|
||||
import io.nekohasekai.libbox.TailscaleStatusUpdate
|
||||
import io.nekohasekai.sfa.compose.base.BaseViewModel
|
||||
import io.nekohasekai.sfa.utils.CommandTarget
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -77,7 +77,7 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
statusSubscription =
|
||||
Libbox.newStandaloneCommandClient()
|
||||
CommandTarget.standaloneClient()
|
||||
.subscribeTailscaleStatus(object : TailscaleStatusHandler {
|
||||
override fun onStatusUpdate(status: TailscaleStatusUpdate) {
|
||||
val endpoints = convertUpdate(status)
|
||||
@@ -117,7 +117,7 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
|
||||
fun setExitNode(endpointTag: String, stableID: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Libbox.newStandaloneCommandClient().setTailscaleExitNode(endpointTag, stableID)
|
||||
CommandTarget.standaloneClient().setTailscaleExitNode(endpointTag, stableID)
|
||||
} catch (e: Exception) {
|
||||
sendErrorMessage(e.message ?: "set exit node failed")
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
|
||||
fun logout(endpointTag: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Libbox.newStandaloneCommandClient().tailscaleLogout(endpointTag)
|
||||
CommandTarget.standaloneClient().tailscaleLogout(endpointTag)
|
||||
} catch (e: Exception) {
|
||||
sendErrorMessage(e.message ?: "logout failed")
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material.icons.outlined.Hub
|
||||
import androidx.compose.material.icons.outlined.Memory
|
||||
import androidx.compose.material.icons.outlined.NetworkCheck
|
||||
import androidx.compose.material.icons.outlined.SwapHoriz
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -26,6 +28,7 @@ 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.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -48,10 +51,13 @@ import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.CrashReportManager
|
||||
import io.nekohasekai.sfa.bg.OOMReportManager
|
||||
import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems
|
||||
import io.nekohasekai.sfa.compose.component.rememberRemoteServers
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession
|
||||
import io.nekohasekai.sfa.utils.RemoteControlManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -61,17 +67,57 @@ fun ToolsScreen(
|
||||
tailscaleViewModel: TailscaleStatusViewModel,
|
||||
sshSharedViewModel: TailscaleSSHSharedViewModel,
|
||||
) {
|
||||
val remoteServers by rememberRemoteServers()
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_tools)) },
|
||||
actions = {
|
||||
if (remoteServers.isNotEmpty()) {
|
||||
Box {
|
||||
var showOthersMenu by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { showOthersMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.title_others),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showOthersMenu,
|
||||
onDismissRequest = { showOthersMenu = false },
|
||||
) {
|
||||
RemoteControlMenuItems(
|
||||
servers = remoteServers,
|
||||
onAction = { showOthersMenu = false },
|
||||
leadingDivider = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val crashUnreadCount by CrashReportManager.unreadCount.collectAsState()
|
||||
val oomUnreadCount by OOMReportManager.unreadCount.collectAsState()
|
||||
val tailscaleState by tailscaleViewModel.uiState.collectAsState()
|
||||
val remoteServer by RemoteControlManager.remoteServer.collectAsState()
|
||||
|
||||
LaunchedEffect(remoteServer?.id) {
|
||||
// Drop the previous target's subscription when switching between the
|
||||
// local service and a remote server, or between two servers: a server
|
||||
// without tailscale leaves no active stream to error out, so the
|
||||
// subscription would stay stale without an explicit cancel.
|
||||
tailscaleViewModel.cancel()
|
||||
if (remoteServer != null || serviceStatus == Status.Started) {
|
||||
tailscaleViewModel.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(serviceStatus) {
|
||||
if (remoteServer != null) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (serviceStatus == Status.Started) {
|
||||
tailscaleViewModel.subscribe()
|
||||
} else {
|
||||
@@ -243,7 +289,7 @@ fun ToolsScreen(
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.NetworkCheck,
|
||||
imageVector = Icons.Outlined.SwapHoriz,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -255,73 +301,77 @@ fun ToolsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.title_debug),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
// Crash/OOM reports read local files, which the remote control API
|
||||
// does not reach.
|
||||
if (remoteServer == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.title_debug),
|
||||
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,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.crash_report),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BugReport,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (crashUnreadCount > 0) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||
Text("$crashUnreadCount")
|
||||
}
|
||||
}
|
||||
},
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable { navController.navigate("tools/crash_report") },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.oom_report),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Memory,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (oomUnreadCount > 0) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||
Text("$oomUnreadCount")
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.crash_report),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BugReport,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (crashUnreadCount > 0) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||
Text("$crashUnreadCount")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable { navController.navigate("tools/oom_report") },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable { navController.navigate("tools/crash_report") },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.oom_report),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Memory,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (oomUnreadCount > 0) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||
Text("$oomUnreadCount")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable { navController.navigate("tools/oom_report") },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ object SettingsKey {
|
||||
const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
|
||||
const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items"
|
||||
|
||||
// Remote Control
|
||||
const val ACTIVE_REMOTE_SERVER_ID = "active_remote_server_id"
|
||||
|
||||
// Tailscale SSH
|
||||
const val TAILSCALE_SSH_REMEMBERED_USERNAMES = "tailscale_ssh_remembered_usernames"
|
||||
const val TAILSCALE_SSH_QUICK_CONNECT_PEERS = "tailscale_ssh_quick_connect_peers"
|
||||
|
||||
@@ -6,13 +6,15 @@ import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [Profile::class],
|
||||
version = 2,
|
||||
entities = [Profile::class, RemoteServer::class],
|
||||
version = 3,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class ProfileDatabase : RoomDatabase() {
|
||||
abstract fun profileDao(): Profile.Dao
|
||||
|
||||
abstract fun remoteServerDao(): RemoteServer.Dao
|
||||
|
||||
companion object {
|
||||
val MIGRATION_1_2 =
|
||||
object : Migration(1, 2) {
|
||||
@@ -21,5 +23,19 @@ abstract class ProfileDatabase : RoomDatabase() {
|
||||
database.execSQL("ALTER TABLE profiles ADD COLUMN icon TEXT DEFAULT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 =
|
||||
object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `remote_servers` (" +
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`userOrder` INTEGER NOT NULL, " +
|
||||
"`name` TEXT NOT NULL, " +
|
||||
"`url` TEXT NOT NULL, " +
|
||||
"`secret` TEXT NOT NULL)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ object ProfileManager {
|
||||
ProfileDatabase::class.java,
|
||||
Path.PROFILES_DATABASE_PATH,
|
||||
)
|
||||
.addMigrations(ProfileDatabase.MIGRATION_1_2)
|
||||
.addMigrations(ProfileDatabase.MIGRATION_1_2, ProfileDatabase.MIGRATION_2_3)
|
||||
.fallbackToDestructiveMigrationOnDowngrade()
|
||||
.enableMultiInstanceInvalidation()
|
||||
.setQueryExecutor { GlobalScope.launch { it.run() } }
|
||||
@@ -93,4 +93,6 @@ object ProfileManager {
|
||||
}
|
||||
|
||||
suspend fun list(): List<Profile> = instance.profileDao().list()
|
||||
|
||||
fun remoteServerDao(): RemoteServer.Dao = instance.remoteServerDao()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package io.nekohasekai.sfa.database
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.net.URI
|
||||
|
||||
@Entity(
|
||||
tableName = "remote_servers",
|
||||
)
|
||||
@Parcelize
|
||||
class RemoteServer(
|
||||
@PrimaryKey(autoGenerate = true) var id: Long = 0L,
|
||||
var userOrder: Long = 0L,
|
||||
var name: String = "",
|
||||
var url: String = "",
|
||||
var secret: String = "",
|
||||
) : Parcelable {
|
||||
val displayName: String
|
||||
get() = name.ifEmpty { url }
|
||||
|
||||
companion object {
|
||||
fun validateURL(urlString: String): String? {
|
||||
var trimmed = urlString.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
if (!trimmed.contains("://")) {
|
||||
trimmed = "http://$trimmed"
|
||||
}
|
||||
val uri =
|
||||
try {
|
||||
URI(trimmed)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme != "http" && scheme != "https") {
|
||||
return null
|
||||
}
|
||||
if (uri.host.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.room.Dao
|
||||
interface Dao {
|
||||
@Insert
|
||||
fun insert(server: RemoteServer): Long
|
||||
|
||||
@Update
|
||||
fun update(server: RemoteServer): Int
|
||||
|
||||
@Update
|
||||
fun update(servers: List<RemoteServer>): Int
|
||||
|
||||
@Delete
|
||||
fun delete(server: RemoteServer): Int
|
||||
|
||||
@Query("SELECT * FROM remote_servers WHERE id = :serverId")
|
||||
fun get(serverId: Long): RemoteServer?
|
||||
|
||||
@Query("SELECT * FROM remote_servers ORDER BY userOrder ASC")
|
||||
fun list(): List<RemoteServer>
|
||||
|
||||
@Query("SELECT MAX(userOrder) + 1 FROM remote_servers")
|
||||
fun nextOrder(): Long?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.nekohasekai.sfa.database
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
object RemoteServerManager {
|
||||
private val callbacks = mutableListOf<() -> Unit>()
|
||||
|
||||
fun registerCallback(callback: () -> Unit) {
|
||||
callbacks.add(callback)
|
||||
}
|
||||
|
||||
fun unregisterCallback(callback: () -> Unit) {
|
||||
callbacks.remove(callback)
|
||||
}
|
||||
|
||||
private fun notifyCallbacks() {
|
||||
for (callback in callbacks.toList()) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nextOrder(): Long = ProfileManager.remoteServerDao().nextOrder() ?: 0
|
||||
|
||||
suspend fun get(id: Long): RemoteServer? = ProfileManager.remoteServerDao().get(id)
|
||||
|
||||
suspend fun create(server: RemoteServer): RemoteServer {
|
||||
server.userOrder = nextOrder()
|
||||
server.id = ProfileManager.remoteServerDao().insert(server)
|
||||
notifyCallbacks()
|
||||
return server
|
||||
}
|
||||
|
||||
suspend fun update(server: RemoteServer): Int {
|
||||
try {
|
||||
return ProfileManager.remoteServerDao().update(server)
|
||||
} finally {
|
||||
notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun update(servers: List<RemoteServer>): Int {
|
||||
try {
|
||||
return ProfileManager.remoteServerDao().update(servers)
|
||||
} finally {
|
||||
notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(server: RemoteServer): Int {
|
||||
try {
|
||||
return ProfileManager.remoteServerDao().delete(server)
|
||||
} finally {
|
||||
notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun list(): List<RemoteServer> = ProfileManager.remoteServerDao().list()
|
||||
}
|
||||
@@ -114,6 +114,8 @@ object Settings {
|
||||
var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
|
||||
var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }
|
||||
|
||||
var activeRemoteServerId by dataStore.long(SettingsKey.ACTIVE_REMOTE_SERVER_ID) { 0L }
|
||||
|
||||
// Tailscale SSH
|
||||
var tailscaleSSHRememberedUsernames by dataStore.map(SettingsKey.TAILSCALE_SSH_REMEMBERED_USERNAMES)
|
||||
var tailscaleSSHQuickConnectPeers by dataStore.stringSet(SettingsKey.TAILSCALE_SSH_QUICK_CONNECT_PEERS)
|
||||
|
||||
@@ -6,4 +6,8 @@ data class ManagedSession(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val terminalSession: TailscaleSSHTerminalSession,
|
||||
val presentedSession: TailscaleSSHPresentedSession,
|
||||
)
|
||||
) {
|
||||
// The session owns its command client (a dedicated connection in remote
|
||||
// control mode) and must disconnect it when the session ends.
|
||||
var commandClient: io.nekohasekai.libbox.CommandClient? = null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package io.nekohasekai.sfa.utils
|
||||
|
||||
import android.util.Log
|
||||
import go.Seq
|
||||
import io.nekohasekai.libbox.CommandClient
|
||||
import io.nekohasekai.libbox.CommandClientHandler
|
||||
import io.nekohasekai.libbox.CommandClientOptions
|
||||
import io.nekohasekai.libbox.ConnectionEvents
|
||||
@@ -16,17 +14,23 @@ import io.nekohasekai.libbox.StatusMessage
|
||||
import io.nekohasekai.libbox.StringIterator
|
||||
import io.nekohasekai.sfa.ktx.toList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
open class CommandClient(
|
||||
private val scope: CoroutineScope,
|
||||
private val connectionTypes: List<ConnectionType>,
|
||||
private val handler: Handler,
|
||||
private val localOnly: Boolean = false,
|
||||
) {
|
||||
constructor(
|
||||
scope: CoroutineScope,
|
||||
connectionType: ConnectionType,
|
||||
handler: Handler,
|
||||
) : this(scope, listOf(connectionType), handler)
|
||||
localOnly: Boolean = false,
|
||||
) : this(scope, listOf(connectionType), handler, localOnly)
|
||||
|
||||
private val additionalHandlers = mutableListOf<Handler>()
|
||||
private var cachedGroups: MutableList<OutboundGroup>? = null
|
||||
@@ -65,11 +69,22 @@ open class CommandClient(
|
||||
Outbounds,
|
||||
}
|
||||
|
||||
enum class ConnectionErrorKind {
|
||||
// A connect attempt failed; retrying is not expected to succeed.
|
||||
ConnectFailed,
|
||||
|
||||
// An established connection dropped (app suspension, network change,
|
||||
// server restart); reconnecting may recover.
|
||||
ConnectionLost,
|
||||
}
|
||||
|
||||
interface Handler {
|
||||
fun onConnected() {}
|
||||
|
||||
fun onDisconnected() {}
|
||||
|
||||
fun onConnectionError(kind: ConnectionErrorKind, message: String) {}
|
||||
|
||||
fun updateStatus(status: StatusMessage) {}
|
||||
|
||||
fun setDefaultLogLevel(level: Int) {}
|
||||
@@ -89,58 +104,126 @@ open class CommandClient(
|
||||
fun writeConnectionEvents(events: ConnectionEvents) {}
|
||||
}
|
||||
|
||||
private var commandClient: CommandClient? = null
|
||||
private val clientHandler = ClientHandler()
|
||||
private val access = Any()
|
||||
private var connectionEpoch = 0
|
||||
private var commandClient: io.nekohasekai.libbox.CommandClient? = null
|
||||
|
||||
fun connect() {
|
||||
disconnect()
|
||||
val options = CommandClientOptions()
|
||||
connectionTypes.forEach { connectionType ->
|
||||
val command =
|
||||
when (connectionType) {
|
||||
ConnectionType.Status -> Libbox.CommandStatus
|
||||
ConnectionType.Groups -> Libbox.CommandGroup
|
||||
ConnectionType.Log -> Libbox.CommandLog
|
||||
ConnectionType.ClashMode -> Libbox.CommandClashMode
|
||||
ConnectionType.Connections -> Libbox.CommandConnections
|
||||
ConnectionType.Outbounds -> Libbox.CommandOutbounds
|
||||
val epoch: Int
|
||||
val previousClient: io.nekohasekai.libbox.CommandClient?
|
||||
synchronized(access) {
|
||||
epoch = ++connectionEpoch
|
||||
previousClient = commandClient
|
||||
commandClient = null
|
||||
}
|
||||
// A remote connect dials over the network and blocks until the probe
|
||||
// completes, so it must run off the main thread.
|
||||
if (previousClient != null) {
|
||||
// The dropped Go-side Disconnected callback is suppressed by the epoch
|
||||
// bump, so the owner-initiated disconnect is reported deterministically.
|
||||
getAllHandlers().forEach { it.onDisconnected() }
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
previousClient?.apply {
|
||||
runCatching {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
val options = CommandClientOptions()
|
||||
connectionTypes.forEach { connectionType ->
|
||||
val command =
|
||||
when (connectionType) {
|
||||
ConnectionType.Status -> Libbox.CommandStatus
|
||||
ConnectionType.Groups -> Libbox.CommandGroup
|
||||
ConnectionType.Log -> Libbox.CommandLog
|
||||
ConnectionType.ClashMode -> Libbox.CommandClashMode
|
||||
ConnectionType.Connections -> Libbox.CommandConnections
|
||||
ConnectionType.Outbounds -> Libbox.CommandOutbounds
|
||||
}
|
||||
options.addCommand(command)
|
||||
}
|
||||
options.statusInterval = 1 * 1000 * 1000 * 1000
|
||||
val remoteServer = if (localOnly) null else CommandTarget.remoteServer
|
||||
val newClient: io.nekohasekai.libbox.CommandClient
|
||||
try {
|
||||
newClient =
|
||||
if (remoteServer != null) {
|
||||
Libbox.newRemoteCommandClient(
|
||||
ClientHandler(epoch),
|
||||
options,
|
||||
CommandTarget.libboxOptions(remoteServer),
|
||||
)
|
||||
} else {
|
||||
io.nekohasekai.libbox.CommandClient(ClientHandler(epoch), options)
|
||||
}
|
||||
newClient.connect()
|
||||
} catch (e: Exception) {
|
||||
Log.d("CommandClient", "connect failed", e)
|
||||
if (isActiveEpoch(epoch)) {
|
||||
handler.onConnectionError(
|
||||
ConnectionErrorKind.ConnectFailed,
|
||||
e.message ?: e.toString(),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val stale =
|
||||
synchronized(access) {
|
||||
if (epoch != connectionEpoch) {
|
||||
true
|
||||
} else {
|
||||
commandClient = newClient
|
||||
false
|
||||
}
|
||||
}
|
||||
if (stale) {
|
||||
runCatching {
|
||||
newClient.disconnect()
|
||||
}
|
||||
options.addCommand(command)
|
||||
}
|
||||
options.statusInterval = 1 * 1000 * 1000 * 1000
|
||||
val commandClient = CommandClient(clientHandler, options)
|
||||
try {
|
||||
commandClient.connect()
|
||||
} catch (e: Exception) {
|
||||
Log.d("CommandClient", "connect failed", e)
|
||||
return
|
||||
}
|
||||
this.commandClient = commandClient
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
commandClient?.apply {
|
||||
runCatching {
|
||||
disconnect()
|
||||
}
|
||||
// Seq.destroyRef(refnum)
|
||||
}
|
||||
commandClient = null
|
||||
}
|
||||
|
||||
private inner class ClientHandler : CommandClientHandler {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun disconnect() {
|
||||
val client: io.nekohasekai.libbox.CommandClient?
|
||||
synchronized(access) {
|
||||
connectionEpoch++
|
||||
client = commandClient
|
||||
commandClient = null
|
||||
}
|
||||
if (client != null) {
|
||||
getAllHandlers().forEach { it.onDisconnected() }
|
||||
// The owning scope may already be cancelled when this is called from
|
||||
// ViewModel.onCleared, so the connection is released independently.
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
client.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isActiveEpoch(epoch: Int): Boolean = synchronized(access) { epoch == connectionEpoch }
|
||||
|
||||
private inner class ClientHandler(private val epoch: Int) : CommandClientHandler {
|
||||
override fun connected() {
|
||||
if (!isActiveEpoch(epoch)) return
|
||||
getAllHandlers().forEach { it.onConnected() }
|
||||
Log.d("CommandClient", "connected")
|
||||
}
|
||||
|
||||
override fun disconnected(message: String?) {
|
||||
if (!isActiveEpoch(epoch)) return
|
||||
getAllHandlers().forEach { it.onDisconnected() }
|
||||
if (message != null) {
|
||||
handler.onConnectionError(ConnectionErrorKind.ConnectionLost, message)
|
||||
}
|
||||
Log.d("CommandClient", "disconnected: $message")
|
||||
}
|
||||
|
||||
override fun writeGroups(message: OutboundGroupIterator?) {
|
||||
if (message == null) {
|
||||
if (message == null || !isActiveEpoch(epoch)) {
|
||||
return
|
||||
}
|
||||
val groups = mutableListOf<OutboundGroup>()
|
||||
@@ -152,7 +235,7 @@ open class CommandClient(
|
||||
}
|
||||
|
||||
override fun writeOutbounds(message: OutboundGroupItemIterator?) {
|
||||
if (message == null) {
|
||||
if (message == null || !isActiveEpoch(epoch)) {
|
||||
return
|
||||
}
|
||||
val outbounds = mutableListOf<io.nekohasekai.libbox.OutboundGroupItem>()
|
||||
@@ -164,15 +247,17 @@ open class CommandClient(
|
||||
}
|
||||
|
||||
override fun setDefaultLogLevel(level: Int) {
|
||||
if (!isActiveEpoch(epoch)) return
|
||||
getAllHandlers().forEach { it.setDefaultLogLevel(level) }
|
||||
}
|
||||
|
||||
override fun clearLogs() {
|
||||
if (!isActiveEpoch(epoch)) return
|
||||
getAllHandlers().forEach { it.clearLogs() }
|
||||
}
|
||||
|
||||
override fun writeLogs(messageList: LogIterator?) {
|
||||
if (messageList == null) {
|
||||
if (messageList == null || !isActiveEpoch(epoch)) {
|
||||
return
|
||||
}
|
||||
val logs = messageList.toList()
|
||||
@@ -180,20 +265,23 @@ open class CommandClient(
|
||||
}
|
||||
|
||||
override fun writeStatus(message: StatusMessage) {
|
||||
if (!isActiveEpoch(epoch)) return
|
||||
getAllHandlers().forEach { it.updateStatus(message) }
|
||||
}
|
||||
|
||||
override fun initializeClashMode(modeList: StringIterator, currentMode: String) {
|
||||
if (!isActiveEpoch(epoch)) return
|
||||
val modes = modeList.toList()
|
||||
getAllHandlers().forEach { it.initializeClashMode(modes, currentMode) }
|
||||
}
|
||||
|
||||
override fun updateClashMode(newMode: String) {
|
||||
if (!isActiveEpoch(epoch)) return
|
||||
getAllHandlers().forEach { it.updateClashMode(newMode) }
|
||||
}
|
||||
|
||||
override fun writeConnectionEvents(events: ConnectionEvents?) {
|
||||
if (events == null) return
|
||||
if (events == null || !isActiveEpoch(epoch)) return
|
||||
getAllHandlers().forEach { it.writeConnectionEvents(events) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package io.nekohasekai.sfa.utils
|
||||
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.RemoteConnectionOptions
|
||||
import io.nekohasekai.sfa.database.RemoteServer
|
||||
|
||||
object CommandTarget {
|
||||
private val access = Any()
|
||||
private var activeRemoteServer: RemoteServer? = null
|
||||
|
||||
// One gRPC channel per remote session: standalone calls reuse it instead of
|
||||
// paying a TCP+TLS handshake (and leaking the connection) on every action.
|
||||
private var sharedRemoteClient: io.nekohasekai.libbox.CommandClient? = null
|
||||
|
||||
val remoteServer: RemoteServer?
|
||||
get() = synchronized(access) { activeRemoteServer }
|
||||
|
||||
val isRemote: Boolean
|
||||
get() = remoteServer != null
|
||||
|
||||
fun setRemoteServer(server: RemoteServer?) {
|
||||
val previousClient: io.nekohasekai.libbox.CommandClient?
|
||||
synchronized(access) {
|
||||
previousClient = sharedRemoteClient
|
||||
sharedRemoteClient = null
|
||||
activeRemoteServer = server
|
||||
}
|
||||
previousClient?.apply {
|
||||
runCatching {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun libboxOptions(server: RemoteServer): RemoteConnectionOptions {
|
||||
val options = RemoteConnectionOptions()
|
||||
options.setURL(server.url)
|
||||
options.secret = server.secret
|
||||
return options
|
||||
}
|
||||
|
||||
// Returns a client for one-shot calls and streamed sessions. In remote mode the
|
||||
// client is shared for the whole session — callers must not disconnect it.
|
||||
fun standaloneClient(): io.nekohasekai.libbox.CommandClient = synchronized(access) {
|
||||
val server = activeRemoteServer ?: return Libbox.newStandaloneCommandClient()
|
||||
sharedRemoteClient?.let { return it }
|
||||
val client = Libbox.newStandaloneRemoteCommandClient(libboxOptions(server))
|
||||
sharedRemoteClient = client
|
||||
client
|
||||
}
|
||||
|
||||
// Returns a dedicated client owned by the caller, who is responsible for
|
||||
// disconnecting it (e.g. the SSH terminal closes its client on session end).
|
||||
fun ownedStandaloneClient(): io.nekohasekai.libbox.CommandClient {
|
||||
val server = remoteServer ?: return Libbox.newStandaloneCommandClient()
|
||||
return Libbox.newStandaloneRemoteCommandClient(libboxOptions(server))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package io.nekohasekai.sfa.utils
|
||||
|
||||
import android.os.SystemClock
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.database.RemoteServer
|
||||
import io.nekohasekai.sfa.database.RemoteServerManager
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object RemoteControlManager : CommandClient.Handler {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val _remoteServer = MutableStateFlow<RemoteServer?>(null)
|
||||
val remoteServer = _remoteServer.asStateFlow()
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected = _isConnected.asStateFlow()
|
||||
|
||||
private val _startedAt = MutableStateFlow<Long?>(null)
|
||||
val startedAt = _startedAt.asStateFlow()
|
||||
|
||||
// The monitor connection owns the session lifecycle: per-screen clients only
|
||||
// connect while it reports connected, and its errors decide between silent
|
||||
// reconnects and falling back to the local device.
|
||||
private val monitorClient = CommandClient(scope, CommandClient.ConnectionType.Status, this)
|
||||
|
||||
private var sessionHadConnected = false
|
||||
private var sessionConnectedAt = 0L
|
||||
private var reconnectAttempts = 0
|
||||
private var restored = false
|
||||
|
||||
// A dropped session gets this many silent reconnect attempts before the
|
||||
// failure is surfaced. The counter resets once a connection survives
|
||||
// STABLE_CONNECTION_INTERVAL_MS, so only rapid connect-drop loops exhaust it.
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 3
|
||||
private const val STABLE_CONNECTION_INTERVAL_MS = 5000L
|
||||
|
||||
fun restore() {
|
||||
if (restored) {
|
||||
return
|
||||
}
|
||||
restored = true
|
||||
scope.launch {
|
||||
val server =
|
||||
withContext(Dispatchers.IO) {
|
||||
val serverId = Settings.activeRemoteServerId
|
||||
if (serverId == 0L) {
|
||||
return@withContext null
|
||||
}
|
||||
val storedServer = runCatching { RemoteServerManager.get(serverId) }.getOrNull()
|
||||
if (storedServer == null) {
|
||||
Settings.activeRemoteServerId = 0L
|
||||
}
|
||||
storedServer
|
||||
}
|
||||
if (server != null && _remoteServer.value == null) {
|
||||
enterRemoteControl(server)
|
||||
}
|
||||
// The initial state was already handled by enterRemoteControl.
|
||||
AppLifecycleObserver.isForeground.drop(1).collect { foreground ->
|
||||
if (_remoteServer.value == null) {
|
||||
return@collect
|
||||
}
|
||||
if (foreground) {
|
||||
// A connection cannot survive while the app is in the
|
||||
// background, so resuming always restores the retry budget.
|
||||
reconnectAttempts = 0
|
||||
sessionConnectedAt = 0L
|
||||
monitorClient.connect()
|
||||
} else {
|
||||
monitorClient.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enterRemoteControl(server: RemoteServer) {
|
||||
CommandTarget.setRemoteServer(server)
|
||||
resetSessionState()
|
||||
_remoteServer.value = server
|
||||
if (AppLifecycleObserver.isForeground.value) {
|
||||
monitorClient.connect()
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.activeRemoteServerId = server.id
|
||||
}
|
||||
}
|
||||
|
||||
fun exitRemoteControl() {
|
||||
if (_remoteServer.value == null) {
|
||||
return
|
||||
}
|
||||
CommandTarget.setRemoteServer(null)
|
||||
resetSessionState()
|
||||
_remoteServer.value = null
|
||||
monitorClient.disconnect()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.activeRemoteServerId = 0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetSessionState() {
|
||||
sessionHadConnected = false
|
||||
sessionConnectedAt = 0L
|
||||
reconnectAttempts = 0
|
||||
_isConnected.value = false
|
||||
_startedAt.value = null
|
||||
}
|
||||
|
||||
override fun onConnected() {
|
||||
scope.launch {
|
||||
if (_remoteServer.value == null) {
|
||||
return@launch
|
||||
}
|
||||
sessionHadConnected = true
|
||||
sessionConnectedAt = SystemClock.elapsedRealtime()
|
||||
_isConnected.value = true
|
||||
val serviceStartedAt =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { CommandTarget.standaloneClient().startedAt }.getOrNull()
|
||||
}
|
||||
if (_isConnected.value) {
|
||||
_startedAt.value = serviceStartedAt?.takeIf { it > 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
scope.launch {
|
||||
_isConnected.value = false
|
||||
_startedAt.value = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionError(kind: CommandClient.ConnectionErrorKind, message: String) {
|
||||
scope.launch {
|
||||
handleConnectionError(kind, message)
|
||||
}
|
||||
}
|
||||
|
||||
// A remote session that cannot connect falls back to the local device
|
||||
// immediately: leaving the app in remote mode would just make every command
|
||||
// call fail at the point of use. A drop of an established session (app
|
||||
// suspension, network change, server restart) is recoverable instead, so it
|
||||
// reconnects silently and only surfaces the error once reconnecting fails too.
|
||||
private suspend fun handleConnectionError(kind: CommandClient.ConnectionErrorKind, message: String) {
|
||||
val server = _remoteServer.value ?: return
|
||||
if (!AppLifecycleObserver.isForeground.value) {
|
||||
// An alert shown now would be invisible and the connection is torn
|
||||
// down anyway; recovery happens on the next foreground transition.
|
||||
return
|
||||
}
|
||||
if (kind == CommandClient.ConnectionErrorKind.ConnectionLost) {
|
||||
if (sessionConnectedAt != 0L &&
|
||||
SystemClock.elapsedRealtime() - sessionConnectedAt >= STABLE_CONNECTION_INTERVAL_MS
|
||||
) {
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
sessionConnectedAt = 0L
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++
|
||||
monitorClient.connect()
|
||||
return
|
||||
}
|
||||
}
|
||||
// A non-retryable connect failure, or a dropped session whose retry
|
||||
// budget is spent: fall back to the local device, then surface the
|
||||
// failure once.
|
||||
val description =
|
||||
if (sessionHadConnected) {
|
||||
Application.application.getString(R.string.remote_disconnected_from, server.displayName)
|
||||
} else {
|
||||
Application.application.getString(R.string.remote_connect_failed, server.displayName)
|
||||
}
|
||||
exitRemoteControl()
|
||||
GlobalEventBus.emit(UiEvent.ErrorMessage("$description\n$message"))
|
||||
}
|
||||
}
|
||||
@@ -585,4 +585,20 @@
|
||||
<string name="privilege_module_restart_notification_title">نیاز به راهاندازی مجدد</string>
|
||||
<string name="privilege_module_restart_notification_message">ماژول LSPosed بهروزرسانی شد. برای اعمال تغییرات دستگاه را راهاندازی مجدد کنید.</string>
|
||||
<string name="privilege_module_restart_channel">ماژول LSPosed</string>
|
||||
|
||||
<!-- Remote Control -->
|
||||
<string name="remote_control">کنترل از راه دور</string>
|
||||
<string name="remote_servers">سرورها</string>
|
||||
<string name="remote_no_servers">بدون سرور</string>
|
||||
<string name="remote_new_server">سرور جدید</string>
|
||||
<string name="remote_edit_server">ویرایش سرور</string>
|
||||
<string name="remote_local_device">دستگاه محلی</string>
|
||||
<string name="remote_manage_servers">مدیریت سرورها…</string>
|
||||
<string name="remote_secret">رمز</string>
|
||||
<string name="remote_optional">اختیاری</string>
|
||||
<string name="remote_connecting">در حال اتصال…</string>
|
||||
<string name="remote_disconnect">قطع اتصال</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_disconnected_from">اتصال با سرور راه دور %1$s قطع شد</string>
|
||||
</resources>
|
||||
|
||||
@@ -591,4 +591,20 @@
|
||||
<string name="privilege_module_restart_notification_title">Требуется перезагрузка</string>
|
||||
<string name="privilege_module_restart_notification_message">Модуль LSPosed обновлён. Перезагрузите устройство, чтобы применить изменения.</string>
|
||||
<string name="privilege_module_restart_channel">Модуль LSPosed</string>
|
||||
|
||||
<!-- Remote Control -->
|
||||
<string name="remote_control">Удаленное управление</string>
|
||||
<string name="remote_servers">Серверы</string>
|
||||
<string name="remote_no_servers">Нет серверов</string>
|
||||
<string name="remote_new_server">Новый сервер</string>
|
||||
<string name="remote_edit_server">Изменить сервер</string>
|
||||
<string name="remote_local_device">Локальное устройство</string>
|
||||
<string name="remote_manage_servers">Управление серверами…</string>
|
||||
<string name="remote_secret">Секрет</string>
|
||||
<string name="remote_optional">Необязательно</string>
|
||||
<string name="remote_connecting">Подключение…</string>
|
||||
<string name="remote_disconnect">Отключить</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_disconnected_from">Отключено от удаленного сервера %1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -581,4 +581,20 @@
|
||||
<string name="lsposed_module_pending_update">LSPosed 模块待更新</string>
|
||||
<string name="lsposed_module_pending_downgrade">LSPosed 模块待降级</string>
|
||||
<string name="lsposed_module_not_activated">LSPosed 模块未激活</string>
|
||||
|
||||
<!-- Remote Control -->
|
||||
<string name="remote_control">远程控制</string>
|
||||
<string name="remote_servers">服务器</string>
|
||||
<string name="remote_no_servers">无服务器</string>
|
||||
<string name="remote_new_server">新建服务器</string>
|
||||
<string name="remote_edit_server">编辑服务器</string>
|
||||
<string name="remote_local_device">本机</string>
|
||||
<string name="remote_manage_servers">管理服务器…</string>
|
||||
<string name="remote_secret">密钥</string>
|
||||
<string name="remote_optional">可选</string>
|
||||
<string name="remote_connecting">连接中…</string>
|
||||
<string name="remote_disconnect">断开连接</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_disconnected_from">已断开与远程服务器 %1$s 的连接</string>
|
||||
</resources>
|
||||
|
||||
@@ -584,4 +584,20 @@
|
||||
<string name="lsposed_module_pending_update">LSPosed 模組待更新</string>
|
||||
<string name="lsposed_module_pending_downgrade">LSPosed 模組待降級</string>
|
||||
<string name="lsposed_module_not_activated">LSPosed 模組未啟用</string>
|
||||
|
||||
<!-- Remote Control -->
|
||||
<string name="remote_control">遠端控制</string>
|
||||
<string name="remote_servers">伺服器</string>
|
||||
<string name="remote_no_servers">無伺服器</string>
|
||||
<string name="remote_new_server">新建伺服器</string>
|
||||
<string name="remote_edit_server">編輯伺服器</string>
|
||||
<string name="remote_local_device">本機</string>
|
||||
<string name="remote_manage_servers">管理伺服器…</string>
|
||||
<string name="remote_secret">密鑰</string>
|
||||
<string name="remote_optional">可選</string>
|
||||
<string name="remote_connecting">連接中…</string>
|
||||
<string name="remote_disconnect">斷開連接</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_disconnected_from">已斷開與遠端伺服器 %1$s 的連接</string>
|
||||
</resources>
|
||||
|
||||
@@ -598,4 +598,20 @@
|
||||
<string name="privilege_module_restart_notification_title">Reboot required</string>
|
||||
<string name="privilege_module_restart_notification_message">LSPosed module updated. Reboot to apply changes.</string>
|
||||
<string name="privilege_module_restart_channel">LSPosed Module</string>
|
||||
|
||||
<!-- Remote Control -->
|
||||
<string name="remote_control">Remote Control</string>
|
||||
<string name="remote_servers">Servers</string>
|
||||
<string name="remote_no_servers">No servers</string>
|
||||
<string name="remote_new_server">New Server</string>
|
||||
<string name="remote_edit_server">Edit Server</string>
|
||||
<string name="remote_local_device">Local Device</string>
|
||||
<string name="remote_manage_servers">Manage Servers…</string>
|
||||
<string name="remote_secret">Secret</string>
|
||||
<string name="remote_optional">Optional</string>
|
||||
<string name="remote_connecting">Connecting…</string>
|
||||
<string name="remote_disconnect">Disconnect</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_disconnected_from">Disconnected from remote server %1$s</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user