9 Commits

Author SHA1 Message Date
zarazaex69 ed4eaf6bd0 fix: zoslonka 2026-04-20 04:01:25 +03:00
zarazaex69 6ed13ed6fd fix: -15 2026-04-20 03:57:56 +03:00
zarazaex69 4ddda4902f feat(limit): up limits 2026-04-20 03:51:03 +03:00
zarazaex69 ba53203dbc feat(limit): up limits 2026-04-20 03:44:27 +03:00
zarazaex69 146f20e13b fix: frizing server checl 2026-04-20 03:31:47 +03:00
zarazaex69 38554d4e55 feat(apk): remove apk 2026-04-20 02:26:14 +03:00
zarazaex69 23725a3210 feat: apply colorAccent tint to copy icon in main recycler item 2026-04-19 23:46:08 +03:00
zarazaex69 50f435f250 style: update copy icon tint and fill color for theme compatibility 2026-04-19 23:42:03 +03:00
zarazaex69 80572e566c feat: add copy to clipboard functionality to main recycler item view 2026-04-19 23:35:43 +03:00
18 changed files with 160 additions and 237 deletions
+69 -26
View File
@@ -170,7 +170,9 @@ func (x *CoreController) MeasureDelay(url string) (int64, error) {
// MeasureOutboundDelay measures the outbound delay for a given configuration and URL
func MeasureOutboundDelay(ConfigureFileContent string, url string) (int64, error) {
return measureOutboundDelayInternal(ConfigureFileContent, url)
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
return measureOutboundDelayInternal(ctx, ConfigureFileContent, url)
}
// MeasureOutboundDelayBatch measures the outbound delay for multiple configurations in parallel
@@ -190,28 +192,46 @@ func MeasureOutboundDelayBatch(itemsJson string, url string, callback PingCallba
return
}
// Semaphore to limit concurrency (max 128 concurrent tests)
sem := make(chan struct{}, 128)
var wg sync.WaitGroup
// Use a worker pool to process items
// Performance tuning: 24 concurrency
concurrency := 24
if len(items) < concurrency {
concurrency = len(items)
}
itemChan := make(chan PingItem, len(items))
for _, item := range items {
wg.Add(1)
go func(it PingItem) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
itemChan <- item
}
close(itemChan)
delay, _ := measureOutboundDelayInternal(it.Config, url)
if callback != nil {
callback.OnResult(it.Guid, delay)
var wg sync.WaitGroup
wg.Add(concurrency)
for i := 0; i < concurrency; i++ {
go func() {
defer wg.Done()
for it := range itemChan {
// Set a reasonable timeout for each individual test
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
delay, _ := measureOutboundDelayInternal(ctx, it.Config, url)
cancel() // cancel context as soon as we have a result
if callback != nil {
callback.OnResult(it.Guid, delay)
}
// Small sleep to prevent system congestion
time.Sleep(10 * time.Millisecond)
}
}(item)
}()
}
wg.Wait()
}
func measureOutboundDelayInternal(ConfigureFileContent string, url string) (int64, error) {
func measureOutboundDelayInternal(ctx context.Context, ConfigureFileContent string, url string) (int64, error) {
config, err := coreserial.LoadJSONConfig(strings.NewReader(ConfigureFileContent))
if err != nil {
return -1, fmt.Errorf("config load error: %w", err)
@@ -237,8 +257,28 @@ func measureOutboundDelayInternal(ConfigureFileContent string, url string) (int6
if err := inst.Start(); err != nil {
return -1, fmt.Errorf("startup failed: %w", err)
}
defer inst.Close()
return measureInstDelay(context.Background(), inst, url)
// Measure delay
delay, err := measureInstDelay(ctx, inst, url)
// Close instance with a short timeout to prevent hanging the worker
closeCtx, closeCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer closeCancel()
done := make(chan struct{})
go func() {
inst.Close()
close(done)
}()
select {
case <-done:
// Closed successfully
case <-closeCtx.Done():
// Close timed out, move on
}
return delay, err
}
// CheckVersionX returns the library and Xray versions
@@ -295,7 +335,7 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
tr := &http.Transport{
TLSHandshakeTimeout: 6 * time.Second,
DisableKeepAlives: false,
DisableKeepAlives: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dest, err := corenet.ParseDestination(fmt.Sprintf("%s:%s", network, addr))
if err != nil {
@@ -307,7 +347,7 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
client := &http.Client{
Transport: tr,
Timeout: 5 * time.Second,
Timeout: 6 * time.Second,
}
if url == "" {
@@ -323,7 +363,7 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
success := false
var lastErr error
// Use 2 attempts as requested by user
// Use 2 attempts
const attempts = 2
for i := 0; i < attempts; i++ {
select {
@@ -344,8 +384,8 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
continue
}
// Read body and close resp immediately
body, err := io.ReadAll(resp.Body)
// Limit reading to 64KB to prevent OOM
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
resp.Body.Close()
if err != nil {
@@ -358,11 +398,14 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
continue
}
// Strict IP check: the body must contain a valid IP address
ipStr := strings.TrimSpace(string(body))
if net.ParseIP(ipStr) == nil {
lastErr = fmt.Errorf("response body is not a valid IP: %s", ipStr)
continue
// Relaxed IP check: only if the URL seems like an IP check service
isIPCheckUrl := strings.Contains(strings.ToLower(url), "ip")
if isIPCheckUrl {
ipStr := strings.TrimSpace(string(body))
if net.ParseIP(ipStr) == nil {
lastErr = fmt.Errorf("response body is not a valid IP: %s", ipStr)
continue
}
}
duration := time.Since(start).Milliseconds()
@@ -73,7 +73,6 @@ object AppConfig {
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
const val PREF_SHOW_COPY_BUTTON = "pref_show_copy_button"
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
@@ -165,7 +164,6 @@ object AppConfig {
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
const val MSG_MEASURE_CONFIG_BATCH = 75
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
@@ -10,6 +10,4 @@ interface MainAdapterListener :BaseAdapterListener {
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
fun onCopyToClipboard(guid: String)
}
@@ -1,14 +0,0 @@
package xyz.zarazaex.olc.dto
import java.io.Serializable
data class PingResultItem(
val guid: String,
val delay: Long
) : Serializable
data class PingProgressUpdate(
val results: ArrayList<PingResultItem>,
val finished: Int,
val total: Int
) : Serializable
@@ -128,39 +128,4 @@ data class ProfileItem(
&& this.pinnedCA256 == obj.pinnedCA256
)
}
override fun hashCode(): Int {
var result = server?.hashCode() ?: 0
result = 31 * result + (serverPort?.hashCode() ?: 0)
result = 31 * result + (password?.hashCode() ?: 0)
result = 31 * result + (method?.hashCode() ?: 0)
result = 31 * result + (flow?.hashCode() ?: 0)
result = 31 * result + (username?.hashCode() ?: 0)
result = 31 * result + (network?.hashCode() ?: 0)
result = 31 * result + (headerType?.hashCode() ?: 0)
result = 31 * result + (host?.hashCode() ?: 0)
result = 31 * result + (path?.hashCode() ?: 0)
result = 31 * result + (seed?.hashCode() ?: 0)
result = 31 * result + (quicSecurity?.hashCode() ?: 0)
result = 31 * result + (quicKey?.hashCode() ?: 0)
result = 31 * result + (mode?.hashCode() ?: 0)
result = 31 * result + (serviceName?.hashCode() ?: 0)
result = 31 * result + (authority?.hashCode() ?: 0)
result = 31 * result + (xhttpMode?.hashCode() ?: 0)
result = 31 * result + (security?.hashCode() ?: 0)
result = 31 * result + (sni?.hashCode() ?: 0)
result = 31 * result + (alpn?.hashCode() ?: 0)
result = 31 * result + (fingerPrint?.hashCode() ?: 0)
result = 31 * result + (publicKey?.hashCode() ?: 0)
result = 31 * result + (shortId?.hashCode() ?: 0)
result = 31 * result + (secretKey?.hashCode() ?: 0)
result = 31 * result + (localAddress?.hashCode() ?: 0)
result = 31 * result + (reserved?.hashCode() ?: 0)
result = 31 * result + (mtu ?: 0)
result = 31 * result + (obfsPassword?.hashCode() ?: 0)
result = 31 * result + (portHopping?.hashCode() ?: 0)
result = 31 * result + (portHoppingInterval?.hashCode() ?: 0)
result = 31 * result + (pinnedCA256?.hashCode() ?: 0)
return result
}
}
@@ -42,8 +42,18 @@ open class FmtBase {
* @return a map of query parameters
*/
fun getQueryParam(uri: URI): Map<String, String> {
return uri.rawQuery.split("&")
.associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
return uri.rawQuery.orEmpty().split("&")
.mapNotNull {
val parts = it.split("=", limit = 2)
if (parts.size == 2) {
parts[0] to Utils.decodeURIComponent(parts[1])
} else if (parts.isNotEmpty() && parts[0].isNotEmpty()) {
parts[0] to ""
} else {
null
}
}
.toMap()
}
/**
@@ -241,8 +241,8 @@ object AngConfigManager {
val subItem = MmkvManager.decodeSubscription(subid)
val oldServerData = if (!append) {
saveOldServerData(subid)
val oldPingData = if (!append) {
saveOldPingData(subid)
} else {
emptyMap()
}
@@ -263,7 +263,7 @@ object AngConfigManager {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid, append)
restoreOldServerData(keyToProfile, oldServerData)
restoreOldPingData(keyToProfile, oldPingData)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
@@ -330,39 +330,31 @@ object AngConfigManager {
return keyToProfile
}
private fun saveOldServerData(subid: String): Map<ProfileItem, Pair<Long, Boolean>> {
val serverData = mutableMapOf<ProfileItem, Pair<Long, Boolean>>()
private fun saveOldPingData(subid: String): Map<ProfileItem, Long> {
val pingData = mutableMapOf<ProfileItem, Long>()
val serverList = MmkvManager.decodeServerList(subid)
serverList.forEach { guid ->
val profile = MmkvManager.decodeServerConfig(guid)
if (profile != null) {
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
val delay = aff?.testDelayMillis ?: 0L
if (delay > 0 || profile.isFavorite) {
serverData[profile] = Pair(delay, profile.isFavorite)
}
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
if (profile != null && aff != null && aff.testDelayMillis > 0) {
pingData[profile] = aff.testDelayMillis
}
}
return serverData
return pingData
}
private fun restoreOldServerData(keyToProfile: Map<String, ProfileItem>, oldServerData: Map<ProfileItem, Pair<Long, Boolean>>) {
if (oldServerData.isEmpty()) return
private fun restoreOldPingData(keyToProfile: Map<String, ProfileItem>, oldPingData: Map<ProfileItem, Long>) {
if (oldPingData.isEmpty()) return
keyToProfile.forEach { (key, newProfile) ->
val oldData = oldServerData[newProfile]
val oldPing = oldPingData.entries.firstOrNull { (oldProfile, _) ->
oldProfile == newProfile
}?.value
if (oldData != null) {
val (oldPing, isFavorite) = oldData
if (oldPing > 0) {
MmkvManager.encodeServerTestDelayMillis(key, oldPing)
}
if (isFavorite) {
newProfile.isFavorite = true
MmkvManager.encodeServerConfig(key, newProfile)
}
if (oldPing != null && oldPing > 0) {
MmkvManager.encodeServerTestDelayMillis(key, oldPing)
}
}
}
@@ -6,12 +6,8 @@ import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.dto.PingProgressUpdate
import xyz.zarazaex.olc.dto.PingResultItem
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.V2RayNativeManager
import xyz.zarazaex.olc.handler.V2rayConfigManager
@@ -33,37 +29,29 @@ class RealPingWorkerService(
private val totalCount = AtomicInteger(guids.size)
private val finishedCount = AtomicInteger(0)
private val pendingResults = ArrayList<PingResultItem>()
private val pendingLock = Any()
private val delayTestUrl = SettingsManager.getDelayTestUrl()
companion object {
private const val RESULT_BATCH_SIZE = 32
private const val FLUSH_INTERVAL_MS = 1000L
}
data class PingItem(val guid: String, val config: String)
fun start() {
scope.launch(Dispatchers.IO) {
while (isActive) {
delay(FLUSH_INTERVAL_MS)
flushPendingResults()
}
}
scope.launch(Dispatchers.IO) {
try {
// Prepare configurations for batch test and shuffle for better async feel
val items =
guids.shuffled().mapNotNull { guid ->
val configResult =
V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (configResult.status) {
PingItem(guid, configResult.content)
} else {
// Notify failure immediately for invalid configs
try {
val configResult =
V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (configResult.status) {
PingItem(guid, configResult.content)
} else {
// Notify failure immediately for invalid configs
reportResult(guid, -1L)
null
}
} catch (e: Exception) {
android.util.Log.e(AppConfig.TAG, "Failed to prepare config for $guid", e)
reportResult(guid, -1L)
null
}
@@ -85,11 +73,13 @@ class RealPingWorkerService(
)
}
flushPendingResults()
onFinish("0")
if (job.isActive) {
onFinish("0")
}
} catch (e: Exception) {
flushPendingResults()
onFinish("-1")
if (job.isActive) {
onFinish("-1")
}
} finally {
cancel()
}
@@ -97,43 +87,22 @@ class RealPingWorkerService(
}
private fun reportResult(guid: String, delay: Long) {
val finished = finishedCount.incrementAndGet()
var readyBatch: PingProgressUpdate? = null
synchronized(pendingLock) {
pendingResults.add(PingResultItem(guid, delay))
if (pendingResults.size >= RESULT_BATCH_SIZE || finished >= totalCount.get()) {
readyBatch = createProgressUpdateLocked(finished)
pendingResults.clear()
if (!job.isActive) return
// Launch in scope to unblock Go worker immediately
scope.launch {
val finished = finishedCount.incrementAndGet()
val total = guids.size
// Notify UI about the individual result
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, delay))
// Throttle progress updates: every 10 items or the very last one
if (finished % 10 == 0 || finished == total) {
val left = total - finished
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $total")
}
}
readyBatch?.let(::sendBatchUpdate)
}
private fun flushPendingResults() {
val finished = finishedCount.get()
val update =
synchronized(pendingLock) {
if (pendingResults.isEmpty()) {
null
} else {
createProgressUpdateLocked(finished).also { pendingResults.clear() }
}
}
update?.let(::sendBatchUpdate)
}
private fun createProgressUpdateLocked(finished: Int): PingProgressUpdate {
return PingProgressUpdate(
results = ArrayList(pendingResults),
finished = finished,
total = totalCount.get()
)
}
private fun sendBatchUpdate(update: PingProgressUpdate) {
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_BATCH, update)
val left = (update.total - update.finished).coerceAtLeast(0)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / ${update.total}")
}
fun cancel() {
@@ -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
@@ -138,7 +137,9 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) != 0) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
@@ -263,10 +264,6 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
setSelectServer(guid)
}
override fun onCopyToClipboard(guid: String) {
share2Clipboard(guid)
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
@@ -17,8 +17,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationView
@@ -86,12 +84,6 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this)
ViewCompat.setOnApplyWindowInsetsListener(binding.drawerContentLayout) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(0, systemBars.top, 0, systemBars.bottom)
insets
}
findViewById<android.widget.TextView>(R.id.drawer_settings)?.setOnClickListener {
requestActivityLauncher.launch(Intent(this, SettingsActivity::class.java))
binding.drawerLayout.closeDrawer(androidx.core.view.GravityCompat.START)
@@ -15,6 +15,8 @@ import xyz.zarazaex.olc.databinding.ItemRecyclerFooterBinding
import xyz.zarazaex.olc.databinding.ItemRecyclerMainBinding
import xyz.zarazaex.olc.dto.ProfileItem
import xyz.zarazaex.olc.dto.ServersCache
import xyz.zarazaex.olc.extension.toastError
import xyz.zarazaex.olc.extension.toastSuccess
import xyz.zarazaex.olc.handler.AngConfigManager
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.helper.ItemTouchHelperAdapter
@@ -33,8 +35,6 @@ class MainRecyclerAdapter(
private val doubleColumnDisplay =
MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private val showCopyButton =
MmkvManager.decodeSettingsBool(AppConfig.PREF_SHOW_COPY_BUTTON, false)
private var data: MutableList<ServersCache> = mutableListOf()
private var recyclerView: RecyclerView? = null
@@ -202,9 +202,12 @@ class MainRecyclerAdapter(
mainViewModel.reloadServerList()
}
holder.itemMainBinding.ivCopy.visibility = if (showCopyButton) View.VISIBLE else View.GONE
holder.itemMainBinding.ivCopy.setOnClickListener {
adapterListener?.onCopyToClipboard(guid)
if (AngConfigManager.share2Clipboard(context, guid) == 0) {
context.toastSuccess(R.string.toast_success)
} else {
context.toastError(R.string.toast_failure)
}
}
holder.itemMainBinding.infoContainer.setOnClickListener {
@@ -15,7 +15,6 @@ import xyz.zarazaex.olc.AngApplication
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.dto.GroupMapItem
import xyz.zarazaex.olc.dto.PingProgressUpdate
import xyz.zarazaex.olc.dto.ServersCache
import xyz.zarazaex.olc.dto.SubscriptionCache
import xyz.zarazaex.olc.dto.SubscriptionUpdateResult
@@ -629,14 +628,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
updateListAction.value = getPosition(resultPair.first)
}
AppConfig.MSG_MEASURE_CONFIG_BATCH -> {
val update = intent.serializable<PingProgressUpdate>("content") ?: return
update.results.forEach { result ->
MmkvManager.encodeServerTestDelayMillis(result.guid, result.delay)
}
updateListAction.value = -1
}
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
val content = intent.getStringExtra("content")
updateTestResultAction.value =
@@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:fillColor="#FF000000"
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z" />
</vector>
@@ -3,8 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
@@ -151,7 +150,6 @@
</LinearLayout>
<LinearLayout
android:id="@+id/drawer_content_layout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
@@ -99,35 +99,28 @@
</LinearLayout>
<LinearLayout
<ImageView
android:id="@+id/iv_favorite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end">
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="@dimen/padding_spacing_dp8"
android:src="@drawable/ic_star_empty" />
<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="@dimen/padding_spacing_dp8"
android:src="@drawable/ic_star_empty" />
<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="@dimen/padding_spacing_dp8"
android:visibility="gone"
app:srcCompat="@drawable/ic_copy"
app:tint="?attr/colorAccent" />
</LinearLayout>
<ImageView
android:id="@+id/iv_copy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="@dimen/padding_spacing_dp8"
android:src="@drawable/ic_copy"
app:tint="?attr/colorAccent" />
</LinearLayout>
@@ -236,9 +236,6 @@
<string name="title_pref_group_all_display">Общая вкладка групп</string>
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
<string name="title_pref_show_copy_button">Кнопка копирования</string>
<string name="summary_pref_show_copy_button">Показывать кнопку копирования конфигурации сервера в буфер обмена</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string>
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
@@ -160,9 +160,6 @@
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string>
<string name="title_pref_show_copy_button">Show copy button</string>
<string name="summary_pref_show_copy_button">Show button to copy server configuration to clipboard</string>
<string name="title_mux_settings">Mux Settings</string>
<string name="title_pref_mux_enabled">Enable Mux</string>
<string name="summary_pref_mux_enabled">Faster, but it may cause unstable connectivity\nCustomize how to handle TCP, UDP and QUIC below</string>
@@ -28,12 +28,6 @@
android:summary="@string/summary_pref_group_all_display"
android:title="@string/title_pref_group_all_display" />
<CheckBoxPreference
android:key="pref_show_copy_button"
android:defaultValue="false"
android:summary="@string/summary_pref_show_copy_button"
android:title="@string/title_pref_show_copy_button" />
<ListPreference
android:defaultValue="auto"
android:entries="@array/language_select"