minimize connection test

This commit is contained in:
armv9 2025-09-04 16:23:02 +09:00
parent a3e529cb19
commit d2afb9df0e
7 changed files with 96 additions and 44 deletions

View File

@ -180,6 +180,10 @@ class SagerNet : Application(),
"service-subscription", "service-subscription",
application.getText(R.string.service_subscription), application.getText(R.string.service_subscription),
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
), NotificationChannel(
"connection-test",
application.getText(R.string.connection_test),
NotificationManager.IMPORTANCE_DEFAULT
) )
) )
) )

View File

@ -17,7 +17,6 @@ import io.nekohasekai.sagernet.ktx.int
import io.nekohasekai.sagernet.ktx.long import io.nekohasekai.sagernet.ktx.long
import io.nekohasekai.sagernet.ktx.parsePort import io.nekohasekai.sagernet.ktx.parsePort
import io.nekohasekai.sagernet.ktx.string import io.nekohasekai.sagernet.ktx.string
import io.nekohasekai.sagernet.ktx.stringSet
import io.nekohasekai.sagernet.ktx.stringToInt import io.nekohasekai.sagernet.ktx.stringToInt
import io.nekohasekai.sagernet.ktx.stringToIntIfExists import io.nekohasekai.sagernet.ktx.stringToIntIfExists
import moe.matsuri.nb4a.TempDatabase import moe.matsuri.nb4a.TempDatabase
@ -41,6 +40,10 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var vpnService: VpnService? = null var vpnService: VpnService? = null
var baseService: BaseService.Interface? = null var baseService: BaseService.Interface? = null
// main
var runningTest = false
fun currentGroupId(): Long { fun currentGroupId(): Long {
val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1) val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1)
if (currentSelected > 0L) return currentSelected if (currentSelected > 0L) return currentSelected

View File

