mirror of
https://github.com/openlibrecommunity/olcng.git
synced 2026-07-03 14:05:17 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2f3415421 |
@@ -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.
|
||||||
|
// No onResume subscription switch needed — the active fragment's subId is always correct.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
mainViewModel.serverListFlow.collect { list ->
|
||||||
if (mainViewModel.subscriptionId == subId) {
|
if (mainViewModel.subscriptionId == subId) {
|
||||||
adapter.setData(mainViewModel.serversCache, index)
|
adapter.setData(list)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Неактивные фрагменты обновятся через onResume → subscriptionIdChanged
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,21 +232,15 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private inner class ActivityAdapterListener : MainAdapterListener {
|
private inner class ActivityAdapterListener : MainAdapterListener {
|
||||||
override fun onEdit(guid: String, position: Int) {
|
override fun onEdit(guid: String, position: Int) {
|
||||||
@@ -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,43 +59,36 @@ 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) {
|
|
||||||
notifyItemChanged(position)
|
|
||||||
} else {
|
|
||||||
notifyDataSetChanged()
|
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.guid == selectedGuid) == (new.guid == selectedGuid) &&
|
old.isSelected == new.isSelected &&
|
||||||
old.testDelayMillis == new.testDelayMillis
|
old.testDelayMillis == new.testDelayMillis
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 -> {
|
||||||
|
|||||||
Reference in New Issue
Block a user