Add remote control

This commit is contained in:
世界
2026-07-06 21:15:31 +08:00
parent 24ac124bae
commit 416d280695
40 changed files with 1986 additions and 213 deletions
@@ -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
@@ -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()
@@ -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()
}
}
}
@@ -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,
@@ -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))
@@ -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,
@@ -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()
}
@@ -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"))
}
}
+16
View File
@@ -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>
+16
View File
@@ -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>