fix(core,android): stop suspending the Core in Doze

The suspended tunnel rejected the traffic Doze still lets through, which
kept FCM from reconnecting, and the re-probe on resume tested every node,
select groups included.

Changelog: Push notifications reconnect in Doze
This commit is contained in:
chen08209
2026-09-27 16:39:11 +08:00
parent e131675bdf
commit 1e9955dec6
9 changed files with 56 additions and 243 deletions
+52 -11
View File
@@ -553,10 +553,15 @@ action notifier, which is only ever read. A derived provider it listens to is ne
of it returns a stale value. So an action that has to follow a signal listens to the keep-alive provider that holds
it, as `SetupAction` does with `appVisibleProvider`, not to a provider derived from that.
On Android, `SuspendModule` suspends the Core while the screen is off and the device is in Doze. Health checks that
run in Doze all fail, so after a suspension the Core re-probes every provider. That re-probe waits for the screen to
come on. A Doze maintenance window resumes the Core too, but probing there would spend the window's radio time on
results nobody sees.
On Android the Core does not react to the screen or to Doze. Doze's firewall keeps apps without an exemption off the
network before their traffic reaches the TUN, so what still arrives is traffic Doze allows, FCM among it; suspending the
tunnel rejects exactly that and stalls push reconnects until their backoff runs out. The VPN service keeps its own
network as a foreground service (`dumpsys netpolicy` shows its `DOZE` block allowed by `FOREGROUND`), so health checks
keep producing real results: holding them blocks the re-check mihomo runs after repeated dial failures, which is how a
`url-test` group leaves a dead node, and hiding their failures hides the truth. Nothing re-probes on resume either:
every proxy group is a provider, `select` groups included, and checking one tests every node in it. Should a ROM be
shown to cut the VPN's own network while the screen is off, holding back failures measured then needs only a screen
on/off signal.
## Database
@@ -854,14 +859,50 @@ Crashlytics NDK symbolicates a native crash only from libraries that still carry
`CodeForgeController` is the document: text lives in the Rust `RopeBridge`, and the controller's methods are split by
concern into `lib/code_forge/controller/` extensions. `CodeForge` builds `_CodeFieldRenderer`, whose painting, layout,
folding, caret and gutter code sit in `lib/code_forge/code_area/` part files.
folding, caret, gutter and pointer code sit in `lib/code_forge/code_area/` part files.
- Rust counts offsets in Unicode scalars and Flutter in UTF-16 code units. Convert at the boundary with
`text_offsets.dart`; a mixed-up offset only shows up with astral characters such as emoji, so tests use them.
- The renderer follows edits through the controller's `lineEdit` (first line, lines removed, lines inserted) and never
reads the whole text per edit; a whole-text `text =` assignment resets the renderer instead.
- The renderer learns what changed from `takeChange()`, whose `lines` (first line, lines removed, lines inserted) let it
follow edits without reading the whole text; a whole-text `text =` assignment marks the change `replaced` and resets
the renderer instead.
- The input method is handed a window of the document around the caret, at most 4096 characters, and its deltas are
mapped through that window, so the platform has to hold the current one: a caret move that shifts the window, the
input method's own included, sends it again. A selection can reach past the window; when the platform removes the
selection it sees, the controller removes the rest with it, as one undo step. Gboard does that by collapsing the
selection and deleting what it saw, then composes the typed letter before a newly sent window reaches it: deltas
made on a window already replaced are not mapped through the new one, and only what they typed is kept. A line
longer than the window gets one cut by character count, which stays where it is until the caret nears an end of it,
or every key would replace it. A caret move can put a window of the very same text further along; an edit the
platform then makes at the caret it had is mapped through the window it still holds.
- A selection the input method collapsed is still the one to replace only while the caret rests at the end it was
left at; Gboard's spacebar caret gesture collapses to the middle and moves on, which lets it go. The platform's own
cut (Gboard's text-editing panel) arrives as a lone deletion of the live selection and copies only the window's
part, so the controller rewrites the clipboard with the whole selection. That panel's select-all and copy still
reach only the window. Gboard types over a selection in two batches, the removal and then the letter: the second
joins the undo step of the first, and undoing it gives the selection back.
- A composition is drawn over the document and written into it only when it ends, so whatever reads or rewrites the
document from outside the input method commits it first: saving, leaving the page, accepting a suggestion, a
snippet, find's replace, and `UndoRedoController`'s `undo`/`redo` through the `settleInput` callback the controller
registers. Losing focus and a closing connection commit it too rather than drop it. Typing that ends a composition,
Enter or a bracket, goes through the typing path so it still indents and pairs. Android ends one with an empty
delta batch when the keyboard is hidden.
- A caret the input method moves has to set `selection` on the change record like any other move, or the renderer
neither repaints nor follows it.
- A line's fold comes from the renderer's `_computeFoldRangeForLine` up to 10000 lines and from `compute_all` in
`rust_api`'s `editor/folds.rs` past that, and the two must find the same ranges; `folding_test.dart` compares them on
one document. A folded range, and the folded ranges inside it that it folds again when it opens, outlive an edit
only while the text still gives that range. An edit reports the lines it changed, not where in them, so a fold
heading a line inside an edit that changes the line count is opened rather than guessed at; `replaceText`, which
find's replace-all uses, rewrites only the span that differs to keep that to the lines between its matches.
- The caret never rests on a line a fold hides: folding over it moves it to the heading line, the keyboard steps over
the fold, and a caret put there any other way opens it.
- The controller reaches the mounted editor only through the `CodeForgeView` it attaches, which the editor's state
implements by forwarding to its renderer.
- FlClash supplies the suggestion popup, context menu and scrollbar; the package has no built-in versions of them.
- Tests live in `test/plugins/code_forge/` at the repository root and need the `rust_api` library.
- `isMobile` comes from `Platform`, so host tests never reach the touch branches (selection handles, long press,
magnifier); check those on an Android emulator.
## window Plugin
@@ -935,10 +976,10 @@ the singleton entry point: `RustLib.init()` runs before any call and `RustLib.di
bounded send queue), `platform` (socket cleanup, Windows peer credentials and the non-blocking pipe reader), and
`server` (lifecycle, accept loop, and the `RUNNING`/`STATE` globals).
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
- `editor/` backs `plugins/code_forge`: a `ropey` buffer with its selection, fold ranges and bracket matching,
indent guides for the viewport, and completion words. `api/editor.rs` exposes the buffer as the opaque
`RopeBridge`. Only the editor imports this module, through `lib/editor.dart`, so `lib/rust_api.dart` keeps its
generic names such as `SelectionState` out of app code.
- `editor/` backs `plugins/code_forge`: a `ropey` buffer, fold ranges and bracket matching, indent guides for the
viewport, and completion words. `api/editor.rs` exposes the buffer as the opaque `RopeBridge`; the selection
stays in Dart. Only the editor imports this module, through `lib/editor.dart`, so `lib/rust_api.dart` keeps its
generic names such as `GuideBlock` out of app code.
- `hotkey/` registers desktop global shortcuts through `global-hotkey`: `keys` maps Flutter USB HID usages to key
codes, `owner` runs every registration on the thread the platform binds it to (a dedicated message-loop thread on
Windows, the main dispatch queue on macOS, in place on Linux), and `service` owns the registry and forwards presses
-7
View File
@@ -68,13 +68,6 @@ Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
return new_string(traffic);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended,
jboolean interactive) {
suspend(suspended, interactive);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
@@ -60,11 +60,6 @@ object Core {
)
}
external fun suspended(
suspended: Boolean,
interactive: Boolean,
)
private external fun invokeMethod(
data: String,
cb: InvokeInterface,
@@ -24,7 +24,6 @@ internal class ServiceModules(private val service: Service) {
val nextModules = listOf(
NotificationModule(service, nextScope),
NetworkObserveModule(service),
SuspendModule(service, nextScope),
)
val startedModules = mutableListOf<ServiceModule>()
@@ -1,47 +0,0 @@
package com.follow.clash.service.modules
import android.app.Service
import android.content.Intent
import android.os.PowerManager
import androidx.core.content.getSystemService
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.core.Core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
internal class SuspendModule(
private val service: Service,
private val scope: CoroutineScope,
) : ServiceModule {
private fun isScreenOn() =
service.getSystemService<PowerManager>()?.isInteractive ?: true
private val isDeviceIdle: Boolean
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
private fun updateSuspension(screenOn: Boolean) {
Core.suspended(!screenOn && isDeviceIdle, screenOn)
}
override fun start() {
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
}.map {
isScreenOn()
}.onStart {
emit(isScreenOn())
}
screenFlow.collect(::updateSuspension)
}
}
override fun stop() {
Core.suspended(false, isScreenOn())
}
}
+4 -6
View File
@@ -55,12 +55,10 @@ var (
// configMu -> selectMu.
selectMu sync.Mutex
isInit atomic.Bool
isRunning atomic.Bool
isSuspended atomic.Bool
healthChecksStale atomic.Bool
sdkVersion atomic.Int32
testURL atomic.Pointer[string]
isInit atomic.Bool
isRunning atomic.Bool
sdkVersion atomic.Int32
testURL atomic.Pointer[string]
delayTestSlots = make(chan struct{}, delayTestConcurrency)
-48
View File
@@ -654,51 +654,6 @@ func handleSideLoadExternalProvider(providerName string, data []byte) *MethodErr
return nil
}
// defaultRefreshHealthChecks re-probes every proxy provider off the calling
// thread. Providers coalesce concurrent checks internally, so an extra call
// costs nothing when one is already running.
func defaultRefreshHealthChecks() {
safeGoDetached("refreshHealthChecks", func() {
for name, p := range tunnel.ProvidersSnapshot() {
log.Debugln("[APP] re-checking provider %s after resume", name)
p.HealthCheck()
}
})
}
var refreshHealthChecks = defaultRefreshHealthChecks
func handleSuspend(suspended, interactive bool) bool {
isSuspended.Store(suspended)
if suspended {
healthChecksStale.Store(true)
tunnel.OnSuspend()
return true
}
tunnel.OnRunning()
// Provider health checks keep ticking through Doze, where the app has no
// network at all, so coming back means every proxy is marked dead and every
// delay reads Timeout. A lazy provider then skips its next tick because
// nothing touched it in the meantime, and the whole list stays wrong until
// the user tests by hand. Re-check once the screen is on: a maintenance
// window resumes the core too, and probing every node there spends the
// window's radio time on results nobody sees. Not while the listeners are
// stopped either, since the service also resumes the core on its way down.
if interactive && healthChecksStale.Swap(false) && isRunning.Load() {
refreshHealthChecks()
}
return true
}
// A failure measured while the device is dozing says nothing about the node -
// the app had no network at all - and publishing it repaints the entire list as
// Timeout for a user who is not even looking. Successes still are worth having,
// whenever they happen.
func shouldPublishDelay(delay uint16) bool {
return delay != 0 || !isSuspended.Load()
}
func handleStartLog() {
logMu.Lock()
if logCancel != nil {
@@ -848,9 +803,6 @@ func handleSetupConfig(params *SetupParams) string {
func init() {
adapter.UrlTestHook = func(url string, name string, delay uint16) {
if !shouldPublishDelay(delay) {
return
}
sendMessage(Message{
Type: DelayMessage,
Data: &Delay{
-113
View File
@@ -105,119 +105,6 @@ func TestPatchSelectGroupRestoresASelectionWithoutValidatingIt(t *testing.T) {
}
}
// Coming out of Doze has to re-probe. The health checks that ran while the app
// had no network at all left every proxy marked dead and every delay reading
// Timeout, and a lazy provider skips its next tick because nothing touched it
// in the meantime.
func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
var refreshes atomic.Int32
previous := refreshHealthChecks
previousRunning := isRunning.Load()
refreshHealthChecks = func() { refreshes.Add(1) }
t.Cleanup(func() {
refreshHealthChecks = previous
isRunning.Store(previousRunning)
isSuspended.Store(false)
healthChecksStale.Store(false)
tunnel.OnRunning()
})
isSuspended.Store(false)
healthChecksStale.Store(false)
isRunning.Store(true)
handleSuspend(false, true)
if got := refreshes.Load(); got != 0 {
t.Errorf("refreshes = %d, want none: the device was never suspended", got)
}
handleSuspend(true, false)
if !isSuspended.Load() {
t.Error("handleSuspend(true) did not record the suspension")
}
if got := refreshes.Load(); got != 0 {
t.Errorf("refreshes = %d, want none while the device is still suspended", got)
}
handleSuspend(false, true)
if isSuspended.Load() {
t.Error("handleSuspend(false) did not clear the suspension")
}
if got := refreshes.Load(); got != 1 {
t.Errorf("refreshes = %d, want exactly one on resume", got)
}
handleSuspend(false, true)
if got := refreshes.Load(); got != 1 {
t.Errorf("refreshes = %d, want a redundant resume to change nothing", got)
}
// The service resumes the core on its way down, and probing every node
// through a teardown only produces failures nobody asked for.
isRunning.Store(false)
handleSuspend(true, false)
handleSuspend(false, true)
if got := refreshes.Load(); got != 1 {
t.Errorf("refreshes = %d, want no probe while the listeners are stopped", got)
}
}
func TestHandleSuspendHoldsTheRefreshUntilTheScreenIsOn(t *testing.T) {
var refreshes atomic.Int32
previous := refreshHealthChecks
previousRunning := isRunning.Load()
refreshHealthChecks = func() { refreshes.Add(1) }
t.Cleanup(func() {
refreshHealthChecks = previous
isRunning.Store(previousRunning)
isSuspended.Store(false)
healthChecksStale.Store(false)
tunnel.OnRunning()
})
isSuspended.Store(false)
healthChecksStale.Store(false)
isRunning.Store(true)
handleSuspend(true, false)
handleSuspend(false, false)
if isSuspended.Load() {
t.Error("a maintenance window has to resume the core, or nothing syncs in it")
}
handleSuspend(true, false)
handleSuspend(false, false)
if got := refreshes.Load(); got != 0 {
t.Errorf("refreshes = %d, want none in maintenance windows with the screen off", got)
}
handleSuspend(false, true)
if got := refreshes.Load(); got != 1 {
t.Errorf("refreshes = %d, want the held refresh once the screen turns on", got)
}
}
func TestShouldPublishDelayDropsFailuresMeasuredInDoze(t *testing.T) {
t.Cleanup(func() { isSuspended.Store(false) })
isSuspended.Store(false)
if !shouldPublishDelay(0) {
t.Error("a failure measured while the device is awake is a real result")
}
if !shouldPublishDelay(120) {
t.Error("a successful probe while awake must reach the host")
}
isSuspended.Store(true)
if shouldPublishDelay(0) {
t.Error("a failure measured in Doze says nothing about the node, want it dropped")
}
if !shouldPublishDelay(120) {
t.Error("a probe that succeeded anyway is still worth publishing")
}
}
func typeMap(types map[string]constant.AdapterType) func(string) (constant.AdapterType, bool) {
return func(name string) (constant.AdapterType, bool) {
adapterType, ok := types[name]
-5
View File
@@ -387,11 +387,6 @@ func stopTun() {
}
}
//export suspend
func suspend(suspended, interactive bool) {
handleSuspend(suspended, interactive)
}
//export forceGC
func forceGC() {
handleForceGC()