Skip to content

Commit b6fd50c

Browse files
committed
feat: persist Android speed test results
1 parent b378866 commit b6fd50c

7 files changed

Lines changed: 344 additions & 54 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package io.nekohasekai.sagernet
2+
3+
enum class SpeedTestDirection {
4+
DOWNLOAD,
5+
UPLOAD,
6+
}
7+
8+
data class SpeedTestRate(
9+
val direction: SpeedTestDirection,
10+
val bitsPerSecond: Long,
11+
)
12+
13+
data class SpeedTestOutcome(
14+
val mode: String,
15+
val downloadBitsPerSecond: Long,
16+
val uploadBitsPerSecond: Long,
17+
) {
18+
19+
fun rates(): List<SpeedTestRate> = when (mode) {
20+
SpeedTestSettings.MODE_DOWNLOAD_UPLOAD -> listOf(
21+
SpeedTestRate(SpeedTestDirection.DOWNLOAD, downloadBitsPerSecond),
22+
SpeedTestRate(SpeedTestDirection.UPLOAD, uploadBitsPerSecond),
23+
)
24+
25+
SpeedTestSettings.MODE_DOWNLOAD,
26+
SpeedTestSettings.MODE_SIMPLE_DOWNLOAD ->
27+
listOf(SpeedTestRate(SpeedTestDirection.DOWNLOAD, downloadBitsPerSecond))
28+
29+
SpeedTestSettings.MODE_UPLOAD ->
30+
listOf(SpeedTestRate(SpeedTestDirection.UPLOAD, uploadBitsPerSecond))
31+
32+
else -> emptyList()
33+
}
34+
35+
companion object {
36+
fun completedOrNull(
37+
mode: String,
38+
stage: String,
39+
done: Boolean,
40+
cancelled: Boolean,
41+
error: String,
42+
downloadBitsPerSecond: Long,
43+
uploadBitsPerSecond: Long,
44+
): SpeedTestOutcome? {
45+
if (!done || cancelled || error.isNotBlank() || stage != "complete") return null
46+
if (!SpeedTestSettings.isValidMode(mode)) return null
47+
return SpeedTestOutcome(
48+
mode = mode,
49+
downloadBitsPerSecond = downloadBitsPerSecond.coerceAtLeast(0),
50+
uploadBitsPerSecond = uploadBitsPerSecond.coerceAtLeast(0),
51+
)
52+
}
53+
}
54+
}

app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ data class ProxyEntity(
6161
var ping: Int = 0,
6262
var uuid: String = "",
6363
var error: String? = null,
64+
@ColumnInfo(defaultValue = "''") var speedTestMode: String = "",
65+
@ColumnInfo(defaultValue = "0") var speedTestDownloadBitsPerSecond: Long = 0L,
66+
@ColumnInfo(defaultValue = "0") var speedTestUploadBitsPerSecond: Long = 0L,
6467
var socksBean: SOCKSBean? = null,
6568
var httpBean: HttpBean? = null,
6669
var ssBean: ShadowsocksBean? = null,
@@ -132,7 +135,7 @@ data class ProxyEntity(
132135
}
133136

134137
override fun serializeToBuffer(output: ByteBufferOutput) {
135-
output.writeInt(0)
138+
output.writeInt(1)
136139

137140
output.writeLong(id)
138141
output.writeLong(groupId)
@@ -144,6 +147,9 @@ data class ProxyEntity(
144147
output.writeInt(ping)
145148
output.writeString(uuid)
146149
output.writeString(error)
150+
output.writeString(speedTestMode)
151+
output.writeLong(speedTestDownloadBitsPerSecond)
152+
output.writeLong(speedTestUploadBitsPerSecond)
147153

148154
val data = KryoConverters.serialize(requireBean())
149155
output.writeVarInt(data.size, true)
@@ -165,6 +171,11 @@ data class ProxyEntity(
165171
ping = input.readInt()
166172
uuid = input.readString()
167173
error = input.readString()
174+
if (version >= 1) {
175+
speedTestMode = input.readString() ?: ""
176+
speedTestDownloadBitsPerSecond = input.readLong()
177+
speedTestUploadBitsPerSecond = input.readLong()
178+
}
168179
putByteArray(input.readBytes(input.readVarInt(true)))
169180

170181
dirty = input.readBoolean()
@@ -625,6 +636,32 @@ data class ProxyEntity(
625636
@Query("UPDATE proxy_entities SET rx = 0, tx = 0 WHERE id IN (:profileIds)")
626637
fun resetTraffic(profileIds: LongArray): Int
627638

639+
@Query(
640+
"""UPDATE proxy_entities SET
641+
speedTestMode = :mode,
642+
speedTestDownloadBitsPerSecond = :downloadBitsPerSecond,
643+
speedTestUploadBitsPerSecond = :uploadBitsPerSecond
644+
WHERE id = :proxyId"""
645+
)
646+
fun updateSpeedTestResult(
647+
proxyId: Long,
648+
mode: String,
649+
downloadBitsPerSecond: Long,
650+
uploadBitsPerSecond: Long,
651+
): Int
652+
653+
@Query(
654+
"""UPDATE proxy_entities SET
655+
status = 0,
656+
ping = 0,
657+
error = NULL,
658+
speedTestMode = '',
659+
speedTestDownloadBitsPerSecond = 0,
660+
speedTestUploadBitsPerSecond = 0
661+
WHERE groupId = :groupId"""
662+
)
663+
fun clearTestResults(groupId: Long): Int
664+
628665
@Insert
629666
fun addProxy(proxy: ProxyEntity): Long
630667

app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ import kotlinx.coroutines.launch
1616

1717
@Database(
1818
entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class],
19-
version = 8,
19+
version = 9,
2020
autoMigrations = [
2121
AutoMigration(from = 3, to = 4),
2222
AutoMigration(from = 4, to = 5),
2323
AutoMigration(from = 5, to = 6),
2424
AutoMigration(from = 6, to = 7),
25-
AutoMigration(from = 7, to = 8)
25+
AutoMigration(from = 7, to = 8),
26+
AutoMigration(from = 8, to = 9),
2627
]
2728
)
2829
@TypeConverters(value = [KryoConverters::class, GsonConverters::class])

app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt

Lines changed: 107 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ import io.nekohasekai.sagernet.GroupType
4949
import io.nekohasekai.sagernet.Key
5050
import io.nekohasekai.sagernet.R
5151
import io.nekohasekai.sagernet.SagerNet
52+
import io.nekohasekai.sagernet.SpeedTestDirection
53+
import io.nekohasekai.sagernet.SpeedTestOutcome
5254
import io.nekohasekai.sagernet.aidl.TrafficData
5355
import io.nekohasekai.sagernet.bg.BaseService
5456
import io.nekohasekai.sagernet.bg.proto.AndroidSpeedTestSession
@@ -705,19 +707,7 @@ class ConfigurationFragment @JvmOverloads constructor(
705707

706708
R.id.action_connection_test_clear_results -> {
707709
runOnDefaultDispatcher {
708-
val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId())
709-
val toClear = mutableListOf<ProxyEntity>()
710-
if (profiles.isNotEmpty()) for (profile in profiles) {
711-
if (profile.status != 0) {
712-
profile.status = 0
713-
profile.ping = 0
714-
profile.error = null
715-
toClear.add(profile)
716-
}
717-
}
718-
if (toClear.isNotEmpty()) {
719-
ProfileManager.updateProfile(toClear)
720-
}
710+
SagerDatabase.proxyDao.clearTestResults(DataStore.currentGroupId())
721711
onMainDispatcher {
722712
getCurrentGroupFragment()?.adapter?.clearTestResults()
723713
}
@@ -927,6 +917,28 @@ class ConfigurationFragment @JvmOverloads constructor(
927917
return@runOnDefaultDispatcher
928918
}
929919
val results = runner.run(profiles) { index, total, sample ->
920+
val outcome = SpeedTestOutcome.completedOrNull(
921+
mode = sample.mode,
922+
stage = sample.stage,
923+
done = sample.done,
924+
cancelled = sample.cancelled,
925+
error = sample.error,
926+
downloadBitsPerSecond = sample.downloadBitsPerSecond,
927+
uploadBitsPerSecond = sample.uploadBitsPerSecond,
928+
)
929+
if (outcome != null && SagerDatabase.proxyDao.updateSpeedTestResult(
930+
proxyId = sample.profileId,
931+
mode = outcome.mode,
932+
downloadBitsPerSecond = outcome.downloadBitsPerSecond,
933+
uploadBitsPerSecond = outcome.uploadBitsPerSecond,
934+
) > 0
935+
) {
936+
runOnMainDispatcher {
937+
adapter.groupFragments.values.forEach { fragment ->
938+
fragment.adapter?.updateSpeedTestResult(sample.profileId, outcome)
939+
}
940+
}
941+
}
930942
runOnMainDispatcher {
931943
val detail = formatSpeedTestSnapshot(sample)
932944
speedTestNotification?.updateNotification(index + 1, total, false, detail)
@@ -2096,15 +2108,25 @@ class ConfigurationFragment @JvmOverloads constructor(
20962108

20972109
fun clearTestResults() {
20982110
for (profile in configurationList.values) {
2099-
if (profile.status != 0) {
2100-
profile.status = 0
2101-
profile.ping = 0
2102-
profile.error = null
2103-
}
2111+
profile.status = 0
2112+
profile.ping = 0
2113+
profile.error = null
2114+
profile.speedTestMode = ""
2115+
profile.speedTestDownloadBitsPerSecond = 0
2116+
profile.speedTestUploadBitsPerSecond = 0
21042117
}
21052118
notifyDataSetChanged()
21062119
}
21072120

2121+
fun updateSpeedTestResult(profileId: Long, outcome: SpeedTestOutcome) {
2122+
val profile = configurationList[profileId] ?: return
2123+
profile.speedTestMode = outcome.mode
2124+
profile.speedTestDownloadBitsPerSecond = outcome.downloadBitsPerSecond
2125+
profile.speedTestUploadBitsPerSecond = outcome.uploadBitsPerSecond
2126+
val index = configurationIdList.indexOf(profileId)
2127+
if (index >= 0) notifyItemChanged(index)
2128+
}
2129+
21082130
fun remove(pos: Int) {
21092131
if (pos < 0) return
21102132
configurationIdList.removeAt(pos)
@@ -2485,6 +2507,67 @@ class ConfigurationFragment @JvmOverloads constructor(
24852507
}
24862508
}
24872509

2510+
private fun speedTestResultText(proxyEntity: ProxyEntity): String? {
2511+
val outcome = SpeedTestOutcome(
2512+
mode = proxyEntity.speedTestMode,
2513+
downloadBitsPerSecond = proxyEntity.speedTestDownloadBitsPerSecond,
2514+
uploadBitsPerSecond = proxyEntity.speedTestUploadBitsPerSecond,
2515+
)
2516+
val rates = outcome.rates()
2517+
if (rates.isEmpty()) return null
2518+
return rates.joinToString(" ") { rate ->
2519+
val direction = when (rate.direction) {
2520+
SpeedTestDirection.DOWNLOAD -> ""
2521+
SpeedTestDirection.UPLOAD -> ""
2522+
}
2523+
"$direction ${getString(R.string.speed_test_rate_mbps, rate.bitsPerSecond / 1_000_000.0)}"
2524+
}
2525+
}
2526+
2527+
private fun bindTestResult(
2528+
proxyEntity: ProxyEntity,
2529+
showTraffic: Boolean,
2530+
speedTestText: String?,
2531+
) {
2532+
val text = SpannableStringBuilder()
2533+
fun appendPart(value: CharSequence?, color: Int) {
2534+
if (value.isNullOrEmpty()) return
2535+
if (text.isNotEmpty()) text.append('\n')
2536+
val start = text.length
2537+
text.append(value)
2538+
text.setSpan(ForegroundColorSpan(color), start, text.length, SPAN_EXCLUSIVE_EXCLUSIVE)
2539+
}
2540+
2541+
val secondary = requireContext().getColorAttr(android.R.attr.textColorSecondary)
2542+
when (proxyEntity.status) {
2543+
1 -> appendPart(
2544+
getString(R.string.available, proxyEntity.ping),
2545+
requireContext().getColour(R.color.material_green_500),
2546+
)
2547+
2548+
2 -> appendPart(
2549+
proxyEntity.error,
2550+
requireContext().getColour(R.color.material_red_500),
2551+
)
2552+
2553+
3 -> {
2554+
val error = proxyEntity.error ?: "<?>"
2555+
val friendly = Protocols.genFriendlyMsg(error)
2556+
appendPart(
2557+
if (friendly != error) friendly else getString(R.string.unavailable),
2558+
requireContext().getColour(R.color.material_red_500),
2559+
)
2560+
}
2561+
2562+
else -> if (speedTestText == null && showTraffic) {
2563+
appendPart(trafficText.text, secondary)
2564+
trafficText.text = ""
2565+
}
2566+
}
2567+
appendPart(speedTestText, secondary)
2568+
profileStatus.text = text
2569+
}
2570+
24882571
fun bind(proxyEntity: ProxyEntity) {
24892572
val pf = parentFragment as? ConfigurationFragment ?: return
24902573

@@ -2497,6 +2580,7 @@ class ConfigurationFragment @JvmOverloads constructor(
24972580

24982581
val rx = proxyEntity.rx
24992582
val tx = proxyEntity.tx
2583+
val speedTestText = speedTestResultText(proxyEntity)
25002584

25012585
val showTraffic = rx + tx != 0L
25022586
trafficText.isVisible = showTraffic
@@ -2525,29 +2609,7 @@ class ConfigurationFragment @JvmOverloads constructor(
25252609
}
25262610
lastSelfHasMiddleRow = !trafficRowEmpty
25272611

2528-
if (proxyEntity.status <= 0) {
2529-
if (showTraffic) {
2530-
profileStatus.text = trafficText.text
2531-
profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary))
2532-
trafficText.text = ""
2533-
} else {
2534-
profileStatus.text = ""
2535-
}
2536-
} else if (proxyEntity.status == 1) {
2537-
profileStatus.text = getString(R.string.available, proxyEntity.ping)
2538-
profileStatus.setTextColor(requireContext().getColour(R.color.material_green_500))
2539-
} else {
2540-
profileStatus.setTextColor(requireContext().getColour(R.color.material_red_500))
2541-
if (proxyEntity.status == 2) {
2542-
profileStatus.text = proxyEntity.error
2543-
}
2544-
}
2545-
2546-
if (proxyEntity.status == 3) {
2547-
val err = proxyEntity.error ?: "<?>"
2548-
val msg = Protocols.genFriendlyMsg(err)
2549-
profileStatus.text = if (msg != err) msg else getString(R.string.unavailable)
2550-
}
2612+
bindTestResult(proxyEntity, showTraffic, speedTestText)
25512613

25522614
val selectOrChain = select || proxyEntity.type == ProxyEntity.TYPE_CHAIN
25532615
val isDoubleColumn = layoutManager is FixedGridLayoutManager
@@ -2614,9 +2676,12 @@ class ConfigurationFragment @JvmOverloads constructor(
26142676
Formatter.formatFileSize(view.context, proxyEntity.tx),
26152677
Formatter.formatFileSize(view.context, proxyEntity.rx)
26162678
)
2617-
if (proxyEntity.status <= 0) {
2679+
if (proxyEntity.status <= 0 && speedTestResultText(proxyEntity) == null) {
26182680
if (profileStatus.text?.toString() != traffic) {
26192681
profileStatus.text = traffic
2682+
profileStatus.setTextColor(
2683+
requireContext().getColorAttr(android.R.attr.textColorSecondary)
2684+
)
26202685
}
26212686
} else if (trafficText.text?.toString() != traffic) {
26222687
trafficText.text = traffic

0 commit comments

Comments
 (0)