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
6 changed files with 135 additions and 43 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()
@@ -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()
}
/**
@@ -40,12 +40,18 @@ class RealPingWorkerService(
// 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
}
@@ -67,9 +73,13 @@ class RealPingWorkerService(
)
}
onFinish("0")
if (job.isActive) {
onFinish("0")
}
} catch (e: Exception) {
onFinish("-1")
if (job.isActive) {
onFinish("-1")
}
} finally {
cancel()
}
@@ -77,15 +87,22 @@ class RealPingWorkerService(
}
private fun reportResult(guid: String, delay: Long) {
val finished = finishedCount.incrementAndGet()
val total = guids.size
if (!job.isActive) return
// Notify UI about the individual result
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, delay))
// Launch in scope to unblock Go worker immediately
scope.launch {
val finished = finishedCount.incrementAndGet()
val total = guids.size
// Notify UI about progress
val left = total - finished
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $total")
// 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")
}
}
}
fun cancel() {
@@ -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
@@ -200,6 +202,14 @@ class MainRecyclerAdapter(
mainViewModel.reloadServerList()
}
holder.itemMainBinding.ivCopy.setOnClickListener {
if (AngConfigManager.share2Clipboard(context, guid) == 0) {
context.toastSuccess(R.string.toast_success)
} else {
context.toastError(R.string.toast_failure)
}
}
holder.itemMainBinding.infoContainer.setOnClickListener {
adapterListener?.onSelectServer(guid)
}
@@ -110,6 +110,18 @@
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: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>
<LinearLayout
BIN
View File
Binary file not shown.