diff --git a/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/3.json b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/3.json new file mode 100644 index 0000000..521a319 --- /dev/null +++ b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/3.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt index bd1d7fb..c111d63 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt @@ -46,7 +46,7 @@ class ServiceNotification(private val status: MutableLiveData, private v @OptIn(DelicateCoroutinesApi::class) private val commandClient = - CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this) + CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this, localOnly = true) private var receiverRegistered = false private val notificationBuilder by lazy { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index f64dcf2..10db892 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.UnfoldLess @@ -97,6 +98,7 @@ import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.SelectableMessageDialog import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.component.RemoteStatusBar import io.nekohasekai.sfa.compose.component.ServiceStatusBar import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.component.UptimeText @@ -128,6 +130,7 @@ import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.hasPermission import io.nekohasekai.sfa.ktx.launchCustomTab import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.utils.RemoteControlManager import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -199,6 +202,7 @@ class MainActivity : enableEdgeToEdge() connection.reconnect() + RemoteControlManager.restore() UpdateState.loadFromCache() if (Settings.checkUpdateEnabled) { @@ -695,6 +699,11 @@ class MainActivity : ) } + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteConnected by RemoteControlManager.isConnected.collectAsState() + val remoteStartedAt by RemoteControlManager.startedAt.collectAsState() + val isRemote = remoteServer != null + // Initialize the dashboard view model and store reference val dashboardViewModel: DashboardViewModel = viewModel() if (!::dashboardViewModel.isInitialized) { @@ -761,7 +770,11 @@ class MainActivity : val showGroupsInNav = dashboardUiState.hasGroups val showConnectionsInNav = - currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting + if (isRemote) { + remoteConnected + } else { + currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting + } val railScreens = buildList { @@ -840,6 +853,12 @@ class MainActivity : } } + is UiEvent.Navigate -> { + navController.navigate(event.route) { + launchSingleTop = true + } + } + is UiEvent.ApplyServiceChange -> enqueueApplyServiceChange(event.mode) } } @@ -855,11 +874,12 @@ class MainActivity : .fillMaxSize() .padding(paddingValues), ) { - // Service Status Bar (shown when service is running or stopping) + // Service Status Bar (shown when service is running or stopping); + // remote control replaces it with the remote session bar. val serviceRunning = currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting - val showStatusBar = serviceRunning || currentServiceStatus == Status.Stopping - val showStartFab = !serviceRunning && dashboardUiState.selectedProfileId != -1L + val showStatusBar = isRemote || serviceRunning || currentServiceStatus == Status.Stopping + val showStartFab = !isRemote && !serviceRunning && dashboardUiState.selectedProfileId != -1L SFANavHost( navController = navController, @@ -878,18 +898,34 @@ class MainActivity : modifier = Modifier.fillMaxSize(), ) if (!useNavigationRail) { - ServiceStatusBar( - visible = showStatusBar && !isSubScreen, - serviceStatus = currentServiceStatus, - startTime = dashboardUiState.serviceStartTime, - groupsCount = dashboardUiState.groupsCount, - hasGroups = dashboardUiState.hasGroups, - onGroupsClick = { showGroupsSheet = true }, - connectionsCount = dashboardUiState.connectionsCount, - onConnectionsClick = { showConnectionsSheet = true }, - onStopClick = { dashboardViewModel.toggleService() }, - modifier = Modifier.align(Alignment.BottomCenter), - ) + if (isRemote) { + RemoteStatusBar( + visible = !isSubScreen, + serverName = remoteServer?.displayName ?: "", + isConnected = remoteConnected, + startTime = remoteStartedAt, + groupsCount = dashboardUiState.groupsCount, + hasGroups = dashboardUiState.hasGroups, + onGroupsClick = { showGroupsSheet = true }, + connectionsCount = dashboardUiState.connectionsCount, + onConnectionsClick = { showConnectionsSheet = true }, + onDisconnectClick = { RemoteControlManager.exitRemoteControl() }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } else { + ServiceStatusBar( + visible = showStatusBar && !isSubScreen, + serviceStatus = currentServiceStatus, + startTime = dashboardUiState.serviceStartTime, + groupsCount = dashboardUiState.groupsCount, + hasGroups = dashboardUiState.hasGroups, + onGroupsClick = { showGroupsSheet = true }, + connectionsCount = dashboardUiState.connectionsCount, + onConnectionsClick = { showConnectionsSheet = true }, + onStopClick = { dashboardViewModel.toggleService() }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } val showPadFab = useNavigationRail && !isSubScreen && (showStartFab || showStatusBar) @@ -905,7 +941,35 @@ class MainActivity : val isRunning = currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting val isStopping = currentServiceStatus == Status.Stopping - if (currentServiceStatus == Status.Stopped) { + if (isRemote) { + ExtendedFloatingActionButton( + onClick = { RemoteControlManager.exitRemoteControl() }, + icon = { + Icon( + imageVector = Icons.Default.LinkOff, + contentDescription = stringResource(R.string.remote_disconnect), + ) + }, + text = { + if (remoteConnected && remoteStartedAt != null) { + UptimeText(startTime = remoteStartedAt!!) + } else { + Text( + text = + if (remoteConnected) { + remoteServer?.displayName ?: "" + } else { + stringResource(R.string.remote_connecting) + }, + style = MaterialTheme.typography.labelLarge, + ) + } + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.height(64.dp), + ) + } else if (currentServiceStatus == Status.Stopped) { FloatingActionButton( onClick = { startService() }, containerColor = MaterialTheme.colorScheme.primaryContainer, @@ -981,7 +1045,8 @@ class MainActivity : } else { // Start FAB (shown when service is stopped and a profile is selected) androidx.compose.animation.AnimatedVisibility( - visible = currentServiceStatus == Status.Stopped && + visible = !isRemote && + currentServiceStatus == Status.Stopped && dashboardUiState.selectedProfileId != -1L && !isSubScreen, enter = scaleIn(), @@ -1007,7 +1072,8 @@ class MainActivity : val crashReportUnreadCount by CrashReportManager.unreadCount.collectAsState() val oomReportUnreadCount by OOMReportManager.unreadCount.collectAsState() - val toolsUnreadCount = crashReportUnreadCount + oomReportUnreadCount + // The crash/OOM report entries are hidden in remote control mode. + val toolsUnreadCount = if (isRemote) 0 else crashReportUnreadCount + oomReportUnreadCount LaunchedEffect(Unit) { withContext(Dispatchers.IO) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt index 70b1f3c..2c29552 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt @@ -15,6 +15,8 @@ sealed class UiEvent { data class EditProfile(val profileId: Long) : UiEvent() + data class Navigate(val route: String) : UiEvent() + object RequestStartService : UiEvent() object RequestReconnectService : UiEvent() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteControlMenuItems.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteControlMenuItems.kt new file mode 100644 index 0000000..5d54152 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteControlMenuItems.kt @@ -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> { + val scope = rememberCoroutineScope() + val servers = remember { mutableStateOf>(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, 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, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteStatusBar.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteStatusBar.kt new file mode 100644 index 0000000..d0e02ab --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteStatusBar.kt @@ -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, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index 0a9d0e0..afd5239 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -29,9 +29,11 @@ import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.EditRemoteServerScreen import io.nekohasekai.sfa.compose.screen.settings.FDroidMirrorScreen import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen +import io.nekohasekai.sfa.compose.screen.settings.RemoteControlScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen import io.nekohasekai.sfa.compose.screen.settings.TailscaleFontPickerScreen @@ -527,6 +529,38 @@ fun SFANavHost( PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus) } + composable( + route = "settings/remote_control", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + RemoteControlScreen(navController = navController) + } + + composable( + route = "settings/remote_control/new", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + EditRemoteServerScreen(navController = navController) + } + + composable( + route = "settings/remote_control/edit/{serverId}", + arguments = listOf(navArgument("serverId") { type = NavType.LongType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val serverId = backStackEntry.arguments?.getLong("serverId") ?: -1L + EditRemoteServerScreen(navController = navController, serverId = serverId) + } + composable( route = "settings/privilege", enterTransition = slideInFromRight, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt index a525ad5..7d59032 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,6 +46,7 @@ import androidx.compose.ui.unit.dp import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.model.Connection +import io.nekohasekai.sfa.utils.RemoteControlManager private fun Drawable.toBitmap(): Bitmap { if (this is BitmapDrawable) return bitmap @@ -82,7 +84,16 @@ private fun rememberAppInfo(packageName: String): AppInfo? { @Composable fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) { var showContextMenu by remember { mutableStateOf(false) } - val packageName = connection.processInfo?.packageNames?.firstOrNull() + // In remote control mode the reported packages belong to the remote device, + // so resolving them against the local package manager would be wrong. + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val isRemote = remoteServer != null + val packageName = + if (isRemote) { + null + } else { + connection.processInfo?.packageNames?.firstOrNull() + } val appInfo = packageName?.let { rememberAppInfo(it) } Box(modifier = modifier) { @@ -101,19 +112,21 @@ fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> U horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // Column 1: App icon - if (appInfo != null) { - Image( - bitmap = appInfo.icon, - contentDescription = null, - modifier = Modifier.size(32.dp), - ) - } else { - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (!isRemote) { + if (appInfo != null) { + Image( + bitmap = appInfo.icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + } else { + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } // Content column diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt index c54087c..9118097 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt @@ -3,7 +3,6 @@ package io.nekohasekai.sfa.compose.screen.connections import androidx.lifecycle.viewModelScope import io.nekohasekai.libbox.ConnectionEvents import io.nekohasekai.libbox.Connections -import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.compose.base.ScreenEvent import io.nekohasekai.sfa.compose.model.Connection @@ -13,6 +12,8 @@ import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.ktx.toList import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.CommandClient +import io.nekohasekai.sfa.utils.CommandTarget +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -64,6 +65,8 @@ class ConnectionsViewModel : val screenOn: Boolean, val visibleCount: Int, val status: Status, + val remoteServerId: Long?, + val remoteConnected: Boolean, ) init { @@ -73,11 +76,17 @@ class ConnectionsViewModel : AppLifecycleObserver.isScreenOn, _visibleCount, _serviceStatus, - ) { foreground, screenOn, visibleCount, status -> - ConnectionState(foreground, screenOn, visibleCount, status) + combine( + RemoteControlManager.remoteServer, + RemoteControlManager.isConnected, + ) { remoteServer, remoteConnected -> remoteServer?.id to remoteConnected }, + ) { foreground, screenOn, visibleCount, status, (remoteServerId, remoteConnected) -> + ConnectionState(foreground, screenOn, visibleCount, status, remoteServerId, remoteConnected) }.collect { state -> + val serviceReady = + if (state.remoteServerId != null) state.remoteConnected else state.status == Status.Started val shouldConnect = state.foreground && state.screenOn && - state.visibleCount > 0 && state.status == Status.Started + state.visibleCount > 0 && serviceReady if (shouldConnect) { updateState { copy(isLoading = true) } commandClient.connect() @@ -98,6 +107,9 @@ class ConnectionsViewModel : } private suspend fun handleServiceStatusChange(status: Status) { + if (RemoteControlManager.remoteServer.value != null) { + return + } if (status != Status.Started) { withContext(Dispatchers.Default) { connectionsMutex.withLock { @@ -151,7 +163,7 @@ class ConnectionsViewModel : fun closeConnection(connectionId: String) { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().closeConnection(connectionId) + CommandTarget.standaloneClient().closeConnection(connectionId) withContext(Dispatchers.Main) { sendEvent(ConnectionsEvent.ConnectionClosed(connectionId)) } @@ -164,7 +176,7 @@ class ConnectionsViewModel : fun closeAllConnections() { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().closeConnections() + CommandTarget.standaloneClient().closeConnections() withContext(Dispatchers.Main) { sendEvent(ConnectionsEvent.AllConnectionsClosed) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt index b13ea38..3d9d897 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt @@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -23,7 +28,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -31,9 +40,12 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems +import io.nekohasekai.sfa.compose.component.rememberRemoteServers import io.nekohasekai.sfa.compose.navigation.NewProfileArgs import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.launch data class CardRenderItem(val cards: List, val isRow: Boolean) @@ -48,16 +60,46 @@ fun DashboardScreen( viewModel: DashboardViewModel = viewModel(), ) { val uiState by viewModel.uiState.collectAsState() + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteConnected by RemoteControlManager.isConnected.collectAsState() + val isRemote = remoteServer != null + val remoteServers by rememberRemoteServers() + var showOthersMenu by remember { mutableStateOf(false) } OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.title_dashboard)) }, actions = { - IconButton(onClick = { viewModel.toggleCardSettingsDialog() }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.title_others), - ) + Box { + IconButton(onClick = { showOthersMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.title_others), + ) + } + DropdownMenu( + expanded = showOthersMenu, + onDismissRequest = { showOthersMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.dashboard_items)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.GridView, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { + showOthersMenu = false + viewModel.toggleCardSettingsDialog() + }, + ) + RemoteControlMenuItems( + servers = remoteServers, + onAction = { showOthersMenu = false }, + ) + } } }, ) @@ -120,6 +162,16 @@ fun DashboardScreen( ) } + if (isRemote && !remoteConnected) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + return + } + Box( modifier = Modifier.fillMaxSize(), ) { @@ -143,8 +195,17 @@ fun DashboardScreen( // Filter cards based on availability val actuallyVisibleCards = uiState.visibleCards.filter { cardGroup -> - when (cardGroup) { - CardGroup.Profiles -> true // Profiles card is always available + when { + // The remote dashboard only renders cards backed by the + // command protocol: profiles and system proxy are + // operations on the local device. + isRemote -> + cardGroup != CardGroup.Profiles && + cardGroup != CardGroup.SystemProxy && + serviceRunning && + isCardAvailableWhenServiceRunning(cardGroup, uiState) + + cardGroup == CardGroup.Profiles -> true // Profiles card is always available else -> serviceRunning && isCardAvailableWhenServiceRunning(cardGroup, uiState) } }.toSet() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt index 32f2ed0..c434a89 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -14,12 +14,16 @@ import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.TypedProfile import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.CommandClient +import io.nekohasekai.sfa.utils.CommandTarget import io.nekohasekai.sfa.utils.HTTPClient +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray @@ -156,9 +160,19 @@ class DashboardViewModel : ProfileManager.registerCallback(::onProfilesChanged) viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (_serviceStatus.value != Status.Started) return@collect - if (foreground) { + combine( + AppLifecycleObserver.isForeground, + RemoteControlManager.remoteServer, + RemoteControlManager.isConnected, + _serviceStatus, + ) { foreground, remoteServer, remoteConnected, status -> + SessionTarget( + connect = foreground && + if (remoteServer != null) remoteConnected else status == Status.Started, + remoteServerId = remoteServer?.id, + ) + }.distinctUntilChanged().collect { target -> + if (target.connect) { commandClient.connect() } else { commandClient.disconnect() @@ -167,6 +181,8 @@ class DashboardViewModel : } } + private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?) + override fun onCleared() { super.onCleared() ProfileManager.unregisterCallback(::onProfilesChanged) @@ -439,7 +455,12 @@ class DashboardViewModel : updateState { copy( serviceStatus = status, - isStatusVisible = status == Status.Starting || status == Status.Started, + isStatusVisible = + if (RemoteControlManager.remoteServer.value != null) { + isStatusVisible + } else { + status == Status.Starting || status == Status.Started + }, ) } handleServiceStatusChange(status) @@ -447,18 +468,21 @@ class DashboardViewModel : } private fun handleServiceStatusChange(status: Status) { + val isRemote = RemoteControlManager.remoteServer.value != null when (status) { Status.Started -> { checkDeprecatedNotes() - if (AppLifecycleObserver.isForeground.value) { - commandClient.connect() + if (isRemote) { + return } reloadSystemProxyStatus() reloadStartedAt() } Status.Stopped -> { - commandClient.disconnect() + if (isRemote) { + return + } updateState { copy( hasGroups = false, @@ -545,7 +569,7 @@ class DashboardViewModel : fun selectClashMode(mode: String) { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().setClashMode(mode) + CommandTarget.standaloneClient().setClashMode(mode) // Update UI state directly without reconnecting withContext(Dispatchers.Main) { updateState { @@ -562,6 +586,12 @@ class DashboardViewModel : override fun onConnected() { viewModelScope.launch(Dispatchers.Main) { updateState { copy(isStatusVisible = true) } + // Returning from remote control skipped the local reloads that + // normally run when the service starts. + if (RemoteControlManager.remoteServer.value == null && _serviceStatus.value == Status.Started) { + reloadSystemProxyStatus() + reloadStartedAt() + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt index 5d1c0f8..8b8dd42 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt @@ -1,7 +1,6 @@ package io.nekohasekai.sfa.compose.screen.dashboard.groups import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.compose.base.ScreenEvent @@ -11,9 +10,13 @@ import io.nekohasekai.sfa.compose.model.toList import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.CommandClient +import io.nekohasekai.sfa.utils.CommandTarget +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -54,9 +57,19 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : } viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (lastServiceStatus != Status.Started) return@collect - if (foreground) { + combine( + AppLifecycleObserver.isForeground, + RemoteControlManager.remoteServer, + RemoteControlManager.isConnected, + _serviceStatus, + ) { foreground, remoteServer, remoteConnected, status -> + SessionTarget( + connect = foreground && + if (remoteServer != null) remoteConnected else status == Status.Started, + remoteServerId = remoteServer?.id, + ) + }.distinctUntilChanged().collect { target -> + if (target.connect) { if (isUsingSharedClient) { commandClient.addHandler(this@GroupsViewModel) } else { @@ -74,6 +87,8 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : } } + private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?) + override fun createInitialState() = GroupsUiState() override fun onCleared() { @@ -86,15 +101,10 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : } private fun handleServiceStatusChange(status: Status) { - if (status == Status.Started) { - if (!isUsingSharedClient && AppLifecycleObserver.isForeground.value) { - updateState { copy(isLoading = true) } - commandClient.connect() - } - } else { - if (!isUsingSharedClient) { - commandClient.disconnect() - } + if (RemoteControlManager.remoteServer.value != null) { + return + } + if (status != Status.Started) { updateState { copy( groups = emptyList(), @@ -127,7 +137,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : } viewModelScope.launch(Dispatchers.IO) { runCatching { - Libbox.newStandaloneCommandClient().setGroupExpand(groupTag, newExpanded) + CommandTarget.standaloneClient().setGroupExpand(groupTag, newExpanded) } } } @@ -148,7 +158,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : viewModelScope.launch(Dispatchers.IO) { groups.forEach { group -> runCatching { - Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, newExpanded) + CommandTarget.standaloneClient().setGroupExpand(group.tag, newExpanded) } } } @@ -165,7 +175,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : viewModelScope.launch(Dispatchers.IO) { try { // Select the new outbound immediately - Libbox.newStandaloneCommandClient().selectOutbound(groupTag, itemTag) + CommandTarget.standaloneClient().selectOutbound(groupTag, itemTag) // Update local state and show snackbar withContext(Dispatchers.Main) { @@ -193,7 +203,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : fun closeConnections() { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().closeConnections() + CommandTarget.standaloneClient().closeConnections() withContext(Dispatchers.Main) { dismissCloseConnectionsSnackbar() } @@ -215,7 +225,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : fun urlTest(groupTag: String) { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().urlTest(groupTag) + CommandTarget.standaloneClient().urlTest(groupTag) } catch (e: Exception) { sendError(e) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt index e4ab1f4..3260961 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt @@ -94,8 +94,11 @@ import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compat.WindowSizeClassCompat import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat +import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems +import io.nekohasekai.sfa.compose.component.rememberRemoteServers import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.RemoteControlManager import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -124,6 +127,8 @@ fun LogScreen( val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() val resolvedTitle = title ?: stringResource(R.string.title_log) + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteServers by rememberRemoteServers() val emptyStateMessage = emptyMessage ?: stringResource(R.string.privilege_settings_hook_logs_empty) OverrideTopBar { @@ -471,10 +476,14 @@ fun LogScreen( ) { Text( text = if (showStatusInfo) { - when (serviceStatus) { - Status.Started -> stringResource(R.string.status_started) - Status.Starting -> stringResource(R.string.status_starting) - Status.Stopping -> stringResource(R.string.status_stopping) + when { + remoteServer != null && !uiState.isConnected -> + stringResource(R.string.remote_connecting) + + remoteServer != null -> stringResource(R.string.status_started) + serviceStatus == Status.Started -> stringResource(R.string.status_started) + serviceStatus == Status.Starting -> stringResource(R.string.status_starting) + serviceStatus == Status.Stopping -> stringResource(R.string.status_stopping) else -> stringResource(R.string.status_default) } } else { @@ -828,6 +837,13 @@ fun LogScreen( }, ) } + + if (showStatusInfo) { + RemoteControlMenuItems( + servers = remoteServers, + onAction = { resolvedViewModel.toggleOptionsMenu() }, + ) + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt index ef7ebe1..121d0a7 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt @@ -1,13 +1,17 @@ package io.nekohasekai.sfa.compose.screen.log import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LogEntry import io.nekohasekai.sfa.compose.util.AnsiColorUtils import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.CommandClient +import io.nekohasekai.sfa.utils.CommandTarget +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -28,12 +32,23 @@ class LogViewModel : handler = this, ) private var lastServiceStatus: Status = Status.Stopped + private val serviceStatusFlow = MutableStateFlow(Status.Stopped) init { viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (lastServiceStatus != Status.Started) return@collect - if (foreground) { + combine( + AppLifecycleObserver.isForeground, + RemoteControlManager.remoteServer, + RemoteControlManager.isConnected, + serviceStatusFlow, + ) { foreground, remoteServer, remoteConnected, status -> + SessionTarget( + connect = foreground && + if (remoteServer != null) remoteConnected else status == Status.Started, + remoteServerId = remoteServer?.id, + ) + }.distinctUntilChanged().collect { target -> + if (target.connect) { commandClient.connect() } else { commandClient.disconnect() @@ -42,6 +57,8 @@ class LogViewModel : } } + private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?) + private fun processLogEntry(entry: LogEntry): ProcessedLogEntry { val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default return ProcessedLogEntry( @@ -53,17 +70,14 @@ class LogViewModel : override fun updateServiceStatus(status: Status) { lastServiceStatus = status + serviceStatusFlow.value = status _uiState.update { it.copy(serviceStatus = status) } + if (RemoteControlManager.remoteServer.value != null) { + return + } when (status) { - Status.Started -> { - if (AppLifecycleObserver.isForeground.value) { - commandClient.connect() - } - } - Status.Stopped, Status.Stopping -> { - commandClient.disconnect() _uiState.update { it.copy(isConnected = false) } } @@ -101,7 +115,7 @@ class LogViewModel : val sent = withContext(Dispatchers.IO) { runCatching { - Libbox.newStandaloneCommandClient().clearLogs() + CommandTarget.standaloneClient().clearLogs() }.isSuccess } // With the service stopped there is no broadcast to clear the UI, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt new file mode 100644 index 0000000..957aa71 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt @@ -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(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)) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt new file mode 100644 index 0000000..b026933 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt @@ -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>(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) + } + }, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt index b7442c9..aabcea4 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SettingsRemote import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.Badge import androidx.compose.material3.Card @@ -185,6 +186,29 @@ fun SettingsScreen(navController: NavController) { ), ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.remote_control), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.SettingsRemote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clickable { navController.navigate("settings/remote_control") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + ListItem( headlineContent = { Text( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt index 63d8963..8e44750 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt @@ -53,6 +53,7 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.RemoteControlManager @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,7 +63,11 @@ fun NetworkQualityScreen( viewModel: NetworkQualityViewModel = viewModel(), ) { val state by viewModel.uiState.collectAsState() - val vpnRunning = serviceStatus == Status.Started + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteConnected by RemoteControlManager.isConnected.collectAsState() + val serviceAvailable = remoteServer != null || serviceStatus == Status.Started + val vpnRunning = + if (remoteServer != null) remoteConnected else serviceStatus == Status.Started val context = LocalContext.current var showConfigURLDialog by remember { mutableStateOf(false) } @@ -107,7 +112,7 @@ fun NetworkQualityScreen( title = { Text(stringResource(R.string.network_quality_metered_title)) }, text = { Text(stringResource(R.string.network_quality_metered_message)) }, confirmButton = { - TextButton(onClick = { viewModel.confirmMeteredStart(vpnRunning) }) { + TextButton(onClick = { viewModel.confirmMeteredStart(serviceAvailable) }) { Text(stringResource(R.string.network_quality_metered_continue)) } }, @@ -299,7 +304,7 @@ fun NetworkQualityScreen( } } else { Button( - onClick = { viewModel.requestStartTest(context, vpnRunning) }, + onClick = { viewModel.requestStartTest(context, serviceAvailable) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.network_quality_start)) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt index b57e2a0..26285f5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt @@ -10,6 +10,7 @@ import io.nekohasekai.libbox.NetworkQualityTestHandler import io.nekohasekai.libbox.NetworkQualityTestSession import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.utils.CommandTarget import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -117,7 +118,7 @@ class NetworkQualityViewModel : BaseViewModel() { viewModelScope.launch(Dispatchers.IO) { try { nqSession = - Libbox.newStandaloneCommandClient() + CommandTarget.standaloneClient() .startNetworkQualityTest( configURL, outboundTag, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt index 9681283..5389974 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt @@ -50,6 +50,7 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.RemoteControlManager @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -59,7 +60,11 @@ fun STUNTestScreen( viewModel: STUNTestViewModel = viewModel(), ) { val state by viewModel.uiState.collectAsState() - val vpnRunning = serviceStatus == Status.Started + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteConnected by RemoteControlManager.isConnected.collectAsState() + val serviceAvailable = remoteServer != null || serviceStatus == Status.Started + val vpnRunning = + if (remoteServer != null) remoteConnected else serviceStatus == Status.Started var showServerDialog by remember { mutableStateOf(false) } @@ -191,7 +196,7 @@ fun STUNTestScreen( } } else { Button( - onClick = { viewModel.startTest(vpnRunning) }, + onClick = { viewModel.startTest(serviceAvailable) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.stun_start)) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt index 19073b8..a550a95 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt @@ -7,6 +7,7 @@ import io.nekohasekai.libbox.STUNTestProgress import io.nekohasekai.libbox.STUNTestResult import io.nekohasekai.libbox.STUNTestSession import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.utils.CommandTarget import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -63,7 +64,7 @@ class STUNTestViewModel : BaseViewModel() { viewModelScope.launch(Dispatchers.IO) { try { stunSession = - Libbox.newStandaloneCommandClient() + CommandTarget.standaloneClient() .startSTUNTest(server, outboundTag, handler) } catch (e: Exception) { withContext(Dispatchers.Main) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt index 3967fe8..d9c3d5d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt @@ -1,11 +1,11 @@ package io.nekohasekai.sfa.compose.screen.tools import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.TailscalePingHandler import io.nekohasekai.libbox.TailscalePingResult import io.nekohasekai.libbox.TailscalePingSession import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.utils.CommandTarget import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -38,7 +38,7 @@ class TailscalePingViewModel : BaseViewModel() { viewModelScope.launch(Dispatchers.IO) { try { pingSession = - Libbox.newStandaloneCommandClient() + CommandTarget.standaloneClient() .startTailscalePing( endpointTag, peerIP, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalViewModel.kt index b5cedec..8bd22d8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalViewModel.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.lifecycle.viewModelScope import com.termux.terminal.TerminalSession import com.termux.terminal.TerminalSessionClient -import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.TailscaleSSHHandler import io.nekohasekai.libbox.TailscaleSSHOptions @@ -12,7 +11,10 @@ import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.terminal.ManagedSession import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession import io.nekohasekai.sfa.terminal.TailscaleSSHTerminalSession +import io.nekohasekai.sfa.utils.CommandTarget +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch data class TailscaleSSHTerminalState( @@ -59,6 +61,7 @@ class TailscaleSSHTerminalViewModel : BaseViewModel() viewModelScope.launch(Dispatchers.IO) { try { statusSubscription = - Libbox.newStandaloneCommandClient() + CommandTarget.standaloneClient() .subscribeTailscaleStatus(object : TailscaleStatusHandler { override fun onStatusUpdate(status: TailscaleStatusUpdate) { val endpoints = convertUpdate(status) @@ -117,7 +117,7 @@ class TailscaleStatusViewModel : BaseViewModel() fun setExitNode(endpointTag: String, stableID: String) { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().setTailscaleExitNode(endpointTag, stableID) + CommandTarget.standaloneClient().setTailscaleExitNode(endpointTag, stableID) } catch (e: Exception) { sendErrorMessage(e.message ?: "set exit node failed") } @@ -127,7 +127,7 @@ class TailscaleStatusViewModel : BaseViewModel() fun logout(endpointTag: String) { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().tailscaleLogout(endpointTag) + CommandTarget.standaloneClient().tailscaleLogout(endpointTag) } catch (e: Exception) { sendErrorMessage(e.message ?: "logout failed") } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt index 11ed9ce..bdc2a5c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt @@ -14,11 +14,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.Hub import androidx.compose.material.icons.outlined.Memory import androidx.compose.material.icons.outlined.NetworkCheck +import androidx.compose.material.icons.outlined.SwapHoriz import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -26,6 +28,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -48,10 +51,13 @@ import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.CrashReportManager import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems +import io.nekohasekai.sfa.compose.component.rememberRemoteServers import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession +import io.nekohasekai.sfa.utils.RemoteControlManager @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -61,17 +67,57 @@ fun ToolsScreen( tailscaleViewModel: TailscaleStatusViewModel, sshSharedViewModel: TailscaleSSHSharedViewModel, ) { + val remoteServers by rememberRemoteServers() + OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.title_tools)) }, + actions = { + if (remoteServers.isNotEmpty()) { + Box { + var showOthersMenu by remember { mutableStateOf(false) } + IconButton(onClick = { showOthersMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.title_others), + ) + } + DropdownMenu( + expanded = showOthersMenu, + onDismissRequest = { showOthersMenu = false }, + ) { + RemoteControlMenuItems( + servers = remoteServers, + onAction = { showOthersMenu = false }, + leadingDivider = false, + ) + } + } + } + }, ) } val crashUnreadCount by CrashReportManager.unreadCount.collectAsState() val oomUnreadCount by OOMReportManager.unreadCount.collectAsState() val tailscaleState by tailscaleViewModel.uiState.collectAsState() + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + + LaunchedEffect(remoteServer?.id) { + // Drop the previous target's subscription when switching between the + // local service and a remote server, or between two servers: a server + // without tailscale leaves no active stream to error out, so the + // subscription would stay stale without an explicit cancel. + tailscaleViewModel.cancel() + if (remoteServer != null || serviceStatus == Status.Started) { + tailscaleViewModel.subscribe() + } + } LaunchedEffect(serviceStatus) { + if (remoteServer != null) { + return@LaunchedEffect + } if (serviceStatus == Status.Started) { tailscaleViewModel.subscribe() } else { @@ -243,7 +289,7 @@ fun ToolsScreen( }, leadingContent = { Icon( - imageVector = Icons.Outlined.NetworkCheck, + imageVector = Icons.Outlined.SwapHoriz, contentDescription = null, tint = MaterialTheme.colorScheme.primary, ) @@ -255,73 +301,77 @@ fun ToolsScreen( ) } - Text( - text = stringResource(R.string.title_debug), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) + // Crash/OOM reports read local files, which the remote control API + // does not reach. + if (remoteServer == null) { + Text( + text = stringResource(R.string.title_debug), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.crash_report), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.BugReport, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (crashUnreadCount > 0) { - Badge(containerColor = MaterialTheme.colorScheme.primary) { - Text("$crashUnreadCount") - } - } - }, + Card( modifier = Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) - .clickable { navController.navigate("tools/crash_report") }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - ) - ListItem( - headlineContent = { - Text( - stringResource(R.string.oom_report), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Memory, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (oomUnreadCount > 0) { - Badge(containerColor = MaterialTheme.colorScheme.primary) { - Text("$oomUnreadCount") + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.crash_report), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.BugReport, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (crashUnreadCount > 0) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { + Text("$crashUnreadCount") + } } - } - }, - modifier = Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .clickable { navController.navigate("tools/oom_report") }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("tools/crash_report") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Memory, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (oomUnreadCount > 0) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { + Text("$oomUnreadCount") + } + } + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("tools/oom_report") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index d78d648..81caf34 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -40,6 +40,9 @@ object SettingsKey { const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" + // Remote Control + const val ACTIVE_REMOTE_SERVER_ID = "active_remote_server_id" + // Tailscale SSH const val TAILSCALE_SSH_REMEMBERED_USERNAMES = "tailscale_ssh_remembered_usernames" const val TAILSCALE_SSH_QUICK_CONNECT_PEERS = "tailscale_ssh_quick_connect_peers" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt b/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt index e7eee0f..0dee3b5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt @@ -6,13 +6,15 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @Database( - entities = [Profile::class], - version = 2, + entities = [Profile::class, RemoteServer::class], + version = 3, exportSchema = true, ) abstract class ProfileDatabase : RoomDatabase() { abstract fun profileDao(): Profile.Dao + abstract fun remoteServerDao(): RemoteServer.Dao + companion object { val MIGRATION_1_2 = object : Migration(1, 2) { @@ -21,5 +23,19 @@ abstract class ProfileDatabase : RoomDatabase() { database.execSQL("ALTER TABLE profiles ADD COLUMN icon TEXT DEFAULT NULL") } } + + val MIGRATION_2_3 = + object : Migration(2, 3) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `remote_servers` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`userOrder` INTEGER NOT NULL, " + + "`name` TEXT NOT NULL, " + + "`url` TEXT NOT NULL, " + + "`secret` TEXT NOT NULL)", + ) + } + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt index b0e1643..92e4c91 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt @@ -28,7 +28,7 @@ object ProfileManager { ProfileDatabase::class.java, Path.PROFILES_DATABASE_PATH, ) - .addMigrations(ProfileDatabase.MIGRATION_1_2) + .addMigrations(ProfileDatabase.MIGRATION_1_2, ProfileDatabase.MIGRATION_2_3) .fallbackToDestructiveMigrationOnDowngrade() .enableMultiInstanceInvalidation() .setQueryExecutor { GlobalScope.launch { it.run() } } @@ -93,4 +93,6 @@ object ProfileManager { } suspend fun list(): List = instance.profileDao().list() + + fun remoteServerDao(): RemoteServer.Dao = instance.remoteServerDao() } diff --git a/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt new file mode 100644 index 0000000..6e4eb25 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt @@ -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): 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 + + @Query("SELECT MAX(userOrder) + 1 FROM remote_servers") + fun nextOrder(): Long? + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/database/RemoteServerManager.kt b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServerManager.kt new file mode 100644 index 0000000..27f6c67 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServerManager.kt @@ -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): 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 = ProfileManager.remoteServerDao().list() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index e9b22fb..f4ee6d0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -114,6 +114,8 @@ object Settings { var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" } var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } + var activeRemoteServerId by dataStore.long(SettingsKey.ACTIVE_REMOTE_SERVER_ID) { 0L } + // Tailscale SSH var tailscaleSSHRememberedUsernames by dataStore.map(SettingsKey.TAILSCALE_SSH_REMEMBERED_USERNAMES) var tailscaleSSHQuickConnectPeers by dataStore.stringSet(SettingsKey.TAILSCALE_SSH_QUICK_CONNECT_PEERS) diff --git a/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalSessionManager.kt b/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalSessionManager.kt index 47ab77e..3a47ca5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalSessionManager.kt +++ b/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalSessionManager.kt @@ -6,4 +6,8 @@ data class ManagedSession( val id: String = UUID.randomUUID().toString(), val terminalSession: TailscaleSSHTerminalSession, val presentedSession: TailscaleSSHPresentedSession, -) +) { + // The session owns its command client (a dedicated connection in remote + // control mode) and must disconnect it when the session ends. + var commandClient: io.nekohasekai.libbox.CommandClient? = null +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index b70bdd8..1680ba5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -1,8 +1,6 @@ package io.nekohasekai.sfa.utils import android.util.Log -import go.Seq -import io.nekohasekai.libbox.CommandClient import io.nekohasekai.libbox.CommandClientHandler import io.nekohasekai.libbox.CommandClientOptions import io.nekohasekai.libbox.ConnectionEvents @@ -16,17 +14,23 @@ import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StringIterator import io.nekohasekai.sfa.ktx.toList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch open class CommandClient( private val scope: CoroutineScope, private val connectionTypes: List, private val handler: Handler, + private val localOnly: Boolean = false, ) { constructor( scope: CoroutineScope, connectionType: ConnectionType, handler: Handler, - ) : this(scope, listOf(connectionType), handler) + localOnly: Boolean = false, + ) : this(scope, listOf(connectionType), handler, localOnly) private val additionalHandlers = mutableListOf() private var cachedGroups: MutableList? = null @@ -65,11 +69,22 @@ open class CommandClient( Outbounds, } + enum class ConnectionErrorKind { + // A connect attempt failed; retrying is not expected to succeed. + ConnectFailed, + + // An established connection dropped (app suspension, network change, + // server restart); reconnecting may recover. + ConnectionLost, + } + interface Handler { fun onConnected() {} fun onDisconnected() {} + fun onConnectionError(kind: ConnectionErrorKind, message: String) {} + fun updateStatus(status: StatusMessage) {} fun setDefaultLogLevel(level: Int) {} @@ -89,58 +104,126 @@ open class CommandClient( fun writeConnectionEvents(events: ConnectionEvents) {} } - private var commandClient: CommandClient? = null - private val clientHandler = ClientHandler() + private val access = Any() + private var connectionEpoch = 0 + private var commandClient: io.nekohasekai.libbox.CommandClient? = null fun connect() { - disconnect() - val options = CommandClientOptions() - connectionTypes.forEach { connectionType -> - val command = - when (connectionType) { - ConnectionType.Status -> Libbox.CommandStatus - ConnectionType.Groups -> Libbox.CommandGroup - ConnectionType.Log -> Libbox.CommandLog - ConnectionType.ClashMode -> Libbox.CommandClashMode - ConnectionType.Connections -> Libbox.CommandConnections - ConnectionType.Outbounds -> Libbox.CommandOutbounds + val epoch: Int + val previousClient: io.nekohasekai.libbox.CommandClient? + synchronized(access) { + epoch = ++connectionEpoch + previousClient = commandClient + commandClient = null + } + // A remote connect dials over the network and blocks until the probe + // completes, so it must run off the main thread. + if (previousClient != null) { + // The dropped Go-side Disconnected callback is suppressed by the epoch + // bump, so the owner-initiated disconnect is reported deterministically. + getAllHandlers().forEach { it.onDisconnected() } + } + scope.launch(Dispatchers.IO) { + previousClient?.apply { + runCatching { + disconnect() + } + } + val options = CommandClientOptions() + connectionTypes.forEach { connectionType -> + val command = + when (connectionType) { + ConnectionType.Status -> Libbox.CommandStatus + ConnectionType.Groups -> Libbox.CommandGroup + ConnectionType.Log -> Libbox.CommandLog + ConnectionType.ClashMode -> Libbox.CommandClashMode + ConnectionType.Connections -> Libbox.CommandConnections + ConnectionType.Outbounds -> Libbox.CommandOutbounds + } + options.addCommand(command) + } + options.statusInterval = 1 * 1000 * 1000 * 1000 + val remoteServer = if (localOnly) null else CommandTarget.remoteServer + val newClient: io.nekohasekai.libbox.CommandClient + try { + newClient = + if (remoteServer != null) { + Libbox.newRemoteCommandClient( + ClientHandler(epoch), + options, + CommandTarget.libboxOptions(remoteServer), + ) + } else { + io.nekohasekai.libbox.CommandClient(ClientHandler(epoch), options) + } + newClient.connect() + } catch (e: Exception) { + Log.d("CommandClient", "connect failed", e) + if (isActiveEpoch(epoch)) { + handler.onConnectionError( + ConnectionErrorKind.ConnectFailed, + e.message ?: e.toString(), + ) + } + return@launch + } + val stale = + synchronized(access) { + if (epoch != connectionEpoch) { + true + } else { + commandClient = newClient + false + } + } + if (stale) { + runCatching { + newClient.disconnect() } - options.addCommand(command) - } - options.statusInterval = 1 * 1000 * 1000 * 1000 - val commandClient = CommandClient(clientHandler, options) - try { - commandClient.connect() - } catch (e: Exception) { - Log.d("CommandClient", "connect failed", e) - return - } - this.commandClient = commandClient - } - - fun disconnect() { - commandClient?.apply { - runCatching { - disconnect() } -// Seq.destroyRef(refnum) } - commandClient = null } - private inner class ClientHandler : CommandClientHandler { + @OptIn(DelicateCoroutinesApi::class) + fun disconnect() { + val client: io.nekohasekai.libbox.CommandClient? + synchronized(access) { + connectionEpoch++ + client = commandClient + commandClient = null + } + if (client != null) { + getAllHandlers().forEach { it.onDisconnected() } + // The owning scope may already be cancelled when this is called from + // ViewModel.onCleared, so the connection is released independently. + GlobalScope.launch(Dispatchers.IO) { + runCatching { + client.disconnect() + } + } + } + } + + private fun isActiveEpoch(epoch: Int): Boolean = synchronized(access) { epoch == connectionEpoch } + + private inner class ClientHandler(private val epoch: Int) : CommandClientHandler { override fun connected() { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.onConnected() } Log.d("CommandClient", "connected") } override fun disconnected(message: String?) { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.onDisconnected() } + if (message != null) { + handler.onConnectionError(ConnectionErrorKind.ConnectionLost, message) + } Log.d("CommandClient", "disconnected: $message") } override fun writeGroups(message: OutboundGroupIterator?) { - if (message == null) { + if (message == null || !isActiveEpoch(epoch)) { return } val groups = mutableListOf() @@ -152,7 +235,7 @@ open class CommandClient( } override fun writeOutbounds(message: OutboundGroupItemIterator?) { - if (message == null) { + if (message == null || !isActiveEpoch(epoch)) { return } val outbounds = mutableListOf() @@ -164,15 +247,17 @@ open class CommandClient( } override fun setDefaultLogLevel(level: Int) { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.setDefaultLogLevel(level) } } override fun clearLogs() { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.clearLogs() } } override fun writeLogs(messageList: LogIterator?) { - if (messageList == null) { + if (messageList == null || !isActiveEpoch(epoch)) { return } val logs = messageList.toList() @@ -180,20 +265,23 @@ open class CommandClient( } override fun writeStatus(message: StatusMessage) { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.updateStatus(message) } } override fun initializeClashMode(modeList: StringIterator, currentMode: String) { + if (!isActiveEpoch(epoch)) return val modes = modeList.toList() getAllHandlers().forEach { it.initializeClashMode(modes, currentMode) } } override fun updateClashMode(newMode: String) { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.updateClashMode(newMode) } } override fun writeConnectionEvents(events: ConnectionEvents?) { - if (events == null) return + if (events == null || !isActiveEpoch(epoch)) return getAllHandlers().forEach { it.writeConnectionEvents(events) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt new file mode 100644 index 0000000..8187f09 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt @@ -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)) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/RemoteControlManager.kt b/app/src/main/java/io/nekohasekai/sfa/utils/RemoteControlManager.kt new file mode 100644 index 0000000..50a623b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/RemoteControlManager.kt @@ -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(null) + val remoteServer = _remoteServer.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected = _isConnected.asStateFlow() + + private val _startedAt = MutableStateFlow(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")) + } +} diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index dc8f310..a7f58fb 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -585,4 +585,20 @@ نیاز به راه‌اندازی مجدد ماژول LSPosed به‌روزرسانی شد. برای اعمال تغییرات دستگاه را راه‌اندازی مجدد کنید. ماژول LSPosed + + + کنترل از راه دور + سرورها + بدون سرور + سرور جدید + ویرایش سرور + دستگاه محلی + مدیریت سرورها… + رمز + اختیاری + در حال اتصال… + قطع اتصال + نشانی سرور نامعتبر است: %1$s، قالب مورد انتظار host:port، http://host:port یا https://host:port است + اتصال به سرور راه دور %1$s ناموفق بود + اتصال با سرور راه دور %1$s قطع شد diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index df62c71..6f5a4fe 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -591,4 +591,20 @@ Требуется перезагрузка Модуль LSPosed обновлён. Перезагрузите устройство, чтобы применить изменения. Модуль LSPosed + + + Удаленное управление + Серверы + Нет серверов + Новый сервер + Изменить сервер + Локальное устройство + Управление серверами… + Секрет + Необязательно + Подключение… + Отключить + Неверный URL сервера: %1$s, ожидается host:port, http://host:port или https://host:port + Не удалось подключиться к удаленному серверу %1$s + Отключено от удаленного сервера %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b943554..b45adbc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -581,4 +581,20 @@ LSPosed 模块待更新 LSPosed 模块待降级 LSPosed 模块未激活 + + + 远程控制 + 服务器 + 无服务器 + 新建服务器 + 编辑服务器 + 本机 + 管理服务器… + 密钥 + 可选 + 连接中… + 断开连接 + 无效的服务器 URL: %1$s, 应为 host:port、http://host:port 或 https://host:port + 无法连接到远程服务器 %1$s + 已断开与远程服务器 %1$s 的连接 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 849e498..e44e574 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -584,4 +584,20 @@ LSPosed 模組待更新 LSPosed 模組待降級 LSPosed 模組未啟用 + + + 遠端控制 + 伺服器 + 無伺服器 + 新建伺服器 + 編輯伺服器 + 本機 + 管理伺服器… + 密鑰 + 可選 + 連接中… + 斷開連接 + 無效的伺服器 URL: %1$s, 應為 host:port、http://host:port 或 https://host:port + 無法連接到遠端伺服器 %1$s + 已斷開與遠端伺服器 %1$s 的連接 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5234f0b..20c5f5f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -598,4 +598,20 @@ Reboot required LSPosed module updated. Reboot to apply changes. LSPosed Module + + + Remote Control + Servers + No servers + New Server + Edit Server + Local Device + Manage Servers… + Secret + Optional + Connecting… + Disconnect + Invalid server URL: %1$s, expected host:port, http://host:port or https://host:port + Failed to connect to remote server %1$s + Disconnected from remote server %1$s