1 Commits

Author SHA1 Message Date
zarazaex69 b2f3415421 feat: fix 2026-05-09 02:07:43 +03:00
4 changed files with 110 additions and 120 deletions
@@ -3,5 +3,6 @@ package xyz.zarazaex.olc.dto
data class ServersCache( data class ServersCache(
val guid: String, val guid: String,
val profile: ProfileItem, val profile: ProfileItem,
val testDelayMillis: Long = 0L val testDelayMillis: Long = 0L,
val isSelected: Boolean = false
) )
@@ -12,7 +12,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import xyz.zarazaex.olc.AppConfig import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.contracts.MainAdapterListener import xyz.zarazaex.olc.contracts.MainAdapterListener
@@ -76,18 +75,21 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
// // Set the distance to trigger sync to 160dp // // Set the distance to trigger sync to 160dp
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt()) // binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index -> // Each fragment subscribes independently to the shared flow and filters its own subId.
if (mainViewModel.subscriptionId == subId) { // No onResume subscription switch needed — the active fragment's subId is always correct.
adapter.setData(mainViewModel.serversCache, index) lifecycleScope.launch {
} mainViewModel.serverListFlow.collect { list ->
// Неактивные фрагменты обновятся через onResume → subscriptionIdChanged if (mainViewModel.subscriptionId == subId) {
adapter.setData(list)
}
}
} }
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Tell ViewModel which tab is active so it can rebuild the correct list.
// This is the only place subscriptionId changes — no more races.
mainViewModel.subscriptionIdChanged(subId) mainViewModel.subscriptionIdChanged(subId)
} }
@@ -218,7 +220,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
*/ */
private fun removeServerSub(guid: String, position: Int) { private fun removeServerSub(guid: String, position: Int) {
mainViewModel.removeServer(guid) mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position) // adapter updates automatically via serverListFlow
} }
/** /**
@@ -230,19 +232,13 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
val selected = MmkvManager.getSelectServer() val selected = MmkvManager.getSelectServer()
if (guid == selected) { if (guid == selected) {
MmkvManager.setSelectServer("") MmkvManager.setSelectServer("")
val position = mainViewModel.getPosition(guid)
adapter.setSelectServer(position, position)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
} else { } else {
MmkvManager.setSelectServer(guid) MmkvManager.setSelectServer(guid)
val fromPosition = mainViewModel.getPosition(selected.orEmpty()) }
val toPosition = mainViewModel.getPosition(guid) // Republish snapshot so DiffUtil picks up the selection change in card background
adapter.setSelectServer(fromPosition, toPosition) mainViewModel.reloadServerList()
if (mainViewModel.isRunning.value == true) { if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray() ownerActivity.restartV2Ray()
}
} }
} }
@@ -309,9 +305,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
return return
} }
// Find the position of the selected server val position = mainViewModel.serverListFlow.value.indexOfFirst { it.guid == selectedGuid }
val serversCache = mainViewModel.serversCache
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
val recyclerView = binding.recyclerView val recyclerView = binding.recyclerView
if (position >= 0) { if (position >= 0) {
@@ -59,55 +59,48 @@ class MainRecyclerAdapter(
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) { fun setData(newData: List<ServersCache>) {
val parsedNewData = newData?.toList() ?: emptyList() val oldData = data
val parsedNewData = newData
if (data.isEmpty() || parsedNewData.isEmpty() || position >= 0) { if (oldData.isEmpty() || parsedNewData.isEmpty()) {
data = parsedNewData.toMutableList() data = parsedNewData.toMutableList()
recomputePingRange() recomputePingRange()
if (position >= 0 && position in data.indices) { notifyDataSetChanged()
notifyItemChanged(position)
} else {
notifyDataSetChanged()
}
return return
} }
val oldData = data
val lm = recyclerView?.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager val lm = recyclerView?.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager
val firstVisible = lm?.findFirstVisibleItemPosition()?.coerceAtLeast(0) ?: 0 val firstVisible = lm?.findFirstVisibleItemPosition()?.coerceAtLeast(0) ?: 0
val isAtTop = firstVisible == 0 && (lm?.findViewByPosition(0)?.top ?: 0) >= 0 val isAtTop = firstVisible == 0 && (lm?.findViewByPosition(0)?.top ?: 0) >= 0
val firstVisibleGuid = if (!isAtTop) oldData.getOrNull(firstVisible)?.guid else null val firstVisibleGuid = if (!isAtTop) oldData.getOrNull(firstVisible)?.guid else null
val diffResult = val diffResult = androidx.recyclerview.widget.DiffUtil.calculateDiff(
androidx.recyclerview.widget.DiffUtil.calculateDiff( object : androidx.recyclerview.widget.DiffUtil.Callback() {
object : androidx.recyclerview.widget.DiffUtil.Callback() { override fun getOldListSize() = oldData.size
override fun getOldListSize() = oldData.size override fun getNewListSize() = parsedNewData.size
override fun getNewListSize() = parsedNewData.size
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean { override fun areItemsTheSame(oldPos: Int, newPos: Int) =
return oldData[oldPos].guid == parsedNewData[newPos].guid oldData[oldPos].guid == parsedNewData[newPos].guid
}
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean { override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
val old = oldData[oldPos] val old = oldData[oldPos]
val new = parsedNewData[newPos] val new = parsedNewData[newPos]
val selectedGuid = MmkvManager.getSelectServer() return old.profile == new.profile &&
return old.profile == new.profile && old.profile.isFavorite == new.profile.isFavorite &&
old.profile.isFavorite == new.profile.isFavorite && old.isSelected == new.isSelected &&
(old.guid == selectedGuid) == (new.guid == selectedGuid) && old.testDelayMillis == new.testDelayMillis
old.testDelayMillis == new.testDelayMillis }
}
override fun getChangePayload(oldPos: Int, newPos: Int): Any? { override fun getChangePayload(oldPos: Int, newPos: Int): Any? {
if (oldData[oldPos].profile.isFavorite != parsedNewData[newPos].profile.isFavorite) { if (oldData[oldPos].profile.isFavorite != parsedNewData[newPos].profile.isFavorite) {
return PAYLOAD_FAVORITE return PAYLOAD_FAVORITE
} }
return super.getChangePayload(oldPos, newPos) return super.getChangePayload(oldPos, newPos)
} }
}, },
true true
) )
data = parsedNewData.toMutableList() data = parsedNewData.toMutableList()
recomputePingRange() recomputePingRange()
@@ -181,8 +174,7 @@ class MainRecyclerAdapter(
(holder.itemMainBinding.tvTestResult.layoutParams as? ViewGroup.MarginLayoutParams)?.marginStart = (holder.itemMainBinding.tvTestResult.layoutParams as? ViewGroup.MarginLayoutParams)?.marginStart =
if (addressText.isEmpty()) 0 else 6.dpToPx(context) if (addressText.isEmpty()) 0 else 6.dpToPx(context)
// Keep regular list items on the page surface; selected state is a quiet surface pill. val isSelected = data[position].isSelected
val isSelected = guid == MmkvManager.getSelectServer()
holder.itemMainBinding.cardContainer.apply { holder.itemMainBinding.cardContainer.apply {
val selectedColor = MaterialColors.getColor( val selectedColor = MaterialColors.getColor(
context, context,
@@ -295,21 +287,6 @@ class MainRecyclerAdapter(
return subRemarks?.toString() ?: "" return subRemarks?.toString() ?: ""
} }
fun removeServerSub(guid: String, position: Int) {
val idx = data.indexOfFirst { it.guid == guid }
if (idx >= 0) {
data.removeAt(idx)
recomputePingRange()
notifyItemRemoved(idx)
notifyItemRangeChanged(idx, data.size - idx)
}
}
fun setSelectServer(fromPosition: Int, toPosition: Int) {
notifyItemChanged(fromPosition)
notifyItemChanged(toPosition)
}
private fun Int.dpToPx(context: android.content.Context): Int { private fun Int.dpToPx(context: android.content.Context): Int {
return (this * context.resources.displayMetrics.density).toInt() return (this * context.resources.displayMetrics.density).toInt()
} }
@@ -360,6 +337,8 @@ class MainRecyclerAdapter(
BaseViewHolder(itemFooterBinding.root) BaseViewHolder(itemFooterBinding.root)
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean { override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
// ViewModel swaps both serverList and _serversCache, then publishSnapshot triggers setData.
// We optimistically swap local data + animate immediately for smooth drag UX.
mainViewModel.swapServer(fromPosition, toPosition) mainViewModel.swapServer(fromPosition, toPosition)
if (fromPosition < data.size && toPosition < data.size) { if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition) Collections.swap(data, fromPosition, toPosition)
@@ -33,6 +33,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.Collections import java.util.Collections
@@ -45,9 +48,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/** ISO codes to EXCLUDE (empty = show all) */ /** ISO codes to EXCLUDE (empty = show all) */
var countryFilter: Set<String> = MmkvManager.getCountryFilter() var countryFilter: Set<String> = MmkvManager.getCountryFilter()
private set private set
val serversCache = mutableListOf<ServersCache>()
// Internal mutable cache — never exposed directly
private val _serversCache = mutableListOf<ServersCache>()
// Read-only snapshot for external consumers that need direct access (e.g. export, ping)
val serversCache: List<ServersCache> get() = _serversCache.toList()
// Single source of truth for the list UI — emits a new immutable snapshot on every change
private val _serverListFlow = MutableStateFlow<List<ServersCache>>(emptyList())
val serverListFlow: StateFlow<List<ServersCache>> = _serverListFlow.asStateFlow()
val isRunning by lazy { MutableLiveData<Boolean>() } val isRunning by lazy { MutableLiveData<Boolean>() }
val updateListAction by lazy { MutableLiveData<Int>() }
val updateTestResultAction by lazy { MutableLiveData<String>() } val updateTestResultAction by lazy { MutableLiveData<String>() }
val liteTestFinished = MutableLiveData<Boolean>() val liteTestFinished = MutableLiveData<Boolean>()
val isTesting by lazy { MutableLiveData<Boolean>().also { it.value = false } } val isTesting by lazy { MutableLiveData<Boolean>().also { it.value = false } }
@@ -123,7 +134,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (!suppressPinSelected) pinSelectedGuidToTop(serverList) if (!suppressPinSelected) pinSelectedGuidToTop(serverList)
updateCache() updateCache()
updateListAction.value = -1
} }
/** /**
@@ -133,10 +143,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun removeServer(guid: String) { fun removeServer(guid: String) {
serverList.remove(guid) serverList.remove(guid)
MmkvManager.removeServer(guid) MmkvManager.removeServer(guid)
val index = getPosition(guid) val index = _serversCache.indexOfFirst { it.guid == guid }
if (index >= 0) { if (index >= 0) {
serversCache.removeAt(index) _serversCache.removeAt(index)
} }
publishSnapshot()
} }
/** /**
@@ -150,17 +161,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
Collections.swap(serverList, fromPosition, toPosition) Collections.swap(serverList, fromPosition, toPosition)
Collections.swap(serversCache, fromPosition, toPosition) Collections.swap(_serversCache, fromPosition, toPosition)
publishSnapshot()
MmkvManager.encodeServerList(serverList, subscriptionId) MmkvManager.encodeServerList(serverList, subscriptionId)
} }
/** /**
* Updates the cache of servers. * Rebuilds _serversCache from serverList and publishes a new snapshot to serverListFlow.
*/ */
@Synchronized @Synchronized
fun updateCache() { fun updateCache() {
serversCache.clear() _serversCache.clear()
val kw = keywordFilter.trim() val kw = keywordFilter.trim()
val searchRegex = try { val searchRegex = try {
if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null
@@ -168,10 +180,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
null null
} }
val activeCountryFilter = countryFilter val activeCountryFilter = countryFilter
val selectedGuid = MmkvManager.getSelectServer().orEmpty()
for (guid in serverList) { for (guid in serverList) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue val profile = MmkvManager.decodeServerConfig(guid) ?: continue
// Country filter — skip servers whose country is in the excluded set
if (activeCountryFilter.isNotEmpty()) { if (activeCountryFilter.isNotEmpty()) {
val code = CountryDetector.getCountryCode(profile.remarks, profile.server) val code = CountryDetector.getCountryCode(profile.remarks, profile.server)
if (code in activeCountryFilter) continue if (code in activeCountryFilter) continue
@@ -180,7 +192,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
if (kw.isEmpty()) { if (kw.isEmpty()) {
serversCache.add(ServersCache(guid, profile, delay)) _serversCache.add(ServersCache(guid, profile, delay, guid == selectedGuid))
continue continue
} }
@@ -193,9 +205,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|| server.matchesPattern(searchRegex, kw) || server.matchesPattern(searchRegex, kw)
|| protocol.matchesPattern(searchRegex, kw) || protocol.matchesPattern(searchRegex, kw)
) { ) {
serversCache.add(ServersCache(guid, profile, delay)) _serversCache.add(ServersCache(guid, profile, delay, guid == selectedGuid))
} }
} }
publishSnapshot()
}
/** Emits an immutable copy of _serversCache to the Flow. Must be called on Main or from @Synchronized blocks. */
private fun publishSnapshot() {
_serverListFlow.value = _serversCache.toList()
} }
/** Builds a snapshot of ServersCache for the given subId without changing global state. */ /** Builds a snapshot of ServersCache for the given subId without changing global state. */
@@ -305,9 +323,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun exportAllServer(): Int { fun exportAllServer(): Int {
val serverListCopy = val serverListCopy =
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) { if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
serverList serverList.toList()
} else { } else {
serversCache.map { it.guid }.toList() _serversCache.map { it.guid }
} }
val ret = AngConfigManager.shareNonCustomConfigsToClipboard( val ret = AngConfigManager.shareNonCustomConfigsToClipboard(
@@ -323,9 +341,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun testAllTcping() { fun testAllTcping() {
tcpingTestScope.coroutineContext[Job]?.cancelChildren() tcpingTestScope.coroutineContext[Job]?.cancelChildren()
SpeedtestManager.closeAllTcpSockets() SpeedtestManager.closeAllTcpSockets()
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList()) MmkvManager.clearAllTestDelayResults(_serversCache.map { it.guid })
val serversCopy = serversCache.toList() val serversCopy = _serversCache.toList()
for (item in serversCopy) { for (item in serversCopy) {
item.profile.let { outbound -> item.profile.let { outbound ->
val serverAddress = outbound.server val serverAddress = outbound.server
@@ -335,7 +353,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val testResult = SpeedtestManager.tcping(serverAddress, serverPort.toInt()) val testResult = SpeedtestManager.tcping(serverAddress, serverPort.toInt())
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
MmkvManager.encodeServerTestDelayMillis(item.guid, testResult) MmkvManager.encodeServerTestDelayMillis(item.guid, testResult)
updateListAction.value = getPosition(item.guid) refreshPingInCache(listOf(item.guid))
} }
} }
} }
@@ -370,18 +388,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (removed > 0) { if (removed > 0) {
reloadServerList() reloadServerList()
} }
// Сбрасываем пинги только если не идёт lite-тест (там пинги уже актуальны)
if (!suppressPinSelected) { if (!suppressPinSelected) {
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList()) MmkvManager.clearAllTestDelayResults(_serversCache.map { it.guid })
} }
updateListAction.value = -1 publishSnapshot()
isTesting.value = true isTesting.value = true
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
if (serversCache.isEmpty()) { if (_serversCache.isEmpty()) {
withContext(Dispatchers.Main) { reloadServerList() } withContext(Dispatchers.Main) { reloadServerList() }
} }
if (serversCache.isEmpty()) { if (_serversCache.isEmpty()) {
withContext(Dispatchers.Main) { isTesting.value = false } withContext(Dispatchers.Main) { isTesting.value = false }
return@launch return@launch
} }
@@ -394,7 +411,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
key = AppConfig.MSG_MEASURE_CONFIG, key = AppConfig.MSG_MEASURE_CONFIG,
subscriptionId = actualSubId, subscriptionId = actualSubId,
serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) { serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) {
serversCache.map { it.guid } _serversCache.map { it.guid }
} else { } else {
emptyList() emptyList()
} }
@@ -506,9 +523,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @return The position of the server. * @return The position of the server.
*/ */
fun getPosition(guid: String): Int { fun getPosition(guid: String): Int {
serversCache.forEachIndexed { index, it -> _serversCache.forEachIndexed { index, it ->
if (it.guid == guid) if (it.guid == guid) return index
return index
} }
return -1 return -1
} }
@@ -518,7 +534,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @return The number of removed servers. * @return The number of removed servers.
*/ */
fun removeDuplicateServer(): Int { fun removeDuplicateServer(): Int {
val serversCacheCopy = serversCache.toList().toMutableList() val serversCacheCopy = _serversCache.toList()
val deleteServer = mutableListOf<String>() val deleteServer = mutableListOf<String>()
serversCacheCopy.forEachIndexed { index, sc -> serversCacheCopy.forEachIndexed { index, sc ->
val profile = sc.profile val profile = sc.profile
@@ -545,9 +561,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
*/ */
fun removeDuplicateByIp(): Int { fun removeDuplicateByIp(): Int {
val selectedGuid = MmkvManager.getSelectServer() val selectedGuid = MmkvManager.getSelectServer()
// Group all currently visible servers by their IP address
val byIp = LinkedHashMap<String, MutableList<ServersCache>>() val byIp = LinkedHashMap<String, MutableList<ServersCache>>()
for (sc in serversCache) { for (sc in _serversCache) {
val ip = sc.profile.server?.trim()?.lowercase() ?: continue val ip = sc.profile.server?.trim()?.lowercase() ?: continue
byIp.getOrPut(ip) { mutableListOf() }.add(sc) byIp.getOrPut(ip) { mutableListOf() }.add(sc)
} }
@@ -644,11 +659,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) { if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
MmkvManager.removeAllServer() MmkvManager.removeAllServer()
} else { } else {
val serversCopy = serversCache.toList() val serversCopy = _serversCache.toList()
for (item in serversCopy) { for (item in serversCopy) {
MmkvManager.removeServer(item.guid) MmkvManager.removeServer(item.guid)
} }
serversCache.toList().count() serversCopy.count()
} }
return count return count
} }
@@ -663,39 +678,40 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
@Synchronized @Synchronized
fun refreshPingInCache(guids: List<String>) { fun refreshPingInCache(guids: List<String>) {
val guidSet = guids.toHashSet() val guidSet = guids.toHashSet()
for (i in serversCache.indices) { for (i in _serversCache.indices) {
val item = serversCache[i] val item = _serversCache[i]
if (item.guid in guidSet) { if (item.guid in guidSet) {
val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L
if (item.testDelayMillis != delay) { if (item.testDelayMillis != delay) {
serversCache[i] = item.copy(testDelayMillis = delay) _serversCache[i] = item.copy(testDelayMillis = delay)
} }
} }
} }
publishSnapshot()
} }
@Synchronized @Synchronized
fun sortServersCacheInPlace() { fun sortServersCacheInPlace() {
// Refresh testDelayMillis from storage so DiffUtil sees the change for (i in _serversCache.indices) {
for (i in serversCache.indices) { val item = _serversCache[i]
val item = serversCache[i]
val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L
if (item.testDelayMillis != delay) { if (item.testDelayMillis != delay) {
serversCache[i] = item.copy(testDelayMillis = delay) _serversCache[i] = item.copy(testDelayMillis = delay)
} }
} }
serversCache.sortWith(compareBy( _serversCache.sortWith(compareBy(
{ !it.profile.isFavorite }, { !it.profile.isFavorite },
{ {
val delay = it.testDelayMillis val delay = it.testDelayMillis
when { when {
delay > 0L -> delay delay > 0L -> delay
delay == 0L -> Long.MAX_VALUE - 1 // untested delay == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE // failed else -> Long.MAX_VALUE
} }
} }
)) ))
if (!suppressPinSelected) pinSelectedCacheItemToTop(serversCache) if (!suppressPinSelected) pinSelectedCacheItemToTop(_serversCache)
publishSnapshot()
} }
fun sortByTestResults() { fun sortByTestResults() {
@@ -852,7 +868,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
reloadServerList() reloadServerList() // rebuilds _serversCache + publishSnapshot
isTesting.value = false isTesting.value = false
liteTestFinished.value = true liteTestFinished.value = true
liteTestFinished.value = false liteTestFinished.value = false
@@ -892,7 +908,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second) MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
refreshPingInCache(listOf(resultPair.first)) refreshPingInCache(listOf(resultPair.first))
if (!suppressPinSelected) sortServersCacheInPlace() if (!suppressPinSelected) sortServersCacheInPlace()
updateListAction.value = -1 // publishSnapshot() already called inside refresh/sort above
} }
AppConfig.MSG_MEASURE_CONFIG_BATCH -> { AppConfig.MSG_MEASURE_CONFIG_BATCH -> {
@@ -902,7 +918,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
refreshPingInCache(update.results.map { it.guid }) refreshPingInCache(update.results.map { it.guid })
if (!suppressPinSelected) sortServersCacheInPlace() if (!suppressPinSelected) sortServersCacheInPlace()
updateListAction.value = -1 // publishSnapshot() already called inside refresh/sort above
} }
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> { AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {