From dc9cd931fddfed583e3a168c4c1af3ecd72a3c42 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:08 -0400 Subject: [PATCH 01/26] fix(bg): unregister DefaultNetworkListener with the service key; use containsKey for callback dedupe --- app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt index 443b255c4..c11fd8e2a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -140,7 +140,7 @@ class BaseService { Runtime.getRuntime().exit(0) return } - if (!callbackIdMap.contains(cb)) { + if (!callbackIdMap.containsKey(cb)) { callbacks.register(cb) } callbackIdMap[cb] = id @@ -308,7 +308,7 @@ class BaseService { wakeLock = null } runOnDefaultDispatcher { - DefaultNetworkListener.stop(this) + DefaultNetworkListener.stop(this@Interface) } } From 4dcab91c57d0553a6b69735861a580ccb6375368 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:08 -0400 Subject: [PATCH 02/26] fix(bg): null-guard proxy in selector_OnProxySelected (teardown race) --- app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt index ae7ef21a9..df147c5ed 100644 --- a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt +++ b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt @@ -90,15 +90,14 @@ class NativeInterface : BoxPlatformInterface, NB4AInterface { Libcore.resetAllConnections(true) DataStore.baseService?.apply { runOnDefaultDispatcher { - val id = data.proxy!!.config.profileTagMap + val proxy = data.proxy ?: return@runOnDefaultDispatcher + val id = proxy.config.profileTagMap .filterValues { it == tag }.keys.firstOrNull() ?: -1 val ent = SagerDatabase.proxyDao.getById(id) ?: return@runOnDefaultDispatcher // traffic & title - data.proxy?.apply { - looper?.selectMain(id) - displayProfileName = ServiceNotification.genTitle(ent) - data.notification?.postNotificationTitle(displayProfileName) - } + proxy.looper?.selectMain(id) + proxy.displayProfileName = ServiceNotification.genTitle(ent) + data.notification?.postNotificationTitle(proxy.displayProfileName) // post binder data.binder.broadcast { b -> b.cbSelectorUpdate(id) From ba5d136897718e49eb0db4b7825183b54ba554f9 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:09 -0400 Subject: [PATCH 03/26] fix(db): scope selectedProxy reset to the affected group --- .../nekohasekai/sagernet/database/GroupManager.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt index e7c2c398f..dbf19e946 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt @@ -54,7 +54,10 @@ object GroupManager { } suspend fun clearGroup(groupId: Long) { - DataStore.selectedProxy = 0L + val selected = DataStore.selectedProxy + if (selected != 0L && SagerDatabase.proxyDao.getById(selected)?.groupId == groupId) { + DataStore.selectedProxy = 0L + } SagerDatabase.proxyDao.deleteAll(groupId) iterator { groupUpdated(groupId) } } @@ -98,6 +101,10 @@ object GroupManager { } suspend fun deleteGroup(groupId: Long) { + val selected = DataStore.selectedProxy + if (selected != 0L && SagerDatabase.proxyDao.getById(selected)?.groupId == groupId) { + DataStore.selectedProxy = 0L + } SagerDatabase.groupDao.deleteById(groupId) SagerDatabase.proxyDao.deleteByGroup(groupId) iterator { groupRemoved(groupId) } @@ -105,6 +112,11 @@ object GroupManager { } suspend fun deleteGroup(group: List) { + val ids = group.map { it.id }.toSet() + val selected = DataStore.selectedProxy + if (selected != 0L && SagerDatabase.proxyDao.getById(selected)?.groupId in ids) { + DataStore.selectedProxy = 0L + } SagerDatabase.groupDao.deleteGroup(group) SagerDatabase.proxyDao.deleteByGroup(group.map { it.id }.toLongArray()) for (proxyGroup in group) iterator { groupRemoved(proxyGroup.id) } From 5ad3d66c2b55a241a10cffdc68ae158b887a94d5 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:09 -0400 Subject: [PATCH 04/26] fix(group): route shadowed duplicate-name rows to delete on subscription update --- .../io/nekohasekai/sagernet/group/RawUpdater.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt index 2bc2861ff..0a75daa5f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -163,17 +163,18 @@ object RawUpdater : GroupUpdater() { Logs.d("Unique profiles: ${nameMap.size}") val toDelete = ArrayList() - val toReplace = exists.mapNotNull { entity -> + val toReplace = HashMap() + for (entity in exists) { val name = entity.displayName() - if (nameMap.contains(name)) { - name to entity + if (nameMap.contains(name) && !toReplace.containsKey(name)) { + // first existing row claiming this name -> replace target + toReplace[name] = entity } else { - let { - toDelete.add(entity) - null - } + // name not in the new set, OR a duplicate of an already-claimed + // name -> delete so the post-transaction count matches proxies.size + toDelete.add(entity) } - }.toMap() + } Logs.d("toDelete profiles: ${toDelete.size}") Logs.d("toReplace profiles: ${toReplace.size}") From 27eed08db67c08a7069ac1af356c694c4569c24d Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:09 -0400 Subject: [PATCH 05/26] fix(group): return instead of cancel() in executeUpdate; rethrow cancellation --- .../java/io/nekohasekai/sagernet/group/GroupUpdater.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt index 432c7888b..1d00a9642 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt @@ -134,7 +134,10 @@ abstract class GroupUpdater { suspend fun executeUpdate(proxyGroup: ProxyGroup, byUser: Boolean): Boolean { return coroutineScope { - if (!updating.add(proxyGroup.id)) cancel() + if (!updating.add(proxyGroup.id)) { + // already updating this group in another run; skip quietly + return@coroutineScope false + } GroupManager.postReload(proxyGroup.id) val subscription = proxyGroup.subscription!! @@ -152,7 +155,6 @@ abstract class GroupUpdater { ) ) { finishUpdate(proxyGroup) - cancel() return@coroutineScope true } } @@ -160,6 +162,9 @@ abstract class GroupUpdater { try { RawUpdater.doUpdate(proxyGroup, subscription, userInterface, byUser) true + } catch (e: CancellationException) { + finishUpdate(proxyGroup) + throw e } catch (e: Throwable) { Logs.w(e) userInterface?.onUpdateFailure(proxyGroup, e.readableMessage) From 0ff0b00045862eecd60f301f09b2e1c7041a409d Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:10 -0400 Subject: [PATCH 06/26] fix(group): fall back to default group instead of !! on missing ungrouped group --- .../java/io/nekohasekai/sagernet/database/DataStore.kt | 3 ++- .../main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index 1ffb18753..e0f06f627 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -104,7 +104,8 @@ object DataStore : OnPreferenceDataStoreChangeListener { val current = currentGroup() if (current.type == GroupType.BASIC) return current.id val groups = SagerDatabase.groupDao.allGroups() - return groups.find { it.type == GroupType.BASIC }!!.id + groups.find { it.type == GroupType.BASIC }?.let { return it.id } + return SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) } var appTLSVersion by configurationStore.string(Key.APP_TLS_VERSION) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt index 0e951bb4a..0875ff5b7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt @@ -164,11 +164,9 @@ class GroupFragment : suspend fun reload() { val groups = SagerDatabase.groupDao.allGroups().toMutableList() - if (groups.size > 1 && SagerDatabase.proxyDao.countByGroup( - groups.find { - it.ungrouped - }!!.id, - ) == 0L + val ungrouped = groups.find { it.ungrouped } + if (groups.size > 1 && ungrouped != null && + SagerDatabase.proxyDao.countByGroup(ungrouped.id) == 0L ) { groups.removeAll { it.ungrouped } } From d4ca557b1226d8257dd2103a6e48b9168b3284d6 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:10 -0400 Subject: [PATCH 07/26] fix(utils): swap PackageCache uidMap by reference; fix awaitLoadSync race --- .../sagernet/utils/PackageCache.kt | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt index f63b2f868..9f2f9a2b2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt @@ -19,7 +19,9 @@ object PackageCache { lateinit var installedPluginPackages: Map lateinit var installedApps: Map lateinit var packageMap: Map - val uidMap = HashMap>() + + @Volatile + var uidMap: Map> = emptyMap() val loaded = Mutex(true) var registerd = AtomicBoolean(false) @@ -57,29 +59,25 @@ object PackageCache { val installed = app.packageManager.getInstalledApplications(PackageManager.GET_META_DATA) installedApps = installed.associateBy { it.packageName } packageMap = installed.associate { it.packageName to it.uid } - uidMap.clear() + val newUidMap = HashMap>() for (info in installed) { - val uid = info.uid - uidMap.getOrPut(uid) { HashSet() }.add(info.packageName) + newUidMap.getOrPut(info.uid) { HashSet() }.add(info.packageName) } + uidMap = newUidMap } operator fun get(uid: Int) = uidMap[uid] operator fun get(packageName: String) = packageMap[packageName] fun awaitLoadSync() { - if (::packageMap.isInitialized) { - return - } + if (::packageMap.isInitialized) return + // Ensure registration has started exactly once; the winner unlocks `loaded` + // after the first reload(). Losers fall through and await the mutex. if (!registerd.get()) { register() - return - } - runBlocking { - loaded.withLock { - // just await - } } + if (::packageMap.isInitialized) return + runBlocking { loaded.withLock { /* await first reload */ } } } private val labelMap = mutableMapOf() From 740aa77c9a1bbdf9cbdd85a9560909dc33ec32a4 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:10 -0400 Subject: [PATCH 08/26] fix(bg): assign TrafficLooper synchronously to close the launch/close race --- .../nekohasekai/sagernet/bg/proto/ProxyInstance.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt index 03226630a..0f4e9d290 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt @@ -5,8 +5,8 @@ import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.ServiceNotification import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.ktx.Logs -import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.Commandline +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.runBlocking import moe.matsuri.nb4a.utils.JavaUtil @@ -60,10 +60,12 @@ class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? = override fun launch() { box.setAsMain() super.launch() // start box - runOnDefaultDispatcher { - looper = service?.let { TrafficLooper(it.data, this) } - looper?.start() - } + // Assign the looper synchronously so close() always observes it (no + // launch/close race). GlobalScope matches the previous scope semantics: + // runOnDefaultDispatcher was GlobalScope.launch(Dispatchers.Default), and + // TrafficLooper.start() launches its own loop on this scope. + looper = service?.let { TrafficLooper(it.data, GlobalScope) } + looper?.start() } override fun close() { From 9c304d3b78c44edec57dfcd207fc31f9746627ee Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:11 -0400 Subject: [PATCH 09/26] fix(group): resume update dialogs safely when the Activity is gone --- .../sagernet/group/GroupInterfaceAdapter.kt | 95 +++++++++++++------ 1 file changed, 66 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt b/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt index c1704d1ae..30097ef12 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt @@ -4,6 +4,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProxyGroup +import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnMainDispatcher import io.nekohasekai.sagernet.ui.ThemedActivity @@ -14,14 +15,23 @@ import kotlin.coroutines.suspendCoroutine class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interface { override suspend fun confirm(message: String): Boolean { - return suspendCoroutine { + return suspendCoroutine { cont -> runOnMainDispatcher { - MaterialAlertDialogBuilder(context).setTitle(R.string.confirm) - .setMessage(message) - .setPositiveButton(R.string.yes) { _, _ -> it.resume(true) } - .setNegativeButton(R.string.no) { _, _ -> it.resume(false) } - .setOnCancelListener { _ -> it.resume(false) } - .show() + if (context.isFinishing || context.isDestroyed) { + cont.resume(false) + return@runOnMainDispatcher + } + try { + MaterialAlertDialogBuilder(context).setTitle(R.string.confirm) + .setMessage(message) + .setPositiveButton(R.string.yes) { _, _ -> cont.resume(true) } + .setNegativeButton(R.string.no) { _, _ -> cont.resume(false) } + .setOnCancelListener { _ -> cont.resume(false) } + .show() + } catch (e: Exception) { + Logs.w(e) + cont.resume(false) + } } } } @@ -37,16 +47,21 @@ class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interfac ) { if (changed == 0 && duplicate.isEmpty()) { if (byUser) { - context.snackbar( - context.getString( - R.string.group_no_difference, - group.displayName(), - ), - ).show() + onMainDispatcher { + if (context.isFinishing || context.isDestroyed) return@onMainDispatcher + try { + context.snackbar( + context.getString( + R.string.group_no_difference, + group.displayName(), + ), + ).show() + } catch (e: Exception) { + Logs.w(e) + } + } } } else { - context.snackbar(context.getString(R.string.group_updated, group.name, changed)).show() - var status = "" if (added.isNotEmpty()) { status += context.getString( @@ -76,32 +91,54 @@ class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interfac } onMainDispatcher { - delay(1000L) + if (context.isFinishing || context.isDestroyed) return@onMainDispatcher + try { + context.snackbar( + context.getString(R.string.group_updated, group.name, changed), + ).show() + delay(1000L) - MaterialAlertDialogBuilder(context).setTitle( - context.getString( - R.string.group_diff, - group.displayName(), - ), - ).setMessage(status.trim()).setPositiveButton(android.R.string.ok, null).show() + MaterialAlertDialogBuilder(context).setTitle( + context.getString( + R.string.group_diff, + group.displayName(), + ), + ).setMessage(status.trim()).setPositiveButton(android.R.string.ok, null).show() + } catch (e: Exception) { + Logs.w(e) + } } } } override suspend fun onUpdateFailure(group: ProxyGroup, message: String) { onMainDispatcher { - context.snackbar(message).show() + if (context.isFinishing || context.isDestroyed) return@onMainDispatcher + try { + context.snackbar(message).show() + } catch (e: Exception) { + Logs.w(e) + } } } override suspend fun alert(message: String) { - return suspendCoroutine { + return suspendCoroutine { cont -> runOnMainDispatcher { - MaterialAlertDialogBuilder(context).setTitle(R.string.ooc_warning) - .setMessage(message) - .setPositiveButton(android.R.string.ok) { _, _ -> it.resume(Unit) } - .setOnCancelListener { _ -> it.resume(Unit) } - .show() + if (context.isFinishing || context.isDestroyed) { + cont.resume(Unit) + return@runOnMainDispatcher + } + try { + MaterialAlertDialogBuilder(context).setTitle(R.string.ooc_warning) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> cont.resume(Unit) } + .setOnCancelListener { _ -> cont.resume(Unit) } + .show() + } catch (e: Exception) { + Logs.w(e) + cont.resume(Unit) + } } } } From 06493153c4b62f292ab94164aa87d997f8b4431d Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:11 -0400 Subject: [PATCH 10/26] fix(bg): make TestInstance.doTest cancellable and close on cancel --- .../sagernet/bg/proto/TestInstance.kt | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt index b59803558..af205b65a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt @@ -11,34 +11,43 @@ import io.nekohasekai.sagernet.ktx.tryResumeWithException import io.nekohasekai.sagernet.utils.Commandline import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl -import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.suspendCancellableCoroutine class TestInstance(profile: ProxyEntity, val link: String, private val timeout: Int) : BoxInstance(profile) { - suspend fun doTest(): Int { - return suspendCoroutine { c -> - processes = GuardedProcessPool { - Logs.w(it) - c.tryResumeWithException(it) + suspend fun doTest(): Int = suspendCancellableCoroutine { c -> + processes = GuardedProcessPool { + Logs.w(it) + c.tryResumeWithException(it) + } + // Close the box/sidecars if the caller cancels while the test is in flight + // (e.g. the user pressed Stop). This prevents leaking up to + // connectionTestConcurrent full sing-box instances + plugin sidecars that + // otherwise run to completion in the background. + c.invokeOnCancellation { + try { + close() + } catch (e: Exception) { + Logs.w(e) } - runOnDefaultDispatcher { - use { - try { - init() - launch() - if (processes.processCount > 0) { - // Wait until the external plugin sidecar(s) have actually bound - // their loopback SOCKS port before testing, instead of a fixed - // 500ms guess that often raced the sidecar (flaky "connection - // refused"). strict = true turns a never-bound listener into a - // clear error rather than a misleading connection failure. - awaitExternalProcessesReady(strict = true) - } - c.tryResume(Libcore.urlTest(box, link, timeout)) - } catch (e: Exception) { - c.tryResumeWithException(e) + } + runOnDefaultDispatcher { + use { + try { + init() + launch() + if (processes.processCount > 0) { + // Wait until the external plugin sidecar(s) have actually bound + // their loopback SOCKS port before testing, instead of a fixed + // 500ms guess that often raced the sidecar (flaky "connection + // refused"). strict = true turns a never-bound listener into a + // clear error rather than a misleading connection failure. + awaitExternalProcessesReady(strict = true) } + c.tryResume(Libcore.urlTest(box, link, timeout)) + } catch (e: Exception) { + c.tryResumeWithException(e) } } } From 18c78b31821c9501d12b9fb8dc57fb5d149ea783 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:32:55 -0400 Subject: [PATCH 11/26] =?UTF-8?q?fix(bg):=20address=20CR=20=E2=80=94=20alw?= =?UTF-8?q?ays=20unlock=20PackageCache.loaded;=20rethrow=20cancellation=20?= =?UTF-8?q?in=20group=20dialogs;=20ignore=20.worklog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../sagernet/group/GroupInterfaceAdapter.kt | 7 +++++++ .../nekohasekai/sagernet/utils/PackageCache.kt | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index f4ad61bcd..6b43c3206 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ app/src/main/java/io/nekohasekai/sagernet/ktx/AgentDebugLog.kt # Local on-device DB/config backups (profiles + credentials — never commit) device-backups/ +.worklog/ diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt b/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt index 30097ef12..6f624a6ff 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt @@ -8,6 +8,7 @@ import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnMainDispatcher import io.nekohasekai.sagernet.ui.ThemedActivity +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -56,6 +57,8 @@ class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interfac group.displayName(), ), ).show() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logs.w(e) } @@ -104,6 +107,8 @@ class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interfac group.displayName(), ), ).setMessage(status.trim()).setPositiveButton(android.R.string.ok, null).show() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logs.w(e) } @@ -116,6 +121,8 @@ class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interfac if (context.isFinishing || context.isDestroyed) return@onMainDispatcher try { context.snackbar(message).show() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logs.w(e) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt index 9f2f9a2b2..d98596860 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt @@ -28,12 +28,20 @@ object PackageCache { // called from init (suspend) fun register() { if (registerd.getAndSet(true)) return - reload() - app.listenForPackageChanges(false) { + try { reload() - labelMap.clear() + app.listenForPackageChanges(false) { + reload() + labelMap.clear() + } + } catch (e: Throwable) { + // Never leave `loaded` permanently locked or block a later retry: on a + // failed first load, allow re-registration and still unlock waiters. + registerd.set(false) + throw e + } finally { + if (loaded.isLocked) loaded.unlock() } - loaded.unlock() } @SuppressLint("InlinedApi") From 1a495acdd76cf9e84cdcbbf7adb063b910c8ed1e Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:40:40 -0400 Subject: [PATCH 12/26] fix(bg): use CancellableContinuation resume (avoid internal tryResume API); drop unused imports --- .../io/nekohasekai/sagernet/bg/proto/TestInstance.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt index af205b65a..bf94c6957 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt @@ -6,12 +6,12 @@ import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.buildConfig import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher -import io.nekohasekai.sagernet.ktx.tryResume -import io.nekohasekai.sagernet.ktx.tryResumeWithException import io.nekohasekai.sagernet.utils.Commandline import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException class TestInstance(profile: ProxyEntity, val link: String, private val timeout: Int) : BoxInstance(profile) { @@ -19,7 +19,7 @@ class TestInstance(profile: ProxyEntity, val link: String, private val timeout: suspend fun doTest(): Int = suspendCancellableCoroutine { c -> processes = GuardedProcessPool { Logs.w(it) - c.tryResumeWithException(it) + if (c.isActive) c.resumeWithException(it) } // Close the box/sidecars if the caller cancels while the test is in flight // (e.g. the user pressed Stop). This prevents leaking up to @@ -45,9 +45,10 @@ class TestInstance(profile: ProxyEntity, val link: String, private val timeout: // clear error rather than a misleading connection failure. awaitExternalProcessesReady(strict = true) } - c.tryResume(Libcore.urlTest(box, link, timeout)) + val result = Libcore.urlTest(box, link, timeout) + if (c.isActive) c.resume(result) } catch (e: Exception) { - c.tryResumeWithException(e) + if (c.isActive) c.resumeWithException(e) } } } From 6a8e6de1ad5161aa9335ae2807cf89990be06cff Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:52:04 -0400 Subject: [PATCH 13/26] style(bg): fix import ordering in TestInstance (ktlint) --- .../java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt index bf94c6957..b428a9eaf 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt @@ -7,11 +7,11 @@ import io.nekohasekai.sagernet.fmt.buildConfig import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.Commandline -import libcore.Libcore -import moe.matsuri.nb4a.net.LocalResolverImpl -import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import libcore.Libcore +import moe.matsuri.nb4a.net.LocalResolverImpl class TestInstance(profile: ProxyEntity, val link: String, private val timeout: Int) : BoxInstance(profile) { From 41dfe22f7e8567c4d9f753d48d4fb190246a80fa Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:00:37 -0400 Subject: [PATCH 14/26] style(bg): match ktlint import order in TestInstance (kotlin.* last) --- .../java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt index b428a9eaf..61d6b1e9d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt @@ -7,11 +7,11 @@ import io.nekohasekai.sagernet.fmt.buildConfig import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.Commandline -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException import kotlinx.coroutines.suspendCancellableCoroutine import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException class TestInstance(profile: ProxyEntity, val link: String, private val timeout: Int) : BoxInstance(profile) { From 4609c2915820c439bb537fdd6d1b3cbc91c014b0 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:06:20 -0400 Subject: [PATCH 15/26] fix(bg): make TestInstance.close idempotent (avoid double-close on cancel, address review) --- .../io/nekohasekai/sagernet/bg/proto/TestInstance.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt index 61d6b1e9d..a9f4f3fb0 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt @@ -10,12 +10,24 @@ import io.nekohasekai.sagernet.utils.Commandline import kotlinx.coroutines.suspendCancellableCoroutine import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl +import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException class TestInstance(profile: ProxyEntity, val link: String, private val timeout: Int) : BoxInstance(profile) { + // close() can be reached from two paths that may overlap on cancellation: the + // suspendCancellableCoroutine's invokeOnCancellation and the `use { }` block's + // exit. BoxInstance.close() is not safe to run twice (native box.close()), so + // guard it to run exactly once. + private val closed = AtomicBoolean(false) + + override fun close() { + if (closed.getAndSet(true)) return + super.close() + } + suspend fun doTest(): Int = suspendCancellableCoroutine { c -> processes = GuardedProcessPool { Logs.w(it) From ab171dc8730a88cbdc719508b3a059fabd989578 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:26:00 -0400 Subject: [PATCH 16/26] perf(bg): derive selector group id without building a temp config --- .../io/nekohasekai/sagernet/bg/BaseService.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt index c11fd8e2a..9d1c34405 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import libcore.Libcore import moe.matsuri.nb4a.Protocols +import moe.matsuri.nb4a.proxy.config.ConfigBean import moe.matsuri.nb4a.utils.Util import java.net.UnknownHostException import java.util.concurrent.ConcurrentHashMap @@ -277,14 +278,20 @@ class BaseService { } fun canReloadSelector(): Boolean { - if ((data.proxy?.config?.selectorGroupId ?: -1L) < 0) return false + val running = data.proxy?.lastSelectorGroupId ?: -1L + if (running < 0L) return false val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) ?: return false - val tmpBox = ProxyInstance(ent) - tmpBox.buildConfigTmp() - if (tmpBox.lastSelectorGroupId == data.proxy?.lastSelectorGroupId) { - return true + // Mirrors ConfigBuilder.buildConfig()'s selectorGroupId derivation + // (TYPE_CONFIG/type==0 early exit; else group.isSelector -> group.id). + // Keep in sync with ConfigBuilder.kt:106-119,179,1194. + if (ent.type == ProxyEntity.TYPE_CONFIG && + (ent.requireBean() as? ConfigBean)?.type == 0 + ) { + return false } - return false + val group = SagerDatabase.groupDao.getById(ent.groupId) ?: return false + val newSelectorGroupId = if (group.isSelector) group.id else -1L + return newSelectorGroupId == running } suspend fun startProcesses() { From 856206da9aef2dda5e7c44a09bd669457f54d968 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:26:00 -0400 Subject: [PATCH 17/26] perf(ui): batch connection-test result writes --- .../sagernet/database/ProfileManager.kt | 17 ++++++++ .../sagernet/ui/ConfigurationFragment.kt | 41 +++++++++++++------ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt index a03c062a2..dd76f04bb 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -16,6 +16,9 @@ object ProfileManager { interface Listener { suspend fun onAdd(profile: ProxyEntity) suspend fun onUpdated(data: TrafficData) + suspend fun onUpdated(data: List) { + data.forEach { onUpdated(it) } + } suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) suspend fun onRemoved(groupId: Long, profileId: Long) } @@ -96,6 +99,16 @@ object ProfileManager { } } + /** + * Batch-persist profiles WITHOUT firing per-profile onUpdated listener rounds. + * For callers that follow up with GroupManager.postReload(groupId), which + * re-renders the whole group anyway (e.g. connection-test finalization). + */ + suspend fun updateProfileQuietly(profiles: List) { + if (profiles.isEmpty()) return + SagerDatabase.proxyDao.updateProxy(profiles) + } + suspend fun updateTraffic(profileId: Long, rx: Long, tx: Long) { SagerDatabase.proxyDao.updateTraffic(profileId, rx, tx) } @@ -156,6 +169,10 @@ object ProfileManager { iterator { onUpdated(data) } } + suspend fun postUpdate(data: List) { + iterator { onUpdated(data) } + } + suspend fun createRule(rule: RuleEntity, post: Boolean = true): RuleEntity { rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1 rule.id = SagerDatabase.rulesDao.createRule(rule) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 0b5162e9d..064876c0b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -971,12 +971,10 @@ class ConfigurationFragment @JvmOverloads constructor( runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } - test.results.forEach { - try { - ProfileManager.updateProfile(it) - } catch (e: Exception) { - Logs.w(e) - } + try { + ProfileManager.updateProfileQuietly(test.results.toList()) + } catch (e: Exception) { + Logs.w(e) } GroupManager.postReload(DataStore.currentGroupId()) DataStore.runningTest = false @@ -1055,12 +1053,10 @@ class ConfigurationFragment @JvmOverloads constructor( runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } - test.results.forEach { - try { - ProfileManager.updateProfile(it) - } catch (e: Exception) { - Logs.w(e) - } + try { + ProfileManager.updateProfileQuietly(test.results.toList()) + } catch (e: Exception) { + Logs.w(e) } GroupManager.postReload(DataStore.currentGroupId()) DataStore.runningTest = false @@ -1720,6 +1716,27 @@ class ConfigurationFragment @JvmOverloads constructor( } } + override suspend fun onUpdated(data: List) { + try { + val positions = HashMap(configurationIdList.size) + configurationIdList.forEachIndexed { index, id -> positions[id] = index } + val updates = ArrayList>() + for (item in data) { + val index = positions[item.id] ?: continue + val holder = layoutManager.findViewByPosition(index) + ?.let { configurationListView.getChildViewHolder(it) } as ConfigurationHolder? + if (holder != null) updates.add(holder to item) + } + if (updates.isNotEmpty()) { + onMainDispatcher { + for ((holder, item) in updates) holder.bind(holder.entity, item) + } + } + } catch (e: Exception) { + Logs.w(e) + } + } + override suspend fun onRemoved(groupId: Long, profileId: Long) { if (groupId != proxyGroup.id) return val index = configurationIdList.indexOf(profileId) From e55efdc62455624a6e8e1c8e96db3fbc0c0b3247 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:26:01 -0400 Subject: [PATCH 18/26] perf(bg): batch per-tick traffic updates into one callback --- .../sagernet/aidl/ISagerNetServiceCallback.aidl | 1 + .../java/io/nekohasekai/sagernet/bg/SagerConnection.kt | 10 ++++++++++ .../io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt | 10 ++++------ .../java/io/nekohasekai/sagernet/ui/MainActivity.kt | 6 ++++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl index cb41c2bd7..abe3e0d3a 100644 --- a/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl +++ b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl @@ -8,5 +8,6 @@ oneway interface ISagerNetServiceCallback { void missingPlugin(String profileName, String pluginName); void cbSpeedUpdate(in SpeedDisplayData stats); void cbTrafficUpdate(in TrafficData stats); + void cbTrafficUpdateList(in List stats); void cbSelectorUpdate(long id); } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt index 47e92afbf..53a1f57ea 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt @@ -42,6 +42,9 @@ class SagerConnection( fun cbSpeedUpdate(stats: SpeedDisplayData) {} fun cbTrafficUpdate(data: TrafficData) {} + fun cbTrafficUpdateList(data: List) { + data.forEach { cbTrafficUpdate(it) } + } fun cbSelectorUpdate(id: Long) {} fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) @@ -86,6 +89,13 @@ class SagerConnection( } } + override fun cbTrafficUpdateList(stats: MutableList) { + val callback = callback ?: return + runOnMainDispatcher { + callback.cbTrafficUpdateList(stats) + } + } + override fun cbSelectorUpdate(id: Long) { val callback = callback ?: return runOnMainDispatcher { diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt index 6bbbd598d..244d6688e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt @@ -63,9 +63,7 @@ class TrafficLooper( } } data.binder.broadcast { b -> - for (t in traffic) { - b.cbTrafficUpdate(t.value) - } + b.cbTrafficUpdateList(ArrayList(traffic.values)) } Logs.d("finally traffic post done") } @@ -206,11 +204,11 @@ class TrafficLooper( if (data.binder.callbackIdMap[b] == SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND) { b.cbSpeedUpdate(speed) if (profileTrafficStatistics) { + val batch = ArrayList(idMap.size) idMap.forEach { (id, item) -> - b.cbTrafficUpdate( - TrafficData(id = id, rx = item.rx, tx = item.tx), // display - ) + batch.add(TrafficData(id = id, rx = item.rx, tx = item.tx)) // display } + b.cbTrafficUpdateList(batch) } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt index 8b6aa2e6d..1608752ed 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -430,6 +430,12 @@ class MainActivity : } } + override fun cbTrafficUpdateList(data: List) { + runOnDefaultDispatcher { + ProfileManager.postUpdate(data) + } + } + override fun cbSelectorUpdate(id: Long) { val old = DataStore.selectedProxy DataStore.selectedProxy = id From 3d7a84a77ec4435a359a644b6c8d6f77df8a3842 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:26:11 -0400 Subject: [PATCH 19/26] security(ci): pin third-party GitHub/Depot Actions to commit SHAs --- .depot/workflows/android-instrumented.yml | 12 +++---- .depot/workflows/build-apk.yml | 16 +++++----- .depot/workflows/guard.yml | 2 +- .depot/workflows/lint.yml | 12 +++---- .depot/workflows/unit-tests.yml | 10 +++--- .github/workflows/build.yml | 20 ++++++------ .github/workflows/ci.yml | 38 +++++++++++------------ .github/workflows/preview.yml | 20 ++++++------ .github/workflows/release.yml | 24 +++++++------- 9 files changed, 77 insertions(+), 77 deletions(-) diff --git a/.depot/workflows/android-instrumented.yml b/.depot/workflows/android-instrumented.yml index cefa1de3a..6a21c7eaf 100644 --- a/.depot/workflows/android-instrumented.yml +++ b/.depot/workflows/android-instrumented.yml @@ -34,16 +34,16 @@ jobs: runs-on: depot-ubuntu-24.04-8 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 - name: Install SDK platform + NDK run: | @@ -71,14 +71,14 @@ jobs: - name: libcore cache id: libcore-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: app/libs/libcore.aar key: depot-libcore-${{ env.GO_VERSION }}-${{ env.NDK_VERSION }}-${{ hashFiles('libcore_status') }} - name: Install Go if: steps.libcore-cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: ${{ env.GO_VERSION }} @@ -98,7 +98,7 @@ jobs: echo "KVM present" - name: Run migration test on emulator - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2 with: api-level: 33 arch: x86_64 diff --git a/.depot/workflows/build-apk.yml b/.depot/workflows/build-apk.yml index ca88e947e..df53a33f0 100644 --- a/.depot/workflows/build-apk.yml +++ b/.depot/workflows/build-apk.yml @@ -21,16 +21,16 @@ jobs: runs-on: depot-ubuntu-24.04-8 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 - name: Install SDK platform + NDK run: sdkmanager --install "platforms;android-35" "build-tools;35.0.0" "ndk;${NDK_VERSION}" @@ -51,7 +51,7 @@ jobs: - name: libcore cache id: libcore-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: app/libs/libcore.aar key: depot-libcore-${{ env.GO_VERSION }}-${{ env.NDK_VERSION }}-${{ hashFiles('libcore_status') }} @@ -72,14 +72,14 @@ jobs: - name: sidecars cache id: sidecars-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: app/executableSo key: depot-sidecars-${{ env.GO_VERSION }}-${{ env.NDK_VERSION }}-${{ env.MIERU_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-${{ env.OLCRTC_COMMIT }}-${{ hashFiles('sidecars_status') }} - name: Install Go if: steps.libcore-cache.outputs.cache-hit != 'true' || steps.sidecars-cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: ${{ env.GO_VERSION }} @@ -108,7 +108,7 @@ jobs: done - name: Gradle cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.gradle key: depot-gradle-oss-${{ hashFiles('**/*.gradle.kts') }} @@ -145,7 +145,7 @@ jobs: echo "Built APK: $APK_FILE" - name: Upload APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: NekoBox-debug-arm64-v8a-apk path: ${{ env.APK_FILE }} diff --git a/.depot/workflows/guard.yml b/.depot/workflows/guard.yml index bbe6d2b52..55206fc44 100644 --- a/.depot/workflows/guard.yml +++ b/.depot/workflows/guard.yml @@ -20,7 +20,7 @@ jobs: runs-on: depot-ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Guard - AGENTS.md must stay untracked run: bash ./scripts/check-agents-untracked.sh - name: Guard - no backed-up SharedPreferences diff --git a/.depot/workflows/lint.yml b/.depot/workflows/lint.yml index 0da397585..e69eb7f42 100644 --- a/.depot/workflows/lint.yml +++ b/.depot/workflows/lint.yml @@ -30,14 +30,14 @@ jobs: runs-on: depot-ubuntu-24.04-4 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 - name: Install SDK platform + NDK run: sdkmanager --install "platforms;android-35" "build-tools;35.0.0" "ndk;${NDK_VERSION}" - name: local.properties @@ -55,13 +55,13 @@ jobs: | awk '{print $1}' > libcore_status - name: libcore cache id: libcore-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: app/libs/libcore.aar key: depot-libcore-${{ env.GO_VERSION }}-${{ env.NDK_VERSION }}-${{ hashFiles('libcore_status') }} - name: Install Go if: steps.libcore-cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: ${{ env.GO_VERSION }} - name: Build libcore (Go + gomobile) @@ -75,7 +75,7 @@ jobs: # If lint just created the baseline (first run), surface it so it can be committed. - name: Upload lint baseline (if generated) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: lint-baseline path: app/lint-baseline.xml diff --git a/.depot/workflows/unit-tests.yml b/.depot/workflows/unit-tests.yml index e13dda55a..335a32dea 100644 --- a/.depot/workflows/unit-tests.yml +++ b/.depot/workflows/unit-tests.yml @@ -32,14 +32,14 @@ jobs: runs-on: depot-ubuntu-24.04-4 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 - name: Install SDK platform + NDK run: sdkmanager --install "platforms;android-35" "build-tools;35.0.0" "ndk;${NDK_VERSION}" - name: local.properties @@ -57,13 +57,13 @@ jobs: | awk '{print $1}' > libcore_status - name: libcore cache id: libcore-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: app/libs/libcore.aar key: depot-libcore-${{ env.GO_VERSION }}-${{ env.NDK_VERSION }}-${{ hashFiles('libcore_status') }} - name: Install Go if: steps.libcore-cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: ${{ env.GO_VERSION }} - name: Build libcore (Go + gomobile) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aee37e8f7..b7f4c5e4a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: runs-on: namespace-profile-nekoyay steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Golang Status run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - name: Libcore Status @@ -33,7 +33,7 @@ jobs: key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.4' - name: Native Build @@ -77,7 +77,7 @@ jobs: rm -rf "$tmp" exit "$fail" - name: Upload LibCore - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: libcore-aar-build path: app/libs/libcore.aar @@ -88,7 +88,7 @@ jobs: runs-on: namespace-profile-nekoyay steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Sidecars Status run: cat buildScript/lib/mieru.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache @@ -100,7 +100,7 @@ jobs: key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.4' - name: Mieru Build @@ -113,7 +113,7 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: ./run lib naive - name: Upload Sidecars - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: sidecars-so-build path: app/executableSo @@ -127,14 +127,14 @@ jobs: - sidecars steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Download LibCore - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: libcore-aar-build path: app/libs - name: Download Sidecars - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: sidecars-so-build path: app/executableSo @@ -174,7 +174,7 @@ jobs: APK=$(dirname "$APK") echo "APK=$APK" >> $GITHUB_ENV - name: Upload Artifacts - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: NekoBoxs path: ${{ env.APK }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c92bbdea..86f1f6996 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: runs-on: namespace-profile-nekoyay steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Guard - AGENTS.md must stay untracked run: bash ./scripts/check-agents-untracked.sh - name: Guard - no backed-up SharedPreferences @@ -28,14 +28,14 @@ jobs: - libcore steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Download LibCore - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: libcore-aar-ci path: app/libs @@ -55,14 +55,14 @@ jobs: - libcore steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Download LibCore - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: libcore-aar-ci path: app/libs @@ -84,9 +84,9 @@ jobs: - guard steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' @@ -103,14 +103,14 @@ jobs: key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}-ci - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.4' - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core - name: Upload LibCore - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: libcore-aar-ci path: app/libs/libcore.aar @@ -123,7 +123,7 @@ jobs: - guard steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Sidecars Status run: | git ls-files -s -- \ @@ -145,7 +145,7 @@ jobs: key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-${{ env.OLCRTC_COMMIT }}-sidecars-ci - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.4' - name: Mieru Build @@ -161,7 +161,7 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: ./run lib olcrtc - name: Upload Sidecars - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: sidecars-so-ci path: app/executableSo @@ -175,19 +175,19 @@ jobs: - sidecars steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Download LibCore - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: libcore-aar-ci path: app/libs - name: Download Sidecars - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: sidecars-so-ci path: app/executableSo @@ -231,7 +231,7 @@ jobs: APK=$(dirname "$APK") echo "APK=$APK" >> $GITHUB_ENV - name: Upload Artifacts - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: NekoBox-debug-apks path: ${{ env.APK }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index f72261ceb..b244826b3 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -15,7 +15,7 @@ jobs: runs-on: namespace-profile-nekoyay steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Golang Status run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - name: Libcore Status @@ -29,14 +29,14 @@ jobs: key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.4' - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core - name: Upload LibCore - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: libcore-aar-preview path: app/libs/libcore.aar @@ -47,7 +47,7 @@ jobs: runs-on: namespace-profile-nekoyay steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Sidecars Status run: cat buildScript/lib/mieru.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache @@ -59,7 +59,7 @@ jobs: key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.4' - name: Mieru Build @@ -72,7 +72,7 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: ./run lib naive - name: Upload Sidecars - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: sidecars-so-preview path: app/executableSo @@ -86,14 +86,14 @@ jobs: - sidecars steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Download LibCore - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: libcore-aar-preview path: app/libs - name: Download Sidecars - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: sidecars-so-preview path: app/executableSo @@ -128,7 +128,7 @@ jobs: fi APK=$(dirname "$APK") echo "APK=$APK" >> $GITHUB_ENV - - uses: namespace-actions/upload-artifact@v1 + - uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: APKs path: ${{ env.APK }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0243b23e..110e79d68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: runs-on: namespace-profile-nekoyay steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Golang Status run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - name: Libcore Status @@ -35,14 +35,14 @@ jobs: key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.4' - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core - name: Upload LibCore - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: libcore-aar-release path: app/libs/libcore.aar @@ -53,7 +53,7 @@ jobs: runs-on: namespace-profile-nekoyay steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Sidecars Status run: cat buildScript/lib/mieru.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache @@ -65,7 +65,7 @@ jobs: key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.26.4' - name: Mieru Build @@ -78,7 +78,7 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: ./run lib naive - name: Upload Sidecars - uses: namespace-actions/upload-artifact@v1 + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: sidecars-so-release path: app/executableSo @@ -92,14 +92,14 @@ jobs: - sidecars steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Download LibCore - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: libcore-aar-release path: app/libs - name: Download Sidecars - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: sidecars-so-release path: app/executableSo @@ -138,7 +138,7 @@ jobs: fi APK=$(dirname "$APK") echo "APK=$APK" >> $GITHUB_ENV - - uses: namespace-actions/upload-artifact@v1 + - uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1 with: name: APKs path: ${{ env.APK }} @@ -151,9 +151,9 @@ jobs: needs: build steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Donwload Artifacts - uses: namespace-actions/download-artifact@v1 + uses: namespace-actions/download-artifact@5c070f7d7ebdc47682b04aa736c76e46ff5f6e1e # v1 with: name: APKs path: artifacts From 7d88e61e5a90588de37354f730b9e9cdbb12ecbf Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:26:12 -0400 Subject: [PATCH 20/26] security(build): pin gomobile branch and mieru tag to commit SHAs --- buildScript/lib/mieru.sh | 10 ++++++++-- libcore/init.sh | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/buildScript/lib/mieru.sh b/buildScript/lib/mieru.sh index f0fcc0bbf..4bf3f675b 100755 --- a/buildScript/lib/mieru.sh +++ b/buildScript/lib/mieru.sh @@ -19,6 +19,9 @@ fi # Mieru release tag to build from source. MIERU_VERSION="${MIERU_VERSION:-v3.34.0}" +# Immutable commit that MIERU_VERSION points to (pinned for reproducible builds; +# update together with MIERU_VERSION on any bump). +MIERU_COMMIT="${MIERU_COMMIT:-1532c85cc8ca08dff469326f35a3f027697c6950}" DEPS="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" # macOS NDK host dirs are darwin-x86_64 / darwin-arm64; fall back if linux is absent. @@ -40,7 +43,7 @@ OUT="$(pwd)/app/executableSo" # checkout is missing or its git operations fail (e.g. corrupted by a prior build). need_clone=1 if [ -d "$WORK/.git" ]; then - if git -C "$WORK" fetch --depth 1 origin "refs/tags/$MIERU_VERSION" \ + if git -C "$WORK" fetch --depth 1 origin "$MIERU_COMMIT" \ && git -C "$WORK" checkout -q FETCH_HEAD; then need_clone=0 else @@ -49,7 +52,10 @@ if [ -d "$WORK/.git" ]; then fi if [ "$need_clone" -eq 1 ]; then rm -rf "$WORK" - git clone --depth 1 --branch "$MIERU_VERSION" https://github.com/enfein/mieru.git "$WORK" + git init -q "$WORK" + git -C "$WORK" remote add origin https://github.com/enfein/mieru.git + git -C "$WORK" fetch --depth 1 origin "$MIERU_COMMIT" + git -C "$WORK" checkout -q FETCH_HEAD fi pushd "$WORK" >/dev/null diff --git a/libcore/init.sh b/libcore/init.sh index 50bf4cfbd..54d903614 100755 --- a/libcore/init.sh +++ b/libcore/init.sh @@ -7,11 +7,17 @@ if [ -z "$GOPATH" ]; then GOPATH=$(go env GOPATH) fi +# gomobile toolchain pin. Resolved from MatsuriDayo/gomobile master2 and pinned to +# an immutable commit for reproducible JNI-bridge generation. Bump deliberately. +GOMOBILE_COMMIT="${GOMOBILE_COMMIT:-17d6af34f6bd6d7e1e428e0c652c8b54a46bda4f}" + # Install gomobile if [ ! -f "$GOPATH/bin/gomobile-matsuri" ]; then - git clone https://github.com/MatsuriDayo/gomobile.git + git init -q gomobile + git -C gomobile remote add origin https://github.com/MatsuriDayo/gomobile.git + git -C gomobile fetch --depth 1 origin "$GOMOBILE_COMMIT" + git -C gomobile checkout -q FETCH_HEAD pushd gomobile - git checkout origin/master2 pushd cmd pushd gomobile go install -v From 8c98adf141cf314b387aea0b70d7d84f66d55bfb Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:26:12 -0400 Subject: [PATCH 21/26] security(plugin): resolve bundled sidecar binaries before external providers --- .../sagernet/plugin/PluginManager.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt b/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt index 64848f875..ccafad964 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt @@ -40,18 +40,21 @@ object PluginManager { } private fun initNative(pluginId: String): InitResult? { - val info = Plugins.getPlugin(pluginId) ?: return null - - // internal so - if (info.applicationInfo == null) { - try { - initNativeInternal(pluginId)?.let { return InitResult(it, info) } - } catch (t: Throwable) { - Logs.w("initNativeInternal failed", t) + // A bundled sidecar always wins over an externally-installed provider: + // external providers are matched by authority prefix with no signature + // check, so they must not be able to shadow binaries we ship. + try { + initNativeInternal(pluginId)?.let { path -> + return InitResult( + path, + ProviderInfo().apply { authority = Plugins.AUTHORITIES_PREFIX_NEKO_EXE }, + ) } - return null + } catch (t: Throwable) { + Logs.w("initNativeInternal failed", t) } + val info = Plugins.getPluginExternal(pluginId) ?: return null try { initNativeFaster(info)?.let { return InitResult(it, info) } } catch (t: Throwable) { From 45b46ea485cb621e58848e76cf2521acfa0c97d8 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:26:12 -0400 Subject: [PATCH 22/26] security(config): require a per-install token for the Clash API --- .../main/java/io/nekohasekai/sagernet/Constants.kt | 1 + .../io/nekohasekai/sagernet/database/DataStore.kt | 10 ++++++++++ .../io/nekohasekai/sagernet/fmt/ConfigBuilder.kt | 1 + .../io/nekohasekai/sagernet/ui/WebviewFragment.kt | 14 ++++++++++++-- .../io/nekohasekai/sagernet/utils/Commandline.kt | 2 +- 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index a3a9e9294..673b9778a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -48,6 +48,7 @@ object Key { const val MIXED_PORT = "mixedPort" const val MIXED_SECRET = "mixedSecret" // storage key for the generated inbound secret + const val CLASH_API_SECRET = "clashApiSecret" // per-install secret for the local Clash API const val MIXED_USERNAME = "neko" // username presented to the authed mixed inbound const val ALLOW_ACCESS = "allowAccess" const val REQUIRE_PROXY_IN_VPN = "requireProxyInVPN" // keep local mixed inbound open in VPN mode diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index e0f06f627..67d9825ff 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -184,6 +184,16 @@ object DataStore : OnPreferenceDataStoreChangeListener { return s } + val clashApiSecret: String + @Synchronized get() { + var s = configurationStore.getString(Key.CLASH_API_SECRET) + if (s.isNullOrEmpty()) { + s = java.util.UUID.randomUUID().toString().replace("-", "") + configurationStore.putString(Key.CLASH_API_SECRET, s) + } + return s + } + var mixedPort: Int get() = getLocalPort(Key.MIXED_PORT, 2080) set(value) = saveLocalPort(Key.MIXED_PORT, value) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index ac94bff82..e8c1cbe7e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -240,6 +240,7 @@ fun buildConfig(proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean clash_api = ClashAPIOptions().apply { external_controller = "127.0.0.1:9090" external_ui = "../files/yacd" + secret = DataStore.clashApiSecret } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt index 040b2c253..188789481 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt @@ -63,7 +63,17 @@ class WebviewFragment : ToolbarFragment(R.layout.layout_webview), Toolbar.OnMenu super.onPageFinished(view, url) } } - mWebView.loadUrl(DataStore.yacdURL) + mWebView.loadUrl(dashboardUrl()) + } + + private fun dashboardUrl(): String { + val base = DataStore.yacdURL + // Only inject the token into the local controller's own UI; never append + // it to a user-configured remote dashboard URL. + if (!base.startsWith("http://127.0.0.1:9090")) return base + if (base.contains("secret=")) return base + val sep = if (base.contains('?')) "&" else "?" + return base + sep + "hostname=127.0.0.1&port=9090&secret=" + DataStore.clashApiSecret } @SuppressLint("CheckResult") @@ -78,7 +88,7 @@ class WebviewFragment : ToolbarFragment(R.layout.layout_webview), Toolbar.OnMenu .setView(view) .setPositiveButton(android.R.string.ok) { _, _ -> DataStore.yacdURL = view.text.toString() - mWebView.loadUrl(DataStore.yacdURL) + mWebView.loadUrl(dashboardUrl()) } .setNegativeButton(android.R.string.cancel, null) .show() diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt index d0645c18d..5c56e3a58 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt @@ -46,7 +46,7 @@ object Commandline { private val SENSITIVE_OUTPUT_PATTERNS = listOf( Regex( "(?i)(\\\"" + - "(?:clientId|key|keyHex|password|roomId|serverPassword|serverUsername|" + + "(?:clientId|key|keyHex|password|roomId|secret|serverPassword|serverUsername|" + "socksPass|socksUser|username)" + "\\\"\\s*:\\s*\\\")[^\\\"]*(\\\")", ) to "\$1\$2", From e66c3bde3e301c53fd801912f44dcbbec0f8d029 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:33:33 -0400 Subject: [PATCH 23/26] =?UTF-8?q?security(build):=20address=20CR=20?= =?UTF-8?q?=E2=80=94=20fresh=20gomobile=20checkout=20dir=20and=20fail-fast?= =?UTF-8?q?=20on=20git=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libcore/init.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libcore/init.sh b/libcore/init.sh index 54d903614..75213e513 100755 --- a/libcore/init.sh +++ b/libcore/init.sh @@ -13,10 +13,14 @@ GOMOBILE_COMMIT="${GOMOBILE_COMMIT:-17d6af34f6bd6d7e1e428e0c652c8b54a46bda4f}" # Install gomobile if [ ! -f "$GOPATH/bin/gomobile-matsuri" ]; then + # Fresh checkout dir every time so a partial/stale clone from an interrupted + # prior run can't be reused; fail fast if any git step fails rather than + # building from wrong/absent sources. + rm -rf gomobile git init -q gomobile git -C gomobile remote add origin https://github.com/MatsuriDayo/gomobile.git - git -C gomobile fetch --depth 1 origin "$GOMOBILE_COMMIT" - git -C gomobile checkout -q FETCH_HEAD + git -C gomobile fetch --depth 1 origin "$GOMOBILE_COMMIT" || exit 1 + git -C gomobile checkout -q FETCH_HEAD || exit 1 pushd gomobile pushd cmd pushd gomobile From d225538a4c5279dbd7f1073c227ced9f8757495c Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:05:01 -0400 Subject: [PATCH 24/26] =?UTF-8?q?security(config):=20build=20dashboard=20U?= =?UTF-8?q?RL=20via=20Uri=20=E2=80=94=20exact=20host:port,=20query=20befor?= =?UTF-8?q?e=20fragment=20(address=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sagernet/ui/WebviewFragment.kt | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt index 188789481..b4bddfe64 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt @@ -1,6 +1,7 @@ package io.nekohasekai.sagernet.ui import android.annotation.SuppressLint +import android.net.Uri import android.os.Bundle import android.text.InputType import android.view.MenuItem @@ -68,12 +69,24 @@ class WebviewFragment : ToolbarFragment(R.layout.layout_webview), Toolbar.OnMenu private fun dashboardUrl(): String { val base = DataStore.yacdURL - // Only inject the token into the local controller's own UI; never append - // it to a user-configured remote dashboard URL. - if (!base.startsWith("http://127.0.0.1:9090")) return base - if (base.contains("secret=")) return base - val sep = if (base.contains('?')) "&" else "?" - return base + sep + "hostname=127.0.0.1&port=9090&secret=" + DataStore.clashApiSecret + val uri = try { + Uri.parse(base) + } catch (e: Exception) { + return base + } + // Only inject the token into the local controller's own UI; never append it + // to a user-configured remote dashboard URL. Match host + port exactly (a + // prefix check would also match e.g. 127.0.0.1:90909). + if (!(uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 9090)) return base + if (uri.getQueryParameter("secret") != null) return base + // Build the query via Uri so the params land in the query component, not + // inside a #fragment (appending a raw "?..." after a fragment hides them). + return uri.buildUpon() + .appendQueryParameter("hostname", "127.0.0.1") + .appendQueryParameter("port", "9090") + .appendQueryParameter("secret", DataStore.clashApiSecret) + .build() + .toString() } @SuppressLint("CheckResult") From 722a1eac418775306a290f332cf36b5f27e6fcfb Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:18:04 -0400 Subject: [PATCH 25/26] security(config): use String.toUri KTX extension (lint UseKtx) --- .../main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt index b4bddfe64..cb0931252 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt @@ -1,7 +1,6 @@ package io.nekohasekai.sagernet.ui import android.annotation.SuppressLint -import android.net.Uri import android.os.Bundle import android.text.InputType import android.view.MenuItem @@ -9,6 +8,7 @@ import android.view.View import android.webkit.* import android.widget.EditText import androidx.appcompat.widget.Toolbar +import androidx.core.net.toUri import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.R @@ -70,7 +70,7 @@ class WebviewFragment : ToolbarFragment(R.layout.layout_webview), Toolbar.OnMenu private fun dashboardUrl(): String { val base = DataStore.yacdURL val uri = try { - Uri.parse(base) + base.toUri() } catch (e: Exception) { return base } From 1a1140f7b55a195fe003a12d004b7a518e1121bf Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:10:09 -0400 Subject: [PATCH 26/26] security(config): omit the local API token from exported configs The per-install token is for the local controller only; a config exported for sharing must not embed it. Mirrors the existing export gating in the selector path. --- app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index e8c1cbe7e..ebdfabd70 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -240,7 +240,8 @@ fun buildConfig(proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean clash_api = ClashAPIOptions().apply { external_controller = "127.0.0.1:9090" external_ui = "../files/yacd" - secret = DataStore.clashApiSecret + // Exported/shared configs must not carry the per-install token. + if (!forExport) secret = DataStore.clashApiSecret } } }