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