From 6ca9cb9e62ca15ad2c32e1267708e3a7942f5c2a Mon Sep 17 00:00:00 2001
From: hawkff <109485367+hawkff@users.noreply.github.com>
Date: Fri, 3 Jul 2026 16:45:45 -0400
Subject: [PATCH 1/3] fix(bg): build config off the main thread; register
tuic/juicity import schemes
The connect path ran proxy.init() -> buildConfig() (synchronous group/profile DAO
reads) inside runOnMainDispatcher, so with the main-thread-DB allowance removed in debug
(Plan 027) starting a profile threw 'Cannot access database on the main thread' and the
service failed to start. Wrap proxy.init() in onDefaultDispatcher so the config build and
its DAO reads run off the UI thread; the surrounding notification/state/UI calls stay on
main. This is the main-thread site the 027 flag was designed to surface (device-caught
on a real connect).
Also register the tuic and juicity schemes in the profile-import VIEW intent-filter for
parity with the other protocols (the parser in Formats.kt already handles both; only the
manifest deep-link entry was missing, so they previously imported only via QR/paste).
---
app/src/main/AndroidManifest.xml | 2 ++
app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt | 5 ++++-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c3dd21f47..a879c0baf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -136,6 +136,8 @@
+
+
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 9d1c34405..bc1d946bb 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
@@ -539,7 +539,10 @@ class BaseService {
Executable.killAll() // clean up old processes
preInit()
- proxy.init()
+ // buildConfig() (via proxy.init()) does synchronous group/profile DAO reads;
+ // run it off the main thread so it works with the main-thread-DB allowance
+ // removed (Plan 027). init() is suspend and does not touch the UI.
+ onDefaultDispatcher { proxy.init() }
DataStore.currentProfile = profile.id
proxy.processes = GuardedProcessPool {
From c9514dfd0e01b08500398f5395e2343feb805fea Mon Sep 17 00:00:00 2001
From: hawkff <109485367+hawkff@users.noreply.github.com>
Date: Fri, 3 Jul 2026 16:56:19 -0400
Subject: [PATCH 2/3] fix(bg): move remaining connect/reload/teardown DB reads
off the main thread
Device StrictMode (allowance off) surfaced three more main-thread DAO sites beyond
buildConfig:
- ProxyInstance.close() ran the final traffic flush (persist -> updateTraffic /
addLifetimeTraffic) via runBlocking on the main thread during teardown; dispatch it on
Dispatchers.Default so the DAO writes run off the UI thread.
- The connect block called ServiceNotification.genTitle(profile) (a groupDao read) on the
main dispatcher; reuse proxy.displayProfileName, which is already computed off-main at
ProxyInstance construction.
- reloadInner() did canReloadSelector()/getById on the main thread; resolve the selector
fast-path tag off-main in the caller (resolveSelectorReloadTag) and pass it in, so
reloadInner touches no DB on the UI thread.
TUIC verified end-to-end on device after these: service starts, egress flows through the
tuic outbound, no 'Cannot access database on the main thread'.
---
.../io/nekohasekai/sagernet/bg/BaseService.kt | 41 +++++++++++++------
.../sagernet/bg/proto/ProxyInstance.kt | 6 ++-
2 files changed, 33 insertions(+), 14 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 bc1d946bb..ede20bfb1 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
@@ -230,7 +230,11 @@ class BaseService {
profileId > 0L && SagerDatabase.proxyDao.getById(profileId) != null ->
DataStore.selectedProxy = profileId
}
- onMainDispatcher { reloadInner(reloadStopGeneration) }
+ // Compute the in-place selector decision here (off the main thread) so
+ // reloadInner() does no DAO reads on the UI thread (Plan 027). null tag =>
+ // no fast-path (fall through to the state machine).
+ val selectorTag = resolveSelectorReloadTag()
+ onMainDispatcher { reloadInner(reloadStopGeneration, selectorTag) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -245,7 +249,7 @@ class BaseService {
}
}
- private fun reloadInner(reloadStopGeneration: Long) {
+ private fun reloadInner(reloadStopGeneration: Long, selectorTag: String?) {
// A stop raced the async refresh: drop this stale reload so it can't restart a service
// the user stopped. (A Connecting->Connected transition does NOT bump stopGeneration,
// so an in-flight legitimate reload still applies.)
@@ -258,16 +262,13 @@ class BaseService {
// Only take the in-place selector fast-path when fully Connected: during Connecting
// data.proxy is set but proxy.init() may not have built config/box yet, and during
// Stopping the box is being torn down — touching them would throw or act on a dead
- // instance. In those states fall through to the state machine below.
- if (s == State.Connected && canReloadSelector()) {
- val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy)
- val tag = data.proxy!!.config.profileTagMap[ent?.id] ?: ""
- if (tag.isNotBlank() && ent != null) {
- // select from GUI
- data.proxy!!.box.selectOutbound(tag)
- // or select from webui
- // => selector_OnProxySelected
- }
+ // instance. In those states fall through to the state machine below. selectorTag was
+ // resolved off the main thread by the caller (null => no fast-path).
+ if (s == State.Connected && selectorTag != null && selectorTag.isNotBlank()) {
+ // select from GUI
+ data.proxy!!.box.selectOutbound(selectorTag)
+ // or select from webui
+ // => selector_OnProxySelected
return
}
when {
@@ -277,6 +278,18 @@ class BaseService {
}
}
+ // Off-main-thread resolver for reloadInner's in-place selector fast-path. Returns the
+ // outbound tag to select, or null if the selector fast-path does not apply. Does all the
+ // DAO reads (proxy/group) so reloadInner touches no DB on the UI thread.
+ fun resolveSelectorReloadTag(): String? {
+ if (data.state != State.Connected) return null
+ if (!canReloadSelector()) return null
+ val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) ?: return null
+ val proxy = data.proxy ?: return null
+ val tag = proxy.config.profileTagMap[ent.id] ?: ""
+ return tag.ifBlank { null }
+ }
+
fun canReloadSelector(): Boolean {
val running = data.proxy?.lastSelectorGroupId ?: -1L
if (running < 0L) return false
@@ -535,7 +548,9 @@ class BaseService {
return runOnMainDispatcher {
try {
- data.notification = createNotification(ServiceNotification.genTitle(profile))
+ // Reuse the title computed during ProxyInstance construction (off the main
+ // thread); calling genTitle() here would do a groupDao read on the main thread.
+ data.notification = createNotification(proxy.displayProfileName)
Executable.killAll() // clean up old processes
preInit()
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 0f4e9d290..55e579356 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
@@ -6,6 +6,7 @@ import io.nekohasekai.sagernet.bg.ServiceNotification
import io.nekohasekai.sagernet.database.ProxyEntity
import io.nekohasekai.sagernet.ktx.Logs
import io.nekohasekai.sagernet.utils.Commandline
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.runBlocking
import moe.matsuri.nb4a.utils.JavaUtil
@@ -70,7 +71,10 @@ class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? =
override fun close() {
super.close()
- runBlocking {
+ // Teardown is called on the main thread; the final traffic flush in looper.stop() does
+ // synchronous DAO writes, so run the blocking body on a background dispatcher to keep it
+ // off the UI thread (Plan 027 — main-thread-DB allowance removed).
+ runBlocking(Dispatchers.Default) {
looper?.stop()
looper = null
}
From 1d98082d9f8b38bcfa77a462088be0cf094180c1 Mon Sep 17 00:00:00 2001
From: hawkff <109485367+hawkff@users.noreply.github.com>
Date: Fri, 3 Jul 2026 17:09:29 -0400
Subject: [PATCH 3/3] fix(manifest): register all parsed import schemes in the
VIEW filter
Greptile: Formats.kt also parses hysteria2/hy2/vless/anytls/snell but they were missing
from the profile-import intent-filter, so sharing those links from another app didn't
offer NekoBox. Add them alongside the tuic/juicity entries for full parser<->manifest
parity.
---
app/src/main/AndroidManifest.xml | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a879c0baf..0ba626f30 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -134,6 +134,11 @@
+
+
+
+
+