package io.nekohasekai.sagernet.ui.compose
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Construction
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Directions
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Transform
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FabPosition
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.draw.blur
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.aidl.TrafficStats
import io.nekohasekai.sagernet.bg.BaseService
import io.nekohasekai.sagernet.bg.SagerConnection
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
import io.nekohasekai.sagernet.ui.compose.components.ServiceButton
import io.nekohasekai.sagernet.ui.compose.components.ServiceState
import io.nekohasekai.sagernet.ui.compose.components.StatsBar
import io.nekohasekai.sagernet.ui.compose.screens.AboutScreen
import io.nekohasekai.sagernet.ui.compose.screens.ConfigurationScreen
import io.nekohasekai.sagernet.ui.compose.screens.GroupScreen
import io.nekohasekai.sagernet.ui.compose.screens.LogcatScreen
import io.nekohasekai.sagernet.ui.compose.screens.RouteScreen
import io.nekohasekai.sagernet.ui.compose.screens.SettingsScreen
import io.nekohasekai.sagernet.ui.compose.screens.ToolsScreen
import io.nekohasekai.sagernet.ui.compose.screens.TrafficScreen
import kotlinx.coroutines.launch
enum class NavDestination(
val route: String,
val labelRes: Int,
val icon: ImageVector,
) {
CONFIGURATION("configuration", R.string.menu_configuration, Icons.Filled.Description),
GROUP("group", R.string.menu_group, Icons.Filled.List),
ROUTE("route", R.string.menu_route, Icons.Filled.Directions),
SETTINGS("settings", R.string.settings, Icons.Filled.Settings),
LOGCAT("logcat", R.string.menu_log, Icons.Filled.BugReport),
TRAFFIC("traffic", R.string.menu_traffic, Icons.Filled.Transform),
TOOLS("tools", R.string.menu_tools, Icons.Filled.Construction),
}
class ComposeMainActivity : ComponentActivity(), SagerConnection.Callback {
private val connection = SagerConnection()
private var serviceState = mutableStateOf(BaseService.State.Idle)
private var serviceMessage = mutableStateOf
(null)
private var uplinkSpeed = mutableStateOf("")
private var downlinkSpeed = mutableStateOf("")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
connection.connect(this, this)
setContent {
var themeId by remember { mutableIntStateOf(DataStore.appTheme) }
var nightTheme by remember { mutableIntStateOf(DataStore.nightTheme) }
val scrim = remember { io.nekohasekai.sagernet.ui.compose.components.ScrimController() }
val blurRadius by androidx.compose.animation.core.animateDpAsState(
targetValue = scrim.blurRadiusDp.dp,
animationSpec = androidx.compose.animation.core.tween(220),
label = "rootBlur",
)
OwenclaveTheme(themeId = themeId, nightTheme = nightTheme) {
androidx.compose.runtime.CompositionLocalProvider(
io.nekohasekai.sagernet.ui.compose.components.LocalScrimController provides scrim,
) {
Surface(
modifier = Modifier
.fillMaxSize()
.blur(blurRadius),
color = MaterialTheme.colorScheme.surface,
) {
MainScreen(
serviceState = serviceState.value,
onServiceToggle = { toggleService() },
uplinkSpeed = uplinkSpeed.value,
downlinkSpeed = downlinkSpeed.value,
onThemeChanged = { newId -> themeId = newId },
onNightThemeChanged = { newNight -> nightTheme = newNight },
)
}
}
}
}
if (intent?.action == Intent.ACTION_VIEW) {
handleViewIntent(intent)
}
}
private fun handleViewIntent(intent: Intent) {
val uri = intent.data ?: return
val link = uri.toString()
// Run on a background thread — runBlocking here would freeze the UI
// and cause ANR crashes.
io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher {
try {
val beans = io.nekohasekai.sagernet.ktx.parseShareLinks(link)
if (beans.isNotEmpty()) {
val groupId = io.nekohasekai.sagernet.database.DataStore.selectedGroupForImport()
io.nekohasekai.sagernet.database.ProfileManager.createProfile(groupId, beans[0])
}
} catch (_: Exception) {
}
}
}
private fun toggleService() {
val state = serviceState.value
when {
state == BaseService.State.Idle || state == BaseService.State.Stopped ->
SagerNet.startService()
state == BaseService.State.Connected ->
SagerNet.stopService()
}
}
override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {
serviceState.value = state
serviceMessage.value = msg
}
override fun trafficUpdated(profileId: Long, stats: TrafficStats, isCurrent: Boolean) {
uplinkSpeed.value = formatSpeed(stats.txRateProxy + stats.txRateDirect)
downlinkSpeed.value = formatSpeed(stats.rxRateProxy + stats.rxRateDirect)
}
override fun statsUpdated(stats: List) {
}
override fun onServiceConnected(service: io.nekohasekai.sagernet.aidl.ISagerNetService) {
}
override fun onServiceDisconnected() {
}
override fun onBinderDied() {
}
override fun routeAlert(type: Int, routeName: String) {
}
override fun missingPlugin(profileName: String, pluginName: String) {
}
override fun onDestroy() {
connection.disconnect(this)
super.onDestroy()
}
private fun formatSpeed(bytesPerSecond: Long): String {
return when {
bytesPerSecond < 1024 -> "$bytesPerSecond B/s"
bytesPerSecond < 1024 * 1024 -> "%.1f KB/s".format(bytesPerSecond / 1024.0)
bytesPerSecond < 1024 * 1024 * 1024 -> "%.1f MB/s".format(bytesPerSecond / (1024.0 * 1024))
else -> "%.2f GB/s".format(bytesPerSecond / (1024.0 * 1024 * 1024))
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MainScreen(
serviceState: BaseService.State,
onServiceToggle: () -> Unit,
uplinkSpeed: String,
downlinkSpeed: String,
onThemeChanged: (Int) -> Unit = {},
onNightThemeChanged: (Int) -> Unit = {},
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var currentDestination by remember { mutableStateOf(NavDestination.CONFIGURATION) }
val snackbarHostState = remember { SnackbarHostState() }
// Hoisted here so the bottom StatsBar can morph to show batch-test progress.
var batchTestProgress by remember { mutableStateOf?>(null) }
val stateForButton = when (serviceState) {
BaseService.State.Idle -> ServiceState.IDLE
BaseService.State.Connecting -> ServiceState.CONNECTING
BaseService.State.Connected -> ServiceState.CONNECTED
BaseService.State.Stopping -> ServiceState.CONNECTING
BaseService.State.Stopped -> ServiceState.IDLE
}
val statusText = when (serviceState) {
BaseService.State.Idle -> "Idle"
BaseService.State.Connecting -> "Connecting..."
BaseService.State.Connected -> "Connected"
BaseService.State.Stopping -> "Stopping..."
BaseService.State.Stopped -> "Stopped"
}
val EmphasizedEasing = CubicBezierEasing(0.2f, 0f, 0f, 1f)
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
DrawerContent(
currentDestination = currentDestination,
onDestinationSelected = { destination ->
currentDestination = destination
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
floatingActionButton = {
val transition = serviceState == BaseService.State.Connecting ||
serviceState == BaseService.State.Stopping
ServiceButton(
state = stateForButton,
onClick = { if (!transition) onServiceToggle() },
modifier = if (transition) Modifier.graphicsLayer { alpha = 0.7f } else Modifier,
)
},
floatingActionButtonPosition = FabPosition.End,
snackbarHost = { SnackbarHost(snackbarHostState) },
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0),
) { paddingValues ->
// StatsBar is drawn as an OVERLAY (not a bottomBar) so the screen
// content fills the whole area and truly shows through the bar's
// rounded top corners — no reserved grey rectangle behind it.
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
AnimatedContent(
targetState = currentDestination,
transitionSpec = {
(fadeIn(tween(380, easing = EmphasizedEasing)) +
slideInHorizontally(tween(380, easing = EmphasizedEasing)) { it / 4 }) togetherWith
(fadeOut(tween(380, easing = EmphasizedEasing)) +
slideOutHorizontally(tween(380, easing = EmphasizedEasing)) { -it / 4 })
},
label = "screenTransition",
) { destination ->
when (destination) {
NavDestination.CONFIGURATION -> ConfigurationScreen(
onMenuClick = { scope.launch { drawerState.open() } },
serviceRunning = serviceState.canStop,
serviceConnected = serviceState == BaseService.State.Connected,
batchTestProgress = batchTestProgress,
onBatchTestProgress = { batchTestProgress = it },
)
NavDestination.GROUP -> GroupScreen(
onMenuClick = { scope.launch { drawerState.open() } },
)
NavDestination.ROUTE -> RouteScreen(
onMenuClick = { scope.launch { drawerState.open() } },
)
NavDestination.SETTINGS -> SettingsScreen(
onMenuClick = { scope.launch { drawerState.open() } },
onThemeChanged = onThemeChanged,
onNightThemeChanged = onNightThemeChanged,
)
NavDestination.LOGCAT -> LogcatScreen(
onMenuClick = { scope.launch { drawerState.open() } },
)
NavDestination.TRAFFIC -> TrafficScreen(
onMenuClick = { scope.launch { drawerState.open() } },
)
NavDestination.TOOLS -> ToolsScreen(
onMenuClick = { scope.launch { drawerState.open() } },
)
}
}
StatsBar(
statusText = statusText,
uplinkSpeed = uplinkSpeed,
downlinkSpeed = downlinkSpeed,
connected = serviceState == BaseService.State.Connected,
testProgress = batchTestProgress,
modifier = Modifier.align(androidx.compose.ui.Alignment.BottomCenter),
)
}
}
}
}
@Composable
private fun DrawerContent(
currentDestination: NavDestination,
onDestinationSelected: (NavDestination) -> Unit,
) {
ModalDrawerSheet(
drawerContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
) {
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
modifier = Modifier.padding(start = 24.dp, top = 24.dp, bottom = 20.dp),
) {
io.nekohasekai.sagernet.ui.compose.components.AppLogo(
style = io.nekohasekai.sagernet.ui.compose.components.AppLogoStyle.fromId(
DataStore.appLogo
),
size = 40.dp,
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(start = 12.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
)
}
val groups = listOf(
NavDestination.CONFIGURATION,
NavDestination.GROUP,
NavDestination.ROUTE,
NavDestination.SETTINGS,
)
val misc = listOf(
NavDestination.LOGCAT,
NavDestination.TRAFFIC,
NavDestination.TOOLS,
)
groups.forEach { destination ->
DrawerItem(
destination = destination,
selected = currentDestination == destination,
onClick = { onDestinationSelected(destination) },
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 28.dp),
)
misc.forEach { destination ->
DrawerItem(
destination = destination,
selected = currentDestination == destination,
onClick = { onDestinationSelected(destination) },
)
}
}
}
@Composable
private fun DrawerItem(
destination: NavDestination,
selected: Boolean,
onClick: () -> Unit,
) {
NavigationDrawerItem(
label = {
Text(
stringResource(destination.labelRes),
style = MaterialTheme.typography.labelLarge,
)
},
icon = {
io.nekohasekai.sagernet.ui.compose.components.ShapedIcon(
icon = destination.icon,
containerColor = if (selected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (selected)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSecondaryContainer,
size = 40.dp,
pressed = selected,
)
},
selected = selected,
onClick = onClick,
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
unselectedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 3.dp),
)
}