@ -1,8 +1,8 @@
package io.nekohasekai.sagernet.ui package io.nekohasekai.sagernet.ui
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock import android.os.SystemClock
import android.provider.OpenableColumns import android.provider.OpenableColumns
@ -92,12 +92,12 @@ import io.nekohasekai.sagernet.ui.profile.WireGuardSettingsActivity
import io.nekohasekai.sagernet.widget.QRCodeDialog import io.nekohasekai.sagernet.widget.QRCodeDialog
import io.nekohasekai.sagernet.widget.UndoSnackbarManager import io.nekohasekai.sagernet.widget.UndoSnackbarManager
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.newFixedThreadPoolContext
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import moe.matsuri.nb4a.Protocols import moe.matsuri.nb4a.Protocols
@ -106,7 +106,6 @@ import moe.matsuri.nb4a.proxy.anytls.AnyTLSSettingsActivity
import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity
import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSSettingsActivity import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSSettingsActivity
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.UnknownHostException import java.net.UnknownHostException
@ -115,6 +114,8 @@ import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import kotlin.collections.set import kotlin.collections.set
import androidx.core.net.toUri
import moe.matsuri.nb4a.ui.ConnectionTestNotification
class ConfigurationFragment @JvmOverloads constructor( class ConfigurationFragment @JvmOverloads constructor(
val select: Boolean = false, val selectedItem: ProxyEntity? = null, val titleRes: Int = 0 val select: Boolean = false, val selectedItem: ProxyEntity? = null, val titleRes: Int = 0
@ -160,6 +161,7 @@ class ConfigurationFragment @JvmOverloads constructor(
override fun onQueryTextSubmit(query: String): Boolean = false override fun onQueryTextSubmit(query: String): Boolean = false
@SuppressLint("DetachAndAttachSameFragment")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -317,7 +319,7 @@ class ConfigurationFragment @JvmOverloads constructor(
snackbar(getString(R.string.no_proxies_found_in_file)).show() snackbar(getString(R.string.no_proxies_found_in_file)).show()
} else import(proxies) } else import(proxies)
} catch (e: SubscriptionFoundException) { } catch (e: SubscriptionFoundException) {
(requireActivity() as MainActivity).importSubscription(Uri.parse(e.link)) (requireActivity() as MainActivity).importSubscription(e.link.toUri())
} catch (e: Exception) { } catch (e: Exception) {
Logs.w(e) Logs.w(e)
onMainDispatcher { onMainDispatcher {
@ -360,7 +362,7 @@ class ConfigurationFragment @JvmOverloads constructor(
snackbar(getString(R.string.no_proxies_found_in_clipboard)).show() snackbar(getString(R.string.no_proxies_found_in_clipboard)).show()
} else import(proxies) } else import(proxies)
} catch (e: SubscriptionFoundException) { } catch (e: SubscriptionFoundException) {
(requireActivity() as MainActivity).importSubscription(Uri.parse(e.link)) (requireActivity() as MainActivity).importSubscription(e.link.toUri())
} catch (e: Exception) { } catch (e: Exception) {
Logs.w(e) Logs.w(e)
@ -597,15 +599,19 @@ class ConfigurationFragment @JvmOverloads constructor(
inner class TestDialog { inner class TestDialog {
val binding = LayoutProgressListBinding.inflate(layoutInflater) val binding = LayoutProgressListBinding.inflate(layoutInflater)
val builder = MaterialAlertDialogBuilder(requireContext()).setView(binding.root) val builder = MaterialAlertDialogBuilder(requireContext()).setView(binding.root)
.setNegativeButton(android.R.string.cancel) { _, _ -> .setPositiveButton(R.string.minimize) { _, _ ->
cancel() minimize()
} }
.setOnDismissListener { .setNegativeButton(android.R.string.cancel) { _, _ ->
cancel() cancel()
} }
.setCancelable(false) .setCancelable(false)
lateinit var cancel: () -> Unit lateinit var cancel: () -> Unit
lateinit var minimize: () -> Unit
var notification: ConnectionTestNotification? = null
val fragment by lazy { getCurrentGroupFragment() } val fragment by lazy { getCurrentGroupFragment() }
val results = Collections.synchronizedList(mutableListOf<ProxyEntity?>()) val results = Collections.synchronizedList(mutableListOf<ProxyEntity?>())
var proxyN = 0 var proxyN = 0
@ -615,10 +621,10 @@ class ConfigurationFragment @JvmOverloads constructor(
results.add(profile) results.add(profile)
} }
suspend fun update(profile: ProxyEntity) { fun update(profile: ProxyEntity) {
fragment?.configurationListView?.post { runOnMainDispatcher {
val context = context ?: return@post val context = context ?: return@runOnMainDispatcher
if (!isAdded) return@post if (!isAdded) return@runOnMainDispatcher
var profileStatusText: String? = null var profileStatusText: String? = null
var profileStatusColor = 0 var profileStatusColor = 0
@ -669,38 +675,31 @@ class ConfigurationFragment @JvmOverloads constructor(
append("\n") append("\n")
} }
val progress = finishedN.addAndGet(1)
binding.nowTesting.text = text binding.nowTesting.text = text
binding.progress.text = "${finishedN.addAndGet(1)} / $proxyN" binding.progress.text = "$progress / $proxyN"
notification?.updateNotification(progress, proxyN, progress >= proxyN)
} }
} }
} }
fun stopService() {
if (DataStore.serviceState.started) SagerNet.stopService()
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
@Suppress("EXPERIMENTAL_API_USAGE") @Suppress("EXPERIMENTAL_API_USAGE")
fun pingTest(icmpPing: Boolean) { fun pingTest(icmpPing: Boolean) {
if (DataStore.runningTest) return else DataStore.runningTest = true
val test = TestDialog() val test = TestDialog()
val testJobs = mutableListOf<Job>()
val dialog = test.builder.show() val dialog = test.builder.show()
val testJobs = mutableListOf<Job>()
val group = DataStore.currentGroup()
val mainJob = runOnDefaultDispatcher { val mainJob = runOnDefaultDispatcher {
if (DataStore.serviceState.started) {
stopService()
delay(500) // wait for service stop
}
val group = DataStore.currentGroup()
val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id) val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id)
test.proxyN = profilesUnfiltered.size test.proxyN = profilesUnfiltered.size
val profiles = ConcurrentLinkedQueue(profilesUnfiltered) val profiles = ConcurrentLinkedQueue(profilesUnfiltered)
val testPool = newFixedThreadPoolContext(
DataStore.connectionTestConcurrent,
"pingTest"
)
repeat(DataStore.connectionTestConcurrent) { repeat(DataStore.connectionTestConcurrent) {
testJobs.add(launch(testPool) { testJobs.add(launch(Dispatchers.IO) {
while (isActive) { while (isActive) {
val profile = profiles.poll() ?: break val profile = profiles.poll() ?: break
@ -727,7 +726,7 @@ class ConfigurationFragment @JvmOverloads constructor(
var address = profile.requireBean().serverAddress var address = profile.requireBean().serverAddress
if (!address.isIpAddress()) { if (!address.isIpAddress()) {
try { try {
InetAddress.getAllByName(address).apply { SagerNet.underlyingNetwork!!.getAllByName(address).apply {
if (isNotEmpty()) { if (isNotEmpty()) {
address = this[0].hostAddress address = this[0].hostAddress
} }
@ -746,7 +745,9 @@ class ConfigurationFragment @JvmOverloads constructor(
if (icmpPing) { if (icmpPing) {
// removed // removed
} else { } else {
val socket = Socket() val socket =
SagerNet.underlyingNetwork?.socketFactory?.createSocket()
?: Socket()
try { try {
socket.soTimeout = 3000 socket.soTimeout = 3000
socket.bind(InetSocketAddress(0)) socket.bind(InetSocketAddress(0))
@ -802,13 +803,13 @@ class ConfigurationFragment @JvmOverloads constructor(
} }
testJobs.joinAll() testJobs.joinAll()
testPool.close()
onMainDispatcher { runOnMainDispatcher {
dialog.dismiss() test.cancel()
} }
} }
test.cancel = { test.cancel = {
dialog.dismiss()
runOnDefaultDispatcher { runOnDefaultDispatcher {
test.results.filterNotNull().forEach { test.results.filterNotNull().forEach {
try { try {
@ -820,27 +821,32 @@ class ConfigurationFragment @JvmOverloads constructor(
GroupManager.postReload(DataStore.currentGroupId()) GroupManager.postReload(DataStore.currentGroupId())
mainJob.cancel() mainJob.cancel()
testJobs.forEach { it.cancel() } testJobs.forEach { it.cancel() }
DataStore.runningTest = false
} }
} }
test.minimize = {
test.notification = ConnectionTestNotification(
dialog.context,
"[${group.displayName()}] ${getString(R.string.connection_test)}"
)
dialog.hide()
}
} }
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun urlTest() { fun urlTest() {
if (DataStore.runningTest) return else DataStore.runningTest = true
val test = TestDialog() val test = TestDialog()
val dialog = test.builder.show() val dialog = test.builder.show()
val testJobs = mutableListOf<Job>() val testJobs = mutableListOf<Job>()
val group = DataStore.currentGroup()
val mainJob = runOnDefaultDispatcher { val mainJob = runOnDefaultDispatcher {
val group = DataStore.currentGroup()
val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id) val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id)
test.proxyN = profilesUnfiltered.size test.proxyN = profilesUnfiltered.size
val profiles = ConcurrentLinkedQueue(profilesUnfiltered) val profiles = ConcurrentLinkedQueue(profilesUnfiltered)
val testPool = newFixedThreadPoolContext(
DataStore.connectionTestConcurrent,
"urlTest"
)
repeat(DataStore.connectionTestConcurrent) { repeat(DataStore.connectionTestConcurrent) {
testJobs.add(launch(testPool) { testJobs.add(launch(Dispatchers.IO) {
val urlTest = UrlTest() // note: this is NOT in bg process val urlTest = UrlTest() // note: this is NOT in bg process
while (isActive) { while (isActive) {
val profile = profiles.poll() ?: break val profile = profiles.poll() ?: break
@ -866,11 +872,12 @@ class ConfigurationFragment @JvmOverloads constructor(
testJobs.joinAll() testJobs.joinAll()
onMainDispatcher { runOnMainDispatcher {
dialog.dismiss() test.cancel()
} }
} }
test.cancel = { test.cancel = {
dialog.dismiss()
runOnDefaultDispatcher { runOnDefaultDispatcher {
test.results.filterNotNull().forEach { test.results.filterNotNull().forEach {
try { try {
@ -882,8 +889,16 @@ class ConfigurationFragment @JvmOverloads constructor(
GroupManager.postReload(DataStore.currentGroupId()) GroupManager.postReload(DataStore.currentGroupId())
mainJob.cancel() mainJob.cancel()
testJobs.forEach { it.cancel() } testJobs.forEach { it.cancel() }
DataStore.runningTest = false
} }
} }
test.minimize = {
test.notification = ConnectionTestNotification(
dialog.context,
"[${group.displayName()}] ${getString(R.string.connection_test)}"
)
dialog.hide()
}
} }
inner class GroupPagerAdapter : FragmentStateAdapter(this), inner class GroupPagerAdapter : FragmentStateAdapter(this),
@ -1412,7 +1427,6 @@ class ConfigurationFragment @JvmOverloads constructor(
fun reloadProfiles() { fun reloadProfiles() {
var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id)
val subscription = proxyGroup.subscription
when (proxyGroup.order) { when (proxyGroup.order) {
GroupOrder.BY_NAME -> { GroupOrder.BY_NAME -> {
newProfiles = newProfiles.sortedBy { it.displayName() } newProfiles = newProfiles.sortedBy { it.displayName() }

View File

@ -0,0 +1,29 @@
package moe.matsuri.nb4a.ui
import android.content.Context
import androidx.core.app.NotificationCompat
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.ktx.Logs
class ConnectionTestNotification(val context: Context, val title: String) {
private val channelId = "connection-test"
private val notificationId = 1001
fun updateNotification(progress: Int, max: Int, finished: Boolean) {
try {
if (finished) {
SagerNet.notification.cancel(notificationId)
return
}
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_service_active)
.setContentTitle(title)
.setOnlyAlertOnce(true)
.setContentText("$progress / $max").setProgress(max, progress, false)
SagerNet.notification.notify(notificationId, builder.build())
} catch (e: Exception) {
Logs.w(e)
}
}
}

View File

@ -494,4 +494,5 @@
<string name="check_update_no">检查成功,但没有更新。</string> <string name="check_update_no">检查成功,但没有更新。</string>
<string name="reset_settings">恢复默认设置</string> <string name="reset_settings">恢复默认设置</string>
<string name="reset_settings_message">恢复默认设置,但节点、分组等数据将保留。如需完全清除数据,请在系统设置中直接清除应用数据。</string> <string name="reset_settings_message">恢复默认设置,但节点、分组等数据将保留。如需完全清除数据,请在系统设置中直接清除应用数据。</string>
<string name="minimize">最小化</string>
</resources> </resources>

View File

@ -574,4 +574,5 @@
<string name="check_update_no">Check successful, but no updates.</string> <string name="check_update_no">Check successful, but no updates.</string>
<string name="reset_settings">Restore default settings</string> <string name="reset_settings">Restore default settings</string>
<string name="reset_settings_message">Restore default settings, but data such as nodes and groups will be retained. To completely clear data, clear application data directly in the system settings.</string> <string name="reset_settings_message">Restore default settings, but data such as nodes and groups will be retained. To completely clear data, clear application data directly in the system settings.</string>
<string name="minimize">Minimize</string>
</resources> </resources>

View File

@ -1,4 +1,4 @@
PACKAGE_NAME=moe.nb4a PACKAGE_NAME=moe.nb4a
VERSION_NAME=1.3.9 VERSION_NAME=1.3.9
PRE_VERSION_NAME=pre-1.4.0-20250904-1 PRE_VERSION_NAME=pre-1.4.0-20250904-2
VERSION_CODE=43 VERSION_CODE=43