refactor: CommandClient & Connections

This commit is contained in:
世界
2026-01-15 08:16:35 +08:00
parent cd0ae262f1
commit b327532ddb
9 changed files with 255 additions and 172 deletions

View File

@@ -211,6 +211,7 @@ dependencies {
// API 23+ dependencies (play/other)
"playImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion23")
"playImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion23")
"playImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion23")
"playImplementation"("androidx.room:room-runtime:$roomVersion23")
"playImplementation"("androidx.work:work-runtime-ktx:$workVersion23")
"playImplementation"("androidx.camera:camera-view:$cameraVersion23")
@@ -222,6 +223,7 @@ dependencies {
"otherImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion23")
"otherImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion23")
"otherImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion23")
"otherImplementation"("androidx.room:room-runtime:$roomVersion23")
"otherImplementation"("androidx.work:work-runtime-ktx:$workVersion23")
"otherImplementation"("androidx.camera:camera-view:$cameraVersion23")
@@ -233,6 +235,7 @@ dependencies {
// API 21 dependencies (otherLegacy)
"otherLegacyImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion21")
"otherLegacyImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion21")
"otherLegacyImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion21")
"otherLegacyImplementation"("androidx.room:room-runtime:$roomVersion21")
"otherLegacyImplementation"("androidx.work:work-runtime-ktx:$workVersion21")
"otherLegacyImplementation"("androidx.camera:camera-view:$cameraVersion21")

View File

