Skip to content

Commit 97ea23f

Browse files
committed
feat: streaming node latency, subscription auto-update options, vless fixes
- Proxies: stream per-node latency results (Semaphore-limited) and add a sort-by-latency toggle - Subscriptions: choose auto-update interval and connected-only when adding - Decoder: emit vless flow, reality-opts, ws Host header, grpc service name, client-fingerprint; drop bogus cipher
1 parent c593156 commit 97ea23f

14 files changed

Lines changed: 177 additions & 25 deletions

File tree

app/src/main/java/top/uwu/mikubox/profile/MihomoProfileImporter.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ object MihomoProfileImporter {
1515
name: String,
1616
url: String,
1717
intervalMinutes: Long = 24 * 60,
18+
updateWhenConnectedOnly: Boolean = false,
1819
): MihomoProfileStore.Profile =
19-
MihomoProfileStore.createSubscription(context, name, url, intervalMinutes)
20+
MihomoProfileStore.createSubscription(context, name, url, intervalMinutes, updateWhenConnectedOnly)
2021

2122
fun importUri(context: Context, uri: Uri): MihomoProfileStore.Profile {
2223
val subscriptionUrl = when {

app/src/main/java/top/uwu/mikubox/profile/MihomoSubscriptionDecoder.kt

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,27 @@ object MihomoSubscriptionDecoder {
146146
"server" to host, "port" to (uri.port.takeIf { it > 0 } ?: 443).toString(), "uuid" to uuid,
147147
"udp" to "true",
148148
)
149-
uri.getQueryParameter("encryption")?.let { fields += "cipher" to it }
150-
uri.getQueryParameter("security")?.takeIf { it != "none" }?.let { fields += "tls" to "true" }
151-
uri.getQueryParameter("sni")?.let { fields += "servername" to it }
152-
uri.getQueryParameter("type")?.let { fields += "network" to it }
153-
uri.getQueryParameter("path")?.let { fields += "ws-opts.path" to it }
149+
// xtls-rprx-vision flow (vless), TLS/Reality security.
150+
uri.getQueryParameter("flow")?.takeIf { it.isNotBlank() }?.let { fields += "flow" to it }
151+
val security = uri.getQueryParameter("security")
152+
if (security != null && security != "none") fields += "tls" to "true"
153+
uri.getQueryParameter("sni")?.takeIf { it.isNotBlank() }?.let { fields += "servername" to it }
154+
uri.getQueryParameter("fp")?.takeIf { it.isNotBlank() }?.let { fields += "client-fingerprint" to it }
155+
if (security == "reality") {
156+
uri.getQueryParameter("pbk")?.takeIf { it.isNotBlank() }?.let { fields += "reality-opts.public-key" to it }
157+
uri.getQueryParameter("sid")?.takeIf { it.isNotBlank() }?.let { fields += "reality-opts.short-id" to it }
158+
}
159+
// Transport: ws needs its path + Host header; grpc needs the service name.
160+
val network = uri.getQueryParameter("type")?.takeIf { it.isNotBlank() } ?: "tcp"
161+
fields += "network" to network
162+
when (network.lowercase()) {
163+
"ws" -> {
164+
uri.getQueryParameter("path")?.takeIf { it.isNotBlank() }?.let { fields += "ws-opts.path" to it }
165+
uri.getQueryParameter("host")?.takeIf { it.isNotBlank() }?.let { fields += "ws-opts.headers.Host" to it }
166+
}
167+
"grpc" -> uri.getQueryParameter("serviceName")?.takeIf { it.isNotBlank() }
168+
?.let { fields += "grpc-opts.grpc-service-name" to it }
169+
}
154170
return ProxyYaml(name(uri.fragment, host), type, fields)
155171
}
156172

app/src/main/java/top/uwu/mikubox/ui/AddProfileBottomSheet.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,25 @@ import android.view.LayoutInflater
66
import android.view.View
77
import android.view.ViewGroup
88
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
9+
import com.google.android.material.materialswitch.MaterialSwitch
10+
import com.google.android.material.textfield.MaterialAutoCompleteTextView
911
import com.google.android.material.textfield.TextInputEditText
1012
import top.uwu.mikubox.R
1113

1214
/** Bottom sheet for adding a subscription or importing a config from clipboard. */
1315
class AddProfileBottomSheet : BottomSheetDialogFragment() {
1416

1517
interface Listener {
16-
fun onAddSubscription(url: String, name: String)
18+
fun onAddSubscription(url: String, name: String, intervalMinutes: Long, connectedOnly: Boolean)
1719
fun onImportClipboard(name: String)
1820
}
1921

2022
private var listener: Listener? = null
2123

24+
/** Preset auto-update intervals in minutes, matched by index to R.array.subscription_intervals. */
25+
private val intervalMinutes = longArrayOf(360, 720, 1440, 4320, 10080)
26+
private val defaultIntervalIndex = 2
27+
2228
override fun onAttach(context: Context) {
2329
super.onAttach(context)
2430
listener = context as? Listener
@@ -34,11 +40,21 @@ class AddProfileBottomSheet : BottomSheetDialogFragment() {
3440
super.onViewCreated(view, savedInstanceState)
3541
val url = view.findViewById<TextInputEditText>(R.id.et_sub_url)
3642
val name = view.findViewById<TextInputEditText>(R.id.et_name)
43+
val interval = view.findViewById<MaterialAutoCompleteTextView>(R.id.dropdown_interval)
44+
val connectedOnly = view.findViewById<MaterialSwitch>(R.id.switch_connected_only)
45+
46+
val labels = resources.getStringArray(R.array.subscription_intervals)
47+
interval.setSimpleItems(labels)
48+
interval.setText(labels[defaultIntervalIndex], false)
49+
var selectedIndex = defaultIntervalIndex
50+
interval.setOnItemClickListener { _, _, position, _ -> selectedIndex = position }
3751

3852
view.findViewById<View>(R.id.btn_add_sub).setOnClickListener {
3953
listener?.onAddSubscription(
4054
url.text?.toString()?.trim().orEmpty(),
4155
name.text?.toString()?.trim().orEmpty(),
56+
intervalMinutes[selectedIndex],
57+
connectedOnly.isChecked,
4258
)
4359
dismiss()
4460
}

app/src/main/java/top/uwu/mikubox/ui/MainActivity.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,13 @@ class MainActivity : EdgeToEdgeActivity(), AddProfileBottomSheet.Listener {
258258
return String.format(java.util.Locale.US, "%.1f %s", value, units[unit])
259259
}
260260

261-
override fun onAddSubscription(url: String, name: String) {
261+
override fun onAddSubscription(url: String, name: String, intervalMinutes: Long, connectedOnly: Boolean) {
262262
if (url.isEmpty()) {
263263
toast(getString(R.string.error_subscription_url_blank))
264264
return
265265
}
266266
val profile = try {
267-
MihomoProfileImporter.importSubscription(this, name, url)
267+
MihomoProfileImporter.importSubscription(this, name, url, intervalMinutes, connectedOnly)
268268
} catch (e: Exception) {
269269
toast(getString(R.string.toast_import_failed, e.message ?: ""))
270270
return

app/src/main/java/top/uwu/mikubox/ui/ProxiesActivity.kt

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import kotlinx.coroutines.Dispatchers
88
import kotlinx.coroutines.async
99
import kotlinx.coroutines.awaitAll
1010
import kotlinx.coroutines.launch
11+
import kotlinx.coroutines.sync.Semaphore
12+
import kotlinx.coroutines.sync.withPermit
1113
import kotlinx.coroutines.withContext
1214
import top.uwu.mikubox.R
1315
import top.uwu.mikubox.core.MihomoCore
@@ -28,6 +30,7 @@ class ProxiesActivity : EdgeToEdgeActivity() {
2830
private var allProxies: Map<String, MihomoCore.Proxy> = emptyMap()
2931
private var groups: List<MihomoCore.Proxy> = emptyList()
3032
private val delayCache = mutableMapOf<String, Int>()
33+
private var sortByDelay = false
3134

3235
override fun onCreate(savedInstanceState: Bundle?) {
3336
super.onCreate(savedInstanceState)
@@ -37,11 +40,18 @@ class ProxiesActivity : EdgeToEdgeActivity() {
3740

3841
binding.toolbar.setNavigationOnClickListener { finish() }
3942
binding.toolbar.setOnMenuItemClickListener { item ->
40-
if (item.itemId == R.id.action_test_delay) {
41-
testCurrentGroup()
42-
true
43-
} else {
44-
false
43+
when (item.itemId) {
44+
R.id.action_test_delay -> {
45+
testCurrentGroup()
46+
true
47+
}
48+
R.id.action_sort -> {
49+
sortByDelay = !sortByDelay
50+
item.setTitle(if (sortByDelay) R.string.sort_by_name else R.string.proxies_sort_delay)
51+
renderGroup(binding.groupTab.selectedTabPosition)
52+
true
53+
}
54+
else -> false
4555
}
4656
}
4757

@@ -94,7 +104,13 @@ class ProxiesActivity : EdgeToEdgeActivity() {
94104
selected = memberName == group.now,
95105
)
96106
}
97-
adapter.submit(nodes)
107+
// Reachable nodes first (ascending latency); untested/testing/timeout sink to the bottom.
108+
val ordered = if (sortByDelay) {
109+
nodes.sortedBy { if (it.delay >= 0) it.delay else Int.MAX_VALUE }
110+
} else {
111+
nodes
112+
}
113+
adapter.submit(ordered)
98114
}
99115

100116
private fun selectNode(nodeName: String) {
@@ -118,16 +134,24 @@ class ProxiesActivity : EdgeToEdgeActivity() {
118134
val group = groups.getOrNull(index) ?: return
119135
val members = group.all
120136
if (members.isEmpty()) return
121-
toast(getString(R.string.proxies_testing))
137+
val testUrl = MihomoCoreSettings.testUrl(this)
138+
val timeout = MihomoCoreSettings.testTimeout(this)
139+
// Show a per-node testing state, then stream results in as each probe returns.
140+
members.forEach { delayCache[it] = -3 }
141+
renderGroup(index)
122142
lifecycleScope.launch {
123-
withContext(Dispatchers.IO) {
124-
val testUrl = MihomoCoreSettings.testUrl(this@ProxiesActivity)
125-
val timeout = MihomoCoreSettings.testTimeout(this@ProxiesActivity)
126-
members.map { name -> async { name to MihomoCore.delay(name, testUrl, timeout) } }
127-
.awaitAll()
128-
.forEach { (name, result) -> delayCache[name] = if (result < 0) -1 else result }
129-
}
130-
renderGroup(index)
143+
val gate = Semaphore(16)
144+
members.map { name ->
145+
async(Dispatchers.IO) {
146+
gate.withPermit {
147+
val result = MihomoCore.delay(name, testUrl, timeout)
148+
val value = if (result < 0) -1 else result
149+
delayCache[name] = value
150+
withContext(Dispatchers.Main) { adapter.updateDelay(name, value) }
151+
}
152+
}
153+
}.awaitAll()
154+
if (sortByDelay) renderGroup(index)
131155
}
132156
}
133157

app/src/main/java/top/uwu/mikubox/ui/ProxyNodeAdapter.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class ProxyNodeAdapter(
1414
private val onSelect: (String) -> Unit,
1515
) : RecyclerView.Adapter<ProxyNodeAdapter.VH>() {
1616

17-
/** [delay]: -2 untested, -1 timeout, otherwise milliseconds. */
17+
/** [delay]: -3 testing, -2 untested, -1 timeout, otherwise milliseconds. */
1818
data class Node(val name: String, val type: String, val delay: Int, val selected: Boolean)
1919

2020
private var nodes: List<Node> = emptyList()
@@ -25,6 +25,14 @@ class ProxyNodeAdapter(
2525
notifyDataSetChanged()
2626
}
2727

28+
/** Streams a single node's latency result without rebuilding the whole list. */
29+
fun updateDelay(name: String, delay: Int) {
30+
val index = nodes.indexOfFirst { it.name == name }
31+
if (index < 0) return
32+
nodes = nodes.toMutableList().also { it[index] = it[index].copy(delay = delay) }
33+
notifyItemChanged(index)
34+
}
35+
2836
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
2937
VH(LayoutInflater.from(parent.context).inflate(R.layout.item_proxy_node, parent, false))
3038

@@ -46,6 +54,10 @@ class ProxyNodeAdapter(
4654
selected.visibility = if (node.selected) View.VISIBLE else View.INVISIBLE
4755
selectedBar.visibility = if (node.selected) View.VISIBLE else View.INVISIBLE
4856
when {
57+
node.delay == -3 -> {
58+
delay.text = "···"
59+
delay.setTextColor(ContextCompat.getColor(ctx, R.color.miku_orange))
60+
}
4961
node.delay == -2 -> {
5062
delay.text = ""
5163
}

app/src/main/res/layout/layout_add_sheet.xml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,34 @@
129129
android:maxLines="1" />
130130
</com.google.android.material.textfield.TextInputLayout>
131131

132+
<com.google.android.material.textfield.TextInputLayout
133+
android:id="@+id/til_interval"
134+
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
135+
android:layout_width="match_parent"
136+
android:layout_height="wrap_content"
137+
android:layout_marginHorizontal="16dp"
138+
android:layout_marginTop="8dp"
139+
android:hint="@string/subscription_update_interval">
140+
141+
<com.google.android.material.textfield.MaterialAutoCompleteTextView
142+
android:id="@+id/dropdown_interval"
143+
android:layout_width="match_parent"
144+
android:layout_height="wrap_content"
145+
android:inputType="none"
146+
android:maxLines="1" />
147+
</com.google.android.material.textfield.TextInputLayout>
148+
149+
<com.google.android.material.materialswitch.MaterialSwitch
150+
android:id="@+id/switch_connected_only"
151+
android:layout_width="match_parent"
152+
android:layout_height="wrap_content"
153+
android:layout_marginHorizontal="16dp"
154+
android:layout_marginTop="10dp"
155+
android:minHeight="48dp"
156+
android:paddingVertical="4dp"
157+
android:text="@string/subscription_update_when_connected"
158+
android:textColor="?attr/colorOnSurface" />
159+
132160
<LinearLayout
133161
android:id="@+id/btn_add_sub"
134162
android:layout_width="match_parent"

app/src/main/res/menu/menu_proxies.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@
66
android:icon="@drawable/ic_baseline_speed_24"
77
android:title="@string/proxies_test"
88
app:showAsAction="ifRoom" />
9+
<item
10+
android:id="@+id/action_sort"
11+
android:title="@string/proxies_sort_delay"
12+
app:showAsAction="never" />
913
</menu>

app/src/main/res/values-fr/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<string name="proxies_empty_disconnected">Connectez-vous d\'abord pour charger et gérer les nœuds.</string>
2929
<string name="proxies_empty_none">Le profil actif n\'a aucun groupe sélectionnable.</string>
3030
<string name="proxies_test">Tester la latence</string>
31+
<string name="proxies_sort_delay">Trier par latence</string>
3132
<string name="proxies_testing">Test en cours…</string>
3233
<string name="proxies_delay_ms">%1$d ms</string>
3334
<string name="proxies_delay_timeout">expiré</string>

app/src/main/res/values-in/strings.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@
2929
<string name="action_disconnect">Putuskan</string>
3030
<string name="hint_subscription_url">URL langganan (Clash)</string>
3131
<string name="hint_profile_name">Nama (opsional)</string>
32+
<string name="subscription_update_interval">Interval pembaruan otomatis</string>
33+
<string name="subscription_update_when_connected">Perbarui hanya saat terhubung</string>
34+
<string-array name="subscription_intervals">
35+
<item>Setiap 6 jam</item>
36+
<item>Setiap 12 jam</item>
37+
<item>Setiap 24 jam</item>
38+
<item>Setiap 3 hari</item>
39+
<item>Setiap 7 hari</item>
40+
</string-array>
3241
<string name="action_add_profile">Tambah profil</string>
3342
<string name="action_add_subscription">Tambah &amp; ambil langganan</string>
3443
<string name="action_import_clipboard">Impor konfigurasi dari papan klip</string>
@@ -130,6 +139,7 @@
130139
<string name="proxies_empty_disconnected">Sambungkan dulu untuk memuat dan mengelola node.</string>
131140
<string name="proxies_empty_none">Profil aktif tidak punya grup yang bisa dipilih.</string>
132141
<string name="proxies_test">Uji latensi</string>
142+
<string name="proxies_sort_delay">Urutkan berdasarkan latensi</string>
133143
<string name="proxies_testing">Menguji…</string>
134144
<string name="proxies_delay_ms">%1$d ms</string>
135145
<string name="proxies_delay_timeout">waktu habis</string>

0 commit comments

Comments
 (0)