From a7fe205b6a655311c8bb7fb2f4594bbd8c54543c Mon Sep 17 00:00:00 2001 From: Olivi <225673551+Olivi-9@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:16:33 +0800 Subject: [PATCH 1/2] feat: add HealthCheck for specified proxy --- .../com/github/kr328/clash/ProxyActivity.kt | 11 +- .../main/golang/native/tunnel/connectivity.go | 17 +- .../github/kr328/clash/design/ProxyDesign.kt | 15 +- .../clash/design/adapter/ProxyAdapter.kt | 4 + .../design/component/ProxyPageFactory.kt | 6 +- .../kr328/clash/design/component/ProxyView.kt | 309 ++++++++++++++---- .../clash/design/component/ProxyViewConfig.kt | 3 +- .../clash/design/component/ProxyViewState.kt | 8 +- design/src/main/res/values/dimens.xml | 3 +- 9 files changed, 304 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt b/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt index 7fa361a730..1d7b218b47 100644 --- a/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt @@ -99,6 +99,15 @@ class ProxyActivity : BaseActivity() { design.requests.send(ProxyDesign.Request.Reload(it.index)) } } + is ProxyDesign.Request.HealthCheck -> { + launch { + withClash { + healthCheck(it.name) + } + + design.requests.send(ProxyDesign.Request.Reload(it.index)) + } + } is ProxyDesign.Request.PatchMode -> { design.showModeSwitchTips() @@ -115,4 +124,4 @@ class ProxyActivity : BaseActivity() { } } } -} \ No newline at end of file +} diff --git a/core/src/main/golang/native/tunnel/connectivity.go b/core/src/main/golang/native/tunnel/connectivity.go index be716784ea..b2d3810a67 100644 --- a/core/src/main/golang/native/tunnel/connectivity.go +++ b/core/src/main/golang/native/tunnel/connectivity.go @@ -1,7 +1,9 @@ package tunnel import ( + "context" "sync" + "time" "github.com/metacubex/mihomo/adapter/outboundgroup" "github.com/metacubex/mihomo/constant/provider" @@ -20,7 +22,20 @@ func HealthCheck(name string) { g, ok := p.Adapter().(outboundgroup.ProxyGroup) if !ok { - log.Warnln("Request health check for `%s`: invalid type %s", name, p.Type().String()) + testURL := "https://www.gstatic.com/generate_204" + for k := range p.ExtraDelayHistories() { + if len(k) > 0 { + testURL = k + break + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if _, err := p.URLTest(ctx, testURL, nil); err != nil && ctx.Err() == nil { + log.Warnln("Request health check for `%s`: %s", name, err.Error()) + } return } diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 7dc7f413d7..978934a026 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -36,6 +36,7 @@ class ProxyDesign( data class Reload(val index: Int) : Request() data class Select(val index: Int, val name: String) : Request() data class UrlTest(val index: Int) : Request() + data class HealthCheck(val index: Int, val name: String) : Request() } private val binding = DesignProxyBinding @@ -116,9 +117,15 @@ class ProxyDesign( surface, config, List(groupNames.size) { index -> - ProxyAdapter(config) { name -> - requests.trySend(Request.Select(index, name)) - } + ProxyAdapter( + config, + { name -> + requests.trySend(Request.Select(index, name)) + }, + { name -> + requests.trySend(Request.HealthCheck(index, name)) + } + ) } ) { if (it == currentItem) @@ -174,4 +181,4 @@ class ProxyDesign( binding.urlTestProgressView.visibility = View.GONE } } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt index 619c17e58c..ca4d4cf29e 100644 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt +++ b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt @@ -9,6 +9,7 @@ import com.github.kr328.clash.design.component.ProxyViewState class ProxyAdapter( private val config: ProxyViewConfig, private val clicked: (String) -> Unit, + private val delayClicked: (String) -> Unit, ) : RecyclerView.Adapter() { class Holder(val view: ProxyView) : RecyclerView.ViewHolder(view) @@ -24,6 +25,9 @@ class ProxyAdapter( holder.view.apply { state = current + onDelayClick = { + delayClicked(current.proxy.name) + } setOnClickListener { clicked(current.proxy.name) diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt index ff9b84fb11..c597ef8a01 100644 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt +++ b/design/src/main/java/com/github/kr328/clash/design/component/ProxyPageFactory.kt @@ -4,6 +4,7 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator import com.github.kr328.clash.design.view.VerticalScrollableHost class ProxyPageFactory(private val config: ProxyViewConfig) { @@ -48,6 +49,9 @@ class ProxyPageFactory(private val config: ProxyViewConfig) { setRecycledViewPool(childrenPool) clipToPadding = false + + // Avoid default change-animation flicker when delay updates trigger notifyItemRangeChanged. + (itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false } return Holder(recyclerView, root).apply { @@ -58,4 +62,4 @@ class ProxyPageFactory(private val config: ProxyViewConfig) { fun fromRoot(root: View): Holder { return root.tag!! as Holder } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt index 2f2c0500db..b589cf8850 100644 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt +++ b/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt @@ -2,11 +2,15 @@ package com.github.kr328.clash.design.component import android.content.Context import android.graphics.Canvas +import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import android.graphics.RectF +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent import android.view.View import com.github.kr328.clash.common.compat.getDrawableCompat -import com.github.kr328.clash.design.store.UiStore class ProxyView( context: Context, @@ -18,40 +22,134 @@ class ProxyView( } var state: ProxyViewState? = null + set(value) { + field = value + delayTestPending = false + delayOverrideText = null + lastObservedDelay = value?.proxy?.delay ?: Int.MIN_VALUE + delayHandler.removeCallbacks(delayTimeoutRunnable) + } + var onDelayClick: (() -> Unit)? = null + + private val delayRect = RectF() + + private val delayTouchRect = RectF() + + private var delayPressed = false + + private var delayScale = 1f + private var delayAnimProgress = 0f + private var delayTestPending = false + private var delayOverrideText: String? = null + private var lastObservedDelay: Int = Int.MIN_VALUE + + private val delayHandler = Handler(Looper.getMainLooper()) + private val delayTimeoutRunnable = Runnable { + if (delayTestPending) { + delayTestPending = false + delayOverrideText = "--" + postInvalidate() + } + } + constructor(context: Context) : this(context, ProxyViewConfig(context, 2)) + + private fun dp(v: Float): Float { + return v * resources.displayMetrics.density + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (state == null) { + return super.onTouchEvent(event) + } + + if (!delayTouchRect.isEmpty) { + val inside = delayTouchRect.contains(event.x, event.y) + + when (event.actionMasked) { + + MotionEvent.ACTION_DOWN -> { + if (inside) { + delayPressed = true + invalidate() + return true + } + } + + MotionEvent.ACTION_MOVE -> { + if (delayPressed && !inside) { + delayPressed = false + invalidate() + } + + if (delayPressed) { + return true + } + } + + MotionEvent.ACTION_UP -> { + if (delayPressed) { + delayPressed = false + invalidate() + + if (inside) { + startDelayTest() + onDelayClick?.invoke() + } + + return true + } + } + + MotionEvent.ACTION_CANCEL -> { + if (delayPressed) { + delayPressed = false + invalidate() + return true + } + } + } + } + + return super.onTouchEvent(event) + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val state = state ?: return super.onMeasure(widthMeasureSpec, heightMeasureSpec) val width = when (MeasureSpec.getMode(widthMeasureSpec)) { MeasureSpec.UNSPECIFIED -> resources.displayMetrics.widthPixels + MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec) - else -> - throw IllegalArgumentException("invalid measure spec") + + else -> throw IllegalArgumentException("invalid measure spec") } state.paint.apply { reset() - textSize = state.config.textSize - getTextBounds("Stub!", 0, 1, state.rect) } val textHeight = state.rect.height() - val exceptHeight = (state.config.layoutPadding * 2 + - state.config.contentPadding * 2 + - textHeight * 2 + - state.config.textMargin).toInt() + + val expectHeight = ( + state.config.layoutPadding * 2 + + state.config.contentPadding * 2 + + textHeight * 2 + + state.config.textMargin + ).toInt() val height = when (MeasureSpec.getMode(heightMeasureSpec)) { MeasureSpec.UNSPECIFIED -> - exceptHeight + expectHeight + MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> - exceptHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec)) - else -> - throw IllegalArgumentException("invalid measure spec") + expectHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec)) + + else -> throw IllegalArgumentException("invalid measure spec") } setMeasuredDimension(width, height) @@ -63,23 +161,50 @@ class ProxyView( if (state.update(false)) postInvalidate() + val currentDelay = state.proxy.delay + if (currentDelay != lastObservedDelay) { + lastObservedDelay = currentDelay + + if (currentDelay in 1..Short.MAX_VALUE) { + if (delayTestPending || delayOverrideText != null) { + delayTestPending = false + delayOverrideText = null + delayHandler.removeCallbacks(delayTimeoutRunnable) + postInvalidate() + } + } + } + + if (delayPressed && delayAnimProgress < 1f) { + delayAnimProgress += 0.15f + postInvalidate() + } + + if (!delayPressed && delayAnimProgress > 0f) { + delayAnimProgress -= 0.15f + postInvalidate() + } + + delayScale = 1f - delayAnimProgress * 0.1f + val width = width.toFloat() val height = height.toFloat() val paint = state.paint - paint.reset() paint.color = state.background paint.style = Paint.Style.FILL - // draw background canvas.apply { - if (state.config.proxyLine==1) { + + if (state.config.proxyLine == 1) { + drawRect(0f, 0f, width, height, paint) + } else { - val path = state.path + val path = state.path path.reset() path.addRoundRect( @@ -89,7 +214,7 @@ class ProxyView( height - state.config.layoutPadding, state.config.cardRadius, state.config.cardRadius, - Path.Direction.CW, + Path.Direction.CW ) paint.setShadowLayer( @@ -114,39 +239,47 @@ class ProxyView( val state = state ?: return val paint = state.paint - val width = width.toFloat() val height = height.toFloat() + paint.reset() paint.textSize = state.config.textSize + paint.isAntiAlias = true + + val delayPadding = state.config.delayPadding + + val delayStub = "9999" + paint.getTextBounds(delayStub, 0, delayStub.length, state.rect) + + val fixedDelayTextWidth = state.rect.width().toFloat() + val delayAreaWidth = fixedDelayTextWidth + delayPadding * 2 + + val delayText = delayOverrideText ?: state.delayText - // measure delay text bounds val delayCount = paint.breakText( - state.delayText, + delayText, false, - (width - state.config.layoutPadding * 2 - state.config.contentPadding * 2) - .coerceAtLeast(0f), + (delayAreaWidth - delayPadding * 2).coerceAtLeast(0f), null ) - state.paint.getTextBounds(state.delayText, 0, delayCount, state.rect) - - val delayWidth = state.rect.width() + paint.getTextBounds(delayText, 0, delayCount, state.rect) + val delayTextWidth = state.rect.width().toFloat() - val mainTextWidth = (width - - state.config.layoutPadding * 2 - - state.config.contentPadding * 2 - - delayWidth - - state.config.textMargin * 2 - ) - .coerceAtLeast(0f) + val mainTextWidth = ( + width - + state.config.layoutPadding * 2 - + state.config.contentPadding * 2 - + delayAreaWidth - + state.config.textMargin * 2 + ).coerceAtLeast(0f) // measure title text bounds val titleCount = paint.breakText( state.title, false, mainTextWidth, - null, + null ) // measure subtitle text bounds @@ -154,42 +287,100 @@ class ProxyView( state.subtitle, false, mainTextWidth, - null, + null ) // text draw measure val textOffset = (paint.descent() + paint.ascent()) / 2 - paint.reset() + val fm = paint.fontMetrics + val delayAreaHeight = (fm.descent - fm.ascent) + delayPadding * 2 + + val delayAreaLeft = + width - state.config.layoutPadding - state.config.contentPadding - delayAreaWidth + + val delayAreaTop = + height / 2f - delayAreaHeight / 2f + + val delayAreaRight = + delayAreaLeft + delayAreaWidth + + val delayAreaBottom = + delayAreaTop + delayAreaHeight + + delayRect.set(delayAreaLeft, delayAreaTop, delayAreaRight, delayAreaBottom) + + val extra = dp(24f) + + delayTouchRect.set( + delayRect.left - extra, + delayRect.top - extra, + delayRect.right + extra, + delayRect.bottom + extra + ) + + val alpha = (0x24 + delayAnimProgress * 60).toInt() + + paint.color = Color.argb( + alpha, + Color.red(state.controls), + Color.green(state.controls), + Color.blue(state.controls) + ) + + paint.style = Paint.Style.FILL + + canvas.save() + + canvas.scale( + delayScale, + delayScale, + delayRect.centerX(), + delayRect.centerY() + ) + + canvas.drawRoundRect( + delayRect, + delayAreaHeight / 2f, + delayAreaHeight / 2f, + paint + ) - paint.textSize = state.config.textSize - paint.isAntiAlias = true paint.color = state.controls - // draw delay - canvas.apply { - val x = width - state.config.layoutPadding - state.config.contentPadding - delayWidth - val y = height / 2f - textOffset + val x = delayRect.centerX() - delayTextWidth / 2f + val y = delayRect.centerY() - (fm.ascent + fm.descent) / 2f - drawText(state.delayText, 0, delayCount, x, y, paint) - } + canvas.drawText(delayText, 0, delayCount, x, y, paint) - // draw title - canvas.apply { - val x = state.config.layoutPadding + state.config.contentPadding - val y = state.config.layoutPadding + - (height - state.config.layoutPadding * 2) / 3f - textOffset + canvas.restore() - drawText(state.title, 0, titleCount, x, y, paint) - } + canvas.drawText( + state.title, + 0, + titleCount, + state.config.layoutPadding + state.config.contentPadding, + state.config.layoutPadding + + (height - state.config.layoutPadding * 2) / 3f - textOffset, + paint + ) - // draw subtitle - canvas.apply { - val x = state.config.layoutPadding + state.config.contentPadding - val y = state.config.layoutPadding + - (height - state.config.layoutPadding * 2) / 3f * 2 - textOffset + canvas.drawText( + state.subtitle, + 0, + subtitleCount, + state.config.layoutPadding + state.config.contentPadding, + state.config.layoutPadding + + (height - state.config.layoutPadding * 2) / 3f * 2 - textOffset, + paint + ) + } - drawText(state.subtitle, 0, subtitleCount, x, y, paint) - } + private fun startDelayTest() { + delayTestPending = true + delayOverrideText = "ยทยทยท" + delayHandler.removeCallbacks(delayTimeoutRunnable) + delayHandler.postDelayed(delayTimeoutRunnable, 5000L) + invalidate() } -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewConfig.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewConfig.kt index 0426e44567..3fe762f547 100644 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewConfig.kt +++ b/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewConfig.kt @@ -27,6 +27,7 @@ class ProxyViewConfig(val context: Context, var proxyLine: Int) { get() = if (proxyLine==2) context.getPixels(R.dimen.proxy_text_margin).toFloat() else context.getPixels(R.dimen.proxy_text_margin_grid3).toFloat() val textSize get() = if (proxyLine==2) context.getPixels(R.dimen.proxy_text_size).toFloat() else context.getPixels(R.dimen.proxy_text_size_grid3).toFloat() + val delayPadding = context.getPixels(R.dimen.proxy_delay_padding).toFloat() val shadow = Color.argb( 0x15, @@ -37,4 +38,4 @@ class ProxyViewConfig(val context: Context, var proxyLine: Int) { val cardRadius = context.getPixels(R.dimen.proxy_card_radius).toFloat() var cardOffset = context.getPixels(R.dimen.proxy_card_offset).toFloat() -} \ No newline at end of file +} diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewState.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewState.kt index 88e2bcae4d..9920002aed 100644 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewState.kt +++ b/design/src/main/java/com/github/kr328/clash/design/component/ProxyViewState.kt @@ -21,11 +21,11 @@ class ProxyViewState( var title: String = "" var subtitle: String = "" - var delayText: String = "" + var delayText: String = "--" var background: Int = config.unselectedBackground var controls: Int = config.unselectedControl - private var delay: Int = 0 + private var delay: Int = Int.MIN_VALUE private var selected: Boolean = false private var parentNow: String = "" private var linkNow: String? = null @@ -58,7 +58,7 @@ class ProxyViewState( if (delay != proxy.delay) { delay = proxy.delay - delayText = if (proxy.delay in 0..Short.MAX_VALUE) proxy.delay.toString() else "" + delayText = if (proxy.delay in 1..Short.MAX_VALUE) proxy.delay.toString() else "--" } if (parentNow !== parent.now) { @@ -124,4 +124,4 @@ class ProxyViewState( return invalidate } -} \ No newline at end of file +} diff --git a/design/src/main/res/values/dimens.xml b/design/src/main/res/values/dimens.xml index 002d927159..32027f2d2a 100644 --- a/design/src/main/res/values/dimens.xml +++ b/design/src/main/res/values/dimens.xml @@ -71,6 +71,7 @@ 5dp 12sp 11sp + 4dp 5dp 0dp @@ -84,4 +85,4 @@ 12dp 12dp - \ No newline at end of file + From 1b3a588bc6bdb89412e5a9fbf3efbd7cc7ce60c7 Mon Sep 17 00:00:00 2001 From: Olivi <225673551+Olivi-9@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:43:55 +0800 Subject: [PATCH 2/2] chore: Allow proxy text to overflow delay badge and fade near its edge --- .../kr328/clash/design/component/ProxyView.kt | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt b/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt index b589cf8850..85effe8dcc 100644 --- a/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt +++ b/design/src/main/java/com/github/kr328/clash/design/component/ProxyView.kt @@ -3,9 +3,11 @@ package com.github.kr328.clash.design.component import android.content.Context import android.graphics.Canvas import android.graphics.Color +import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.Path import android.graphics.RectF +import android.graphics.Shader import android.os.Handler import android.os.Looper import android.view.MotionEvent @@ -226,7 +228,6 @@ class ProxyView( drawPath(path, paint) - clipPath(path) } } @@ -269,9 +270,7 @@ class ProxyView( val mainTextWidth = ( width - state.config.layoutPadding * 2 - - state.config.contentPadding * 2 - - delayAreaWidth - - state.config.textMargin * 2 + state.config.contentPadding * 2 ).coerceAtLeast(0f) // measure title text bounds @@ -297,7 +296,7 @@ class ProxyView( val delayAreaHeight = (fm.descent - fm.ascent) + delayPadding * 2 val delayAreaLeft = - width - state.config.layoutPadding - state.config.contentPadding - delayAreaWidth + width - state.config.layoutPadding - delayAreaWidth - dp(5f) val delayAreaTop = height / 2f - delayAreaHeight / 2f @@ -355,6 +354,31 @@ class ProxyView( canvas.restore() + val fadeWidth = dp(16f) + val fadeStart = (delayRect.left - fadeWidth).coerceAtLeast(0f) + val fadeEnd = delayRect.left.coerceAtLeast(fadeStart + 1f) + + val baseColor = state.controls + val fadeShader = LinearGradient( + fadeStart, + 0f, + fadeEnd, + 0f, + intArrayOf( + baseColor, + Color.argb( + 0, + Color.red(baseColor), + Color.green(baseColor), + Color.blue(baseColor) + ) + ), + floatArrayOf(0f, 1f), + Shader.TileMode.CLAMP + ) + + paint.shader = fadeShader + canvas.drawText( state.title, 0, @@ -374,6 +398,8 @@ class ProxyView( (height - state.config.layoutPadding * 2) / 3f * 2 - textOffset, paint ) + + paint.shader = null } private fun startDelayTest() {