@@ -61,6 +61,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -541,14 +542,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
val connectionsViewModel: ConnectionsViewModel? =
if (isConnectionsRoute) {
viewModel(
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ConnectionsViewModel(dashboardViewModel.commandClient) as T
}
}
)
viewModel()
} else {
null
}
@@ -973,18 +967,21 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
// Connections ModalBottomSheet
if (showConnectionsSheet && !useNavigationRail) {
val connectionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val connectionsViewModel: ConnectionsViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ConnectionsViewModel(dashboardViewModel.commandClient) as T
}
}
)
val connectionsViewModel: ConnectionsViewModel = viewModel()
val connectionsUiState by connectionsViewModel.uiState.collectAsState()
var selectedConnectionId by remember { mutableStateOf<String?>(null) }
val selectedConnection = connectionsUiState.allConnections.find { it.id == selectedConnectionId }
LaunchedEffect(Unit) {
connectionsViewModel.setVisible(true)
}
DisposableEffect(Unit) {
onDispose {
connectionsViewModel.setVisible(false)
}
}
ModalBottomSheet(
onDismissRequest = {
showConnectionsSheet = false

View File

@@ -43,6 +43,7 @@ import androidx.compose.material3.OutlinedTextField
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
@@ -334,6 +335,16 @@ fun ConnectionsScreen(
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.setVisible(true)
}
DisposableEffect(Unit) {
onDispose {
viewModel.setVisible(false)
}
}
LaunchedEffect(serviceStatus) {
viewModel.updateServiceStatus(serviceStatus)
}

View File

@@ -1,6 +1,7 @@
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
@@ -10,14 +11,16 @@ import io.nekohasekai.sfa.ktx.toList
import io.nekohasekai.sfa.compose.model.Connection
import io.nekohasekai.sfa.compose.model.ConnectionSort
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
import io.nekohasekai.sfa.utils.AppLifecycleObserver
import io.nekohasekai.sfa.utils.CommandClient
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
data class ConnectionsUiState(
@@ -35,70 +38,62 @@ sealed class ConnectionsEvent : ScreenEvent {
data object AllConnectionsClosed : ConnectionsEvent()
}
class ConnectionsViewModel(
private val sharedCommandClient: CommandClient? = null,
) : BaseViewModel<ConnectionsUiState, ConnectionsEvent>(), CommandClient.Handler {
private val commandClient: CommandClient
private val isUsingSharedClient: Boolean
class ConnectionsViewModel : BaseViewModel<ConnectionsUiState, ConnectionsEvent>(), CommandClient.Handler {
private val commandClient = CommandClient(
viewModelScope,
CommandClient.ConnectionType.Connections,
this,
)
private val _serviceStatus = MutableStateFlow(Status.Stopped)
val serviceStatus = _serviceStatus.asStateFlow()
private var lastServiceStatus: Status = Status.Stopped
private var connectionJob: Job? = null
private var rawConnections: Connections? = null
private val _isVisible = MutableStateFlow(false)
init {
if (sharedCommandClient != null) {
commandClient = sharedCommandClient
isUsingSharedClient = true
commandClient.addHandler(this)
} else {
commandClient = CommandClient(
viewModelScope,
CommandClient.ConnectionType.Connections,
this,
)
isUsingSharedClient = false
}
}
private var connectionsStore: Connections? = null
private val connectionsMutex = Mutex()
private val connectionsGeneration = AtomicLong(0)
override fun createInitialState() = ConnectionsUiState()
override fun onCleared() {
super.onCleared()
connectionJob?.cancel()
connectionJob = null
if (isUsingSharedClient) {
commandClient.removeHandler(this)
} else {
commandClient.disconnect()
init {
viewModelScope.launch {
combine(
AppLifecycleObserver.isForeground,
_isVisible,
_serviceStatus
) { foreground, visible, status ->
Triple(foreground, visible, status)
}.collect { (foreground, visible, status) ->
val shouldConnect = foreground && visible && status == Status.Started
if (shouldConnect) {
updateState { copy(isLoading = true) }
commandClient.connect()
} else {
commandClient.disconnect()
}
}
}
}
private fun handleServiceStatusChange(status: Status) {
if (status == Status.Started) {
if (!isUsingSharedClient) {
updateState { copy(isLoading = true) }
connectionJob?.cancel()
connectionJob = viewModelScope.launch(Dispatchers.IO) {
while (isActive) {
try {
commandClient.connect()
break
} catch (e: Exception) {
delay(100)
}
}
fun setVisible(visible: Boolean) {
_isVisible.value = visible
}
override fun onCleared() {
super.onCleared()
commandClient.disconnect()
}
private suspend fun handleServiceStatusChange(status: Status) {
if (status != Status.Started) {
withContext(Dispatchers.Default) {
connectionsMutex.withLock {
connectionsStore = null
}
connectionsGeneration.incrementAndGet()
}
} else {
connectionJob?.cancel()
connectionJob = null
if (!isUsingSharedClient) {
commandClient.disconnect()
}
rawConnections = null
updateState {
copy(connections = emptyList(), allConnections = emptyList(), isLoading = false)
}
@@ -116,17 +111,17 @@ class ConnectionsViewModel(
fun setStateFilter(filter: ConnectionStateFilter) {
updateState { copy(stateFilter = filter) }
rawConnections?.let { processConnections(it) }
requestConnectionsRefresh()
}
fun setSort(sort: ConnectionSort) {
updateState { copy(sort = sort) }
rawConnections?.let { processConnections(it) }
requestConnectionsRefresh()
}
fun setSearchText(text: String) {
updateState { copy(searchText = text) }
rawConnections?.let { processConnections(it) }
requestConnectionsRefresh()
}
fun toggleSearch() {
@@ -138,7 +133,7 @@ class ConnectionsViewModel(
)
}
if (!newSearchActive) {
rawConnections?.let { processConnections(it) }
requestConnectionsRefresh()
}
}
@@ -175,50 +170,102 @@ class ConnectionsViewModel(
}
override fun onDisconnected() {
viewModelScope.launch(Dispatchers.Main) {
rawConnections = null
updateState {
copy(connections = emptyList(), allConnections = emptyList(), isLoading = false)
viewModelScope.launch(Dispatchers.Default) {
connectionsMutex.withLock {
connectionsStore = null
}
connectionsGeneration.incrementAndGet()
withContext(Dispatchers.Main) {
updateState {
copy(connections = emptyList(), allConnections = emptyList(), isLoading = false)
}
}
}
}
override fun updateConnections(connections: Connections) {
rawConnections = connections
processConnections(connections)
}
private fun processConnections(connections: Connections) {
connectionJob?.cancel()
connectionJob = viewModelScope.launch(Dispatchers.Default) {
val currentState = uiState.value
val allConnectionList = connections.iterator().toList()
.filter { it.outboundType != "dns" }
.map { Connection.from(it) }
connections.filterState(currentState.stateFilter.libboxValue)
when (currentState.sort) {
ConnectionSort.ByDate -> connections.sortByDate()
ConnectionSort.ByTraffic -> connections.sortByTraffic()
ConnectionSort.ByTrafficTotal -> connections.sortByTrafficTotal()
override fun writeConnectionEvents(events: ConnectionEvents) {
viewModelScope.launch(Dispatchers.Default) {
val generation = connectionsGeneration.get()
val snapshot = connectionsMutex.withLock {
if (connectionsStore == null) {
connectionsStore = Libbox.newConnections()
}
val store = connectionsStore ?: return@withLock null
store.applyEvents(events)
buildConnectionLists(store, uiState.value)
} ?: return@launch
if (connectionsGeneration.get() != generation) {
return@launch
}
val connectionList = connections.iterator().toList()
.filter { it.outboundType != "dns" }
.map { Connection.from(it) }
.filter { it.performSearch(currentState.searchText) }
withContext(Dispatchers.Main) {
if (connectionsGeneration.get() != generation) {
return@withContext
}
updateState {
copy(
connections = connectionList,
allConnections = allConnectionList,
connections = snapshot.connections,
allConnections = snapshot.allConnections,
isLoading = false,
)
}
}
}
}
private fun requestConnectionsRefresh() {
viewModelScope.launch(Dispatchers.Default) {
val generation = connectionsGeneration.get()
val snapshot = connectionsMutex.withLock {
val store = connectionsStore ?: return@withLock null
buildConnectionLists(store, uiState.value)
} ?: return@launch
if (connectionsGeneration.get() != generation) {
return@launch
}
withContext(Dispatchers.Main) {
if (connectionsGeneration.get() != generation) {
return@withContext
}
updateState {
copy(
connections = snapshot.connections,
allConnections = snapshot.allConnections,
isLoading = false,
)
}
}
}
}
private fun buildConnectionLists(
connections: Connections,
currentState: ConnectionsUiState,
): ConnectionLists {
val allConnectionList = connections.iterator().toList()
.filter { it.outboundType != "dns" }
.map { Connection.from(it) }
connections.filterState(currentState.stateFilter.libboxValue)
when (currentState.sort) {
ConnectionSort.ByDate -> connections.sortByDate()
ConnectionSort.ByTraffic -> connections.sortByTraffic()
ConnectionSort.ByTrafficTotal -> connections.sortByTrafficTotal()
}
val connectionList = connections.iterator().toList()
.filter { it.outboundType != "dns" }
.map { Connection.from(it) }
.filter { it.performSearch(currentState.searchText) }
return ConnectionLists(
connections = connectionList,
allConnections = allConnectionList,
)
}
private data class ConnectionLists(
val connections: List<Connection>,
val allConnections: List<Connection>,
)
}

View File

@@ -1,11 +1,9 @@
package io.nekohasekai.sfa.compose.screen.dashboard
import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.Connections
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.sfa.ktx.toList
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.compose.base.BaseViewModel
import io.nekohasekai.sfa.compose.base.UiEvent
@@ -14,6 +12,7 @@ import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
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.HTTPClient
import kotlinx.coroutines.Dispatchers
@@ -135,7 +134,6 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
CommandClient.ConnectionType.Status,
CommandClient.ConnectionType.ClashMode,
CommandClient.ConnectionType.Groups,
CommandClient.ConnectionType.Connections,
),
this,
)
@@ -157,6 +155,17 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
init {
loadProfiles()
ProfileManager.registerCallback(::onProfilesChanged)
viewModelScope.launch {
AppLifecycleObserver.isForeground.collect { foreground ->
if (_serviceStatus.value != Status.Started) return@collect
if (foreground) {
commandClient.connect()
} else {
commandClient.disconnect()
}
}
}
}
override fun onCleared() {
@@ -447,7 +456,9 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
when (status) {
Status.Started -> {
checkDeprecatedNotes()
commandClient.connect()
if (AppLifecycleObserver.isForeground.value) {
commandClient.connect()
}
reloadSystemProxyStatus()
reloadStartedAt()
}
@@ -458,6 +469,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
copy(
hasGroups = false,
groupsCount = 0,
connectionsCount = 0,
serviceStartTime = null,
clashModeVisible = false,
systemProxyVisible = false,
@@ -587,6 +599,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
goroutines = status.goroutines.toString(),
// Only set trafficVisible to true, never back to false from status updates
trafficVisible = if (status.trafficAvailable) true else trafficVisible,
connectionsCount = status.connectionsIn,
connectionsIn = status.connectionsIn.toString(),
connectionsOut = status.connectionsOut.toString(),
uplink = "${Libbox.formatBytes(status.uplink)}/s",
@@ -633,13 +646,6 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
}
}
override fun updateConnections(connections: Connections) {
viewModelScope.launch(Dispatchers.Main) {
val count = connections.iterator().toList().count { it.outboundType != "dns" }
updateState { copy(connectionsCount = count) }
}
}
fun toggleCardSettingsDialog() {
updateState {
copy(showCardSettingsDialog = !showCardSettingsDialog)

View File

@@ -9,13 +9,11 @@ import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.model.Group
import io.nekohasekai.sfa.compose.model.GroupItem
import io.nekohasekai.sfa.compose.model.toList
import io.nekohasekai.sfa.utils.AppLifecycleObserver
import io.nekohasekai.sfa.utils.CommandClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -39,7 +37,6 @@ class GroupsViewModel(
private val _serviceStatus = MutableStateFlow(Status.Stopped)
val serviceStatus = _serviceStatus.asStateFlow()
private var lastServiceStatus: Status = Status.Stopped
private var connectionJob: Job? = null
init {
if (sharedCommandClient != null) {
@@ -55,14 +52,32 @@ class GroupsViewModel(
)
isUsingSharedClient = false
}
viewModelScope.launch {
AppLifecycleObserver.isForeground.collect { foreground ->
if (lastServiceStatus != Status.Started) return@collect
if (foreground) {
if (isUsingSharedClient) {
commandClient.addHandler(this@GroupsViewModel)
} else {
updateState { copy(isLoading = true) }
commandClient.connect()
}
} else {
if (isUsingSharedClient) {
commandClient.removeHandler(this@GroupsViewModel)
} else {
commandClient.disconnect()
}
}
}
}
}
override fun createInitialState() = GroupsUiState()
override fun onCleared() {
super.onCleared()
connectionJob?.cancel()
connectionJob = null
if (isUsingSharedClient) {
commandClient.removeHandler(this)
} else {
@@ -72,25 +87,11 @@ class GroupsViewModel(
private fun handleServiceStatusChange(status: Status) {
if (status == Status.Started) {
if (!isUsingSharedClient) {
updateState {
copy(isLoading = true)
}
connectionJob?.cancel()
connectionJob = viewModelScope.launch(Dispatchers.IO) {
while (isActive) {
try {
commandClient.connect()
break
} catch (e: Exception) {
delay(100)
}
}
}
if (!isUsingSharedClient && AppLifecycleObserver.isForeground.value) {
updateState { copy(isLoading = true) }
commandClient.connect()
}
} else {
connectionJob?.cancel()
connectionJob = null
if (!isUsingSharedClient) {
commandClient.disconnect()
}
@@ -243,8 +244,6 @@ class GroupsViewModel(
}
override fun updateGroups(newGroups: MutableList<OutboundGroup>) {
connectionJob?.cancel()
connectionJob = null
viewModelScope.launch(Dispatchers.Default) {
val currentGroups = uiState.value.groups
val newGroupsMap = newGroups.associateBy { it.tag }

View File

@@ -5,6 +5,7 @@ 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
@@ -24,6 +25,20 @@ class LogViewModel : BaseLogViewModel(), CommandClient.Handler {
connectionType = CommandClient.ConnectionType.Log,
handler = this,
)
private var lastServiceStatus: Status = Status.Stopped
init {
viewModelScope.launch {
AppLifecycleObserver.isForeground.collect { foreground ->
if (lastServiceStatus != Status.Started) return@collect
if (foreground) {
commandClient.connect()
} else {
commandClient.disconnect()
}
}
}
}
private fun processLogEntry(entry: LogEntry): ProcessedLogEntry {
val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default
@@ -35,11 +50,14 @@ class LogViewModel : BaseLogViewModel(), CommandClient.Handler {
}
override fun updateServiceStatus(status: Status) {
lastServiceStatus = status
_uiState.update { it.copy(serviceStatus = status) }
when (status) {
Status.Started -> {
commandClient.connect()
if (AppLifecycleObserver.isForeground.value) {
commandClient.connect()
}
}
Status.Stopped, Status.Stopping -> {

View File

@@ -0,0 +1,25 @@
package io.nekohasekai.sfa.utils
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object AppLifecycleObserver : DefaultLifecycleObserver {
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
fun register() {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
override fun onStart(owner: LifecycleOwner) {
_isForeground.value = true
}
override fun onStop(owner: LifecycleOwner) {
_isForeground.value = false
}
}

View File

@@ -5,7 +5,7 @@ import go.Seq
import io.nekohasekai.libbox.CommandClient
import io.nekohasekai.libbox.CommandClientHandler
import io.nekohasekai.libbox.CommandClientOptions
import io.nekohasekai.libbox.Connections
import io.nekohasekai.libbox.ConnectionEvents
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.LogEntry
import io.nekohasekai.libbox.LogIterator
@@ -15,10 +15,6 @@ import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.libbox.StringIterator
import io.nekohasekai.sfa.ktx.toList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
open class CommandClient(
private val scope: CoroutineScope,
@@ -87,7 +83,7 @@ open class CommandClient(
fun updateClashMode(newMode: String) {}
fun updateConnections(connections: Connections) {}
fun writeConnectionEvents(events: ConnectionEvents) {}
}
private var commandClient: CommandClient? = null
@@ -109,27 +105,8 @@ open class CommandClient(
}
options.statusInterval = 1 * 1000 * 1000 * 1000
val commandClient = CommandClient(clientHandler, options)
scope.launch(Dispatchers.IO) {
for (i in 1..10) {
delay(100 + i.toLong() * 50)
try {
commandClient.connect()
} catch (ignored: Exception) {
continue
}
if (!isActive) {
runCatching {
commandClient.disconnect()
}
return@launch
}
this@CommandClient.commandClient = commandClient
return@launch
}
runCatching {
commandClient.disconnect()
}
}
commandClient.connect()
this.commandClient = commandClient
}
fun disconnect() {
@@ -197,9 +174,9 @@ open class CommandClient(
getAllHandlers().forEach { it.updateClashMode(newMode) }
}
override fun writeConnections(message: Connections?) {
if (message == null) return
getAllHandlers().forEach { it.updateConnections(message) }
override fun writeConnectionEvents(events: ConnectionEvents?) {
if (events == null) return
getAllHandlers().forEach { it.writeConnectionEvents(events) }
}
}
}