2 Commits

Author SHA1 Message Date
zarazaex69 4fa5dd4bca fix: copy buton not display 2026-05-10 13:52:03 +03:00
zarazaex69 b2f3415421 feat: fix 2026-05-09 02:07:43 +03:00
5 changed files with 212 additions and 212 deletions
@@ -3,5 +3,6 @@ package xyz.zarazaex.olc.dto
data class ServersCache(
val guid: String,
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.ItemTouchHelper
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.contracts.MainAdapterListener
@@ -76,18 +75,21 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
// // Set the distance to trigger sync to 160dp
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId == subId) {
adapter.setData(mainViewModel.serversCache, index)
}
// Неактивные фрагменты обновятся через onResume → subscriptionIdChanged
// 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) {
adapter.setData(list)
}
}
}
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
}
override fun 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)
}
@@ -218,7 +220,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
*/
private fun removeServerSub(guid: String, position: Int) {
mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position)
// adapter updates automatically via serverListFlow
}
/**
@@ -230,19 +232,13 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
val selected = MmkvManager.getSelectServer()
if (guid == selected) {
MmkvManager.setSelectServer("")
val position = mainViewModel.getPosition(guid)
adapter.setSelectServer(position, position)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
} else {
MmkvManager.setSelectServer(guid)
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
val toPosition = mainViewModel.getPosition(guid)
adapter.setSelectServer(fromPosition, toPosition)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
// Republish snapshot so DiffUtil picks up the selection change in card background
mainViewModel.reloadServerList()
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
@@ -309,9 +305,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
return
}
// Find the position of the selected server
val serversCache = mainViewModel.serversCache
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
val position = mainViewModel.serverListFlow.value.indexOfFirst { it.guid == selectedGuid }
val recyclerView = binding.recyclerView
if (position >= 0) {
@@ -59,55 +59,48 @@ class MainRecyclerAdapter(
}
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
val parsedNewData = newData?.toList() ?: emptyList()
fun setData(newData: List<ServersCache>) {
val oldData = data
val parsedNewData = newData
if (data.isEmpty() || parsedNewData.isEmpty() || position >= 0) {
if (oldData.isEmpty() || parsedNewData.isEmpty()) {
data = parsedNewData.toMutableList()
recomputePingRange()
if (position >= 0 && position in data.indices) {
notifyItemChanged(position)
} else {
notifyDataSetChanged()
}
notifyDataSetChanged()
return
}
val oldData = data
val lm = recyclerView?.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager
val firstVisible = lm?.findFirstVisibleItemPosition()?.coerceAtLeast(0) ?: 0
val isAtTop = firstVisible == 0 && (lm?.findViewByPosition(0)?.top ?: 0) >= 0
val firstVisibleGuid = if (!isAtTop) oldData.getOrNull(firstVisible)?.guid else null
val diffResult =
androidx.recyclerview.widget.DiffUtil.calculateDiff(
object : androidx.recyclerview.widget.DiffUtil.Callback() {
override fun getOldListSize() = oldData.size
override fun getNewListSize() = parsedNewData.size
val diffResult = androidx.recyclerview.widget.DiffUtil.calculateDiff(
object : androidx.recyclerview.widget.DiffUtil.Callback() {
override fun getOldListSize() = oldData.size
override fun getNewListSize() = parsedNewData.size
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
return oldData[oldPos].guid == parsedNewData[newPos].guid
}
override fun areItemsTheSame(oldPos: Int, newPos: Int) =
oldData[oldPos].guid == parsedNewData[newPos].guid
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
val old = oldData[oldPos]
val new = parsedNewData[newPos]
val selectedGuid = MmkvManager.getSelectServer()
return old.profile == new.profile &&
old.profile.isFavorite == new.profile.isFavorite &&
(old.guid == selectedGuid) == (new.guid == selectedGuid) &&
old.testDelayMillis == new.testDelayMillis
}
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
val old = oldData[oldPos]
val new = parsedNewData[newPos]
return old.profile == new.profile &&
old.profile.isFavorite == new.profile.isFavorite &&
old.isSelected == new.isSelected &&
old.testDelayMillis == new.testDelayMillis
}
override fun getChangePayload(oldPos: Int, newPos: Int): Any? {
if (oldData[oldPos].profile.isFavorite != parsedNewData[newPos].profile.isFavorite) {
return PAYLOAD_FAVORITE
}
return super.getChangePayload(oldPos, newPos)
}
},
true
)
override fun getChangePayload(oldPos: Int, newPos: Int): Any? {
if (oldData[oldPos].profile.isFavorite != parsedNewData[newPos].profile.isFavorite) {
return PAYLOAD_FAVORITE
}
return super.getChangePayload(oldPos, newPos)
}
},
true
)
data = parsedNewData.toMutableList()
recomputePingRange()
@@ -181,8 +174,7 @@ class MainRecyclerAdapter(
(holder.itemMainBinding.tvTestResult.layoutParams as? ViewGroup.MarginLayoutParams)?.marginStart =
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 = guid == MmkvManager.getSelectServer()
val isSelected = data[position].isSelected
holder.itemMainBinding.cardContainer.apply {
val selectedColor = MaterialColors.getColor(
context,
@@ -295,21 +287,6 @@ class MainRecyclerAdapter(
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 {
return (this * context.resources.displayMetrics.density).toInt()
}
@@ -360,6 +337,8 @@ class MainRecyclerAdapter(
BaseViewHolder(itemFooterBinding.root)
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)
if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition)
@@ -33,6 +33,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.withContext
import java.util.Collections
@@ -45,9 +48,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/** ISO codes to EXCLUDE (empty = show all) */
var countryFilter: Set<String> = MmkvManager.getCountryFilter()
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 updateListAction by lazy { MutableLiveData<Int>() }
val updateTestResultAction by lazy { MutableLiveData<String>() }
val liteTestFinished = MutableLiveData<Boolean>()
val isTesting by lazy { MutableLiveData<Boolean>().also { it.value = false } }
@@ -123,7 +134,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (!suppressPinSelected) pinSelectedGuidToTop(serverList)
updateCache()
updateListAction.value = -1
}
/**
@@ -133,10 +143,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun removeServer(guid: String) {
serverList.remove(guid)
MmkvManager.removeServer(guid)
val index = getPosition(guid)
val index = _serversCache.indexOfFirst { it.guid == guid }
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(serversCache, fromPosition, toPosition)
Collections.swap(_serversCache, fromPosition, toPosition)
publishSnapshot()
MmkvManager.encodeServerList(serverList, subscriptionId)
}
/**
* Updates the cache of servers.
* Rebuilds _serversCache from serverList and publishes a new snapshot to serverListFlow.
*/
@Synchronized
fun updateCache() {
serversCache.clear()
_serversCache.clear()
val kw = keywordFilter.trim()
val searchRegex = try {
if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null
@@ -168,10 +180,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
null
}
val activeCountryFilter = countryFilter
val selectedGuid = MmkvManager.getSelectServer().orEmpty()
for (guid in serverList) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
// Country filter — skip servers whose country is in the excluded set
if (activeCountryFilter.isNotEmpty()) {
val code = CountryDetector.getCountryCode(profile.remarks, profile.server)
if (code in activeCountryFilter) continue
@@ -180,7 +192,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
if (kw.isEmpty()) {
serversCache.add(ServersCache(guid, profile, delay))
_serversCache.add(ServersCache(guid, profile, delay, guid == selectedGuid))
continue
}
@@ -193,9 +205,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|| server.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. */
@@ -305,9 +323,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun exportAllServer(): Int {
val serverListCopy =
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
serverList
serverList.toList()
} else {
serversCache.map { it.guid }.toList()
_serversCache.map { it.guid }
}
val ret = AngConfigManager.shareNonCustomConfigsToClipboard(
@@ -323,9 +341,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun testAllTcping() {
tcpingTestScope.coroutineContext[Job]?.cancelChildren()
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) {
item.profile.let { outbound ->
val serverAddress = outbound.server
@@ -335,7 +353,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val testResult = SpeedtestManager.tcping(serverAddress, serverPort.toInt())
launch(Dispatchers.Main) {
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) {
reloadServerList()
}
// Сбрасываем пинги только если не идёт lite-тест (там пинги уже актуальны)
if (!suppressPinSelected) {
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
MmkvManager.clearAllTestDelayResults(_serversCache.map { it.guid })
}
updateListAction.value = -1
publishSnapshot()
isTesting.value = true
viewModelScope.launch(Dispatchers.Default) {
if (serversCache.isEmpty()) {
if (_serversCache.isEmpty()) {
withContext(Dispatchers.Main) { reloadServerList() }
}
if (serversCache.isEmpty()) {
if (_serversCache.isEmpty()) {
withContext(Dispatchers.Main) { isTesting.value = false }
return@launch
}
@@ -394,7 +411,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
key = AppConfig.MSG_MEASURE_CONFIG,
subscriptionId = actualSubId,
serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) {
serversCache.map { it.guid }
_serversCache.map { it.guid }
} else {
emptyList()
}
@@ -506,9 +523,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @return The position of the server.
*/
fun getPosition(guid: String): Int {
serversCache.forEachIndexed { index, it ->
if (it.guid == guid)
return index
_serversCache.forEachIndexed { index, it ->
if (it.guid == guid) return index
}
return -1
}
@@ -518,7 +534,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @return The number of removed servers.
*/
fun removeDuplicateServer(): Int {
val serversCacheCopy = serversCache.toList().toMutableList()
val serversCacheCopy = _serversCache.toList()
val deleteServer = mutableListOf<String>()
serversCacheCopy.forEachIndexed { index, sc ->
val profile = sc.profile
@@ -545,9 +561,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
*/
fun removeDuplicateByIp(): Int {
val selectedGuid = MmkvManager.getSelectServer()
// Group all currently visible servers by their IP address
val byIp = LinkedHashMap<String, MutableList<ServersCache>>()
for (sc in serversCache) {
for (sc in _serversCache) {
val ip = sc.profile.server?.trim()?.lowercase() ?: continue
byIp.getOrPut(ip) { mutableListOf() }.add(sc)
}
@@ -644,11 +659,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
MmkvManager.removeAllServer()
} else {
val serversCopy = serversCache.toList()
val serversCopy = _serversCache.toList()
for (item in serversCopy) {
MmkvManager.removeServer(item.guid)
}
serversCache.toList().count()
serversCopy.count()
}
return count
}
@@ -663,39 +678,40 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
@Synchronized
fun refreshPingInCache(guids: List<String>) {
val guidSet = guids.toHashSet()
for (i in serversCache.indices) {
val item = serversCache[i]
for (i in _serversCache.indices) {
val item = _serversCache[i]
if (item.guid in guidSet) {
val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L
if (item.testDelayMillis != delay) {
serversCache[i] = item.copy(testDelayMillis = delay)
_serversCache[i] = item.copy(testDelayMillis = delay)
}
}
}
publishSnapshot()
}
@Synchronized
fun sortServersCacheInPlace() {
// Refresh testDelayMillis from storage so DiffUtil sees the change
for (i in serversCache.indices) {
val item = serversCache[i]
for (i in _serversCache.indices) {
val item = _serversCache[i]
val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L
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 },
{
val delay = it.testDelayMillis
when {
delay > 0L -> delay
delay == 0L -> Long.MAX_VALUE - 1 // untested
else -> Long.MAX_VALUE // failed
delay == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
}
))
if (!suppressPinSelected) pinSelectedCacheItemToTop(serversCache)
if (!suppressPinSelected) pinSelectedCacheItemToTop(_serversCache)
publishSnapshot()
}
fun sortByTestResults() {
@@ -852,7 +868,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
withContext(Dispatchers.Main) {
reloadServerList()
reloadServerList() // rebuilds _serversCache + publishSnapshot
isTesting.value = false
liteTestFinished.value = true
liteTestFinished.value = false
@@ -892,7 +908,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
refreshPingInCache(listOf(resultPair.first))
if (!suppressPinSelected) sortServersCacheInPlace()
updateListAction.value = -1
// publishSnapshot() already called inside refresh/sort above
}
AppConfig.MSG_MEASURE_CONFIG_BATCH -> {
@@ -902,7 +918,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
refreshPingInCache(update.results.map { it.guid })
if (!suppressPinSelected) sortServersCacheInPlace()
updateListAction.value = -1
// publishSnapshot() already called inside refresh/sort above
}
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
@@ -20,96 +20,105 @@
app:strokeWidth="0dp">
<LinearLayout
android:id="@+id/info_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:nextFocusRight="@+id/layout_share"
android:orientation="horizontal"
android:paddingStart="0dp"
android:paddingTop="13dp"
android:paddingEnd="4dp"
android:paddingBottom="13dp">
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/info_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:nextFocusRight="@+id/layout_share"
android:orientation="horizontal"
android:paddingStart="0dp"
android:paddingTop="13dp"
android:paddingEnd="4dp"
android:paddingBottom="13dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
android:orientation="horizontal">
<LinearLayout
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="12dp">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:minLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="?attr/colorOnSurface" />
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="5dp">
android:orientation="vertical"
android:paddingStart="12dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_subscription"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="bottom"
android:layout_marginEnd="4dp"
android:background="@drawable/ic_circle">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:minLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="?attr/colorOnSurface" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_subscription"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="bottom"
android:layout_marginEnd="4dp"
android:background="@drawable/ic_circle">
<TextView
android:id="@+id/tv_subscription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/tv_subscription"
android:id="@+id/tv_statistics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tv_statistics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tv_test_result"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:lines="1"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textColor="@color/colorPing"
android:textSize="11sp" />
<TextView
android:id="@+id/tv_test_result"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:lines="1"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textColor="@color/colorPing"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
@@ -117,40 +126,41 @@
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_favorite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="6dp"
android:src="@drawable/kid_star_outline_24" />
<ImageView
android:id="@+id/iv_copy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="6dp"
android:visibility="gone"
app:srcCompat="@drawable/ic_copy"
app:tint="?attr/colorPrimary" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:paddingEnd="4dp">
<ImageView
android:id="@+id/iv_favorite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="6dp"
android:src="@drawable/kid_star_outline_24" />
<ImageView
android:id="@+id/iv_copy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="6dp"
android:visibility="gone"
app:srcCompat="@drawable/ic_copy"
app:tint="?attr/colorPrimary" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>