Skip to content

Consolidated debt, protocol registry, per-profile stats, and DX work#117

Merged
hawkff merged 13 commits into
mainfrom
chore/remaining-debt-features
Jul 3, 2026
Merged

Consolidated debt, protocol registry, per-profile stats, and DX work#117
hawkff merged 13 commits into
mainfrom
chore/remaining-debt-features

Conversation

@hawkff

@hawkff hawkff commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Consolidated debt / refactor / stats / DX work into a single PR to minimize CI review
runs. All changes are behavior-preserving unless noted; verification is on Namespace CI
(unit tests, lint, guards, APK) plus a device smoke pass before merge.

What's in here

  • deps(libcore): source libneko and sing-vmess from the project org mirror (identical
    code at the pinned commit/tag; go.sum regenerated).
  • refactor(config): extract pure buildConfig helpers behind seams (capture-free moves).
  • dx(lint): promote TrustAllX509TrustManager to error (app code has none today),
    surface RestrictedApi at warning, and add a shrink-only lint-baseline guard wired
    into the guard jobs.
  • refactor(ktx): break the ktx -> ui dependency cycle by moving the UI-coupled
    Fragment/Activity helpers to a new ui/UiExt.kt and driving the FAB from ktx through a
    leaf FabContainer interface. ktx is now a leaf w.r.t. ui.
  • refactor(ui): split the 2.1k-line ConfigurationFragment — the paged group fragment
    moves to its own ConfigurationGroupFragment.kt (recreated by the FragmentStateAdapter,
    so the class move is saved-state-safe).
  • refactor(protocol): route ProxyEntity type dispatch (deserialize, requireBean,
    displayType, needExternal, putBean) through a single ProtocolRegistry descriptor table.
    The on-disk format and stored type ids are unchanged; a new all-bean-type test asserts
    serialize/deserialize/dispatch parity to guard that.
  • feat(stats): persistent per-profile lifetime traffic via additive schema v12 columns
    (never destructive); accumulated with a per-session flushed marker so re-entrant flushes
    don't double-count; surfaced read-only as a long-press tooltip on the traffic row.
  • perf(db): staged removal of allowMainThreadQueries() — debug StrictMode thread
    policy, move the proven main-thread reads off the UI thread, and gate the allowance behind
    a build flag (debug off, release on until device-clean, then deleted).

Testing

  • Namespace CI: unit tests (incl. the new bean-dispatch parity test), Android Lint +
    spotless, repo guards, OSS APK build.
  • Device (Android): v11->v12 additive migration on real data with existing profiles intact;
    profiles still load after the registry change; lifetime stats accumulate and survive a
    restart; StrictMode pass for the main-thread-DB work.

Notes

  • schemas/.../12.json carries the additive columns; its Room identity hash is regenerated
    by the CI build.
  • One RawUpdater behavior is unchanged on purpose: subscription refresh resets session and
    lifetime counters alike, matching existing session-counter behavior.

Greptile Summary

This is a broad consolidation PR that bundles several independent refactors, a new stats feature, and DX improvements. All changes are described as behavior-preserving except the new per-profile lifetime traffic feature.

  • ProtocolRegistry: replaces two dozen parallel when(type) / when(bean) ladders in ProxyEntity with a single descriptor table; wire format and persisted type ids are unchanged, and a new round-trip dispatch test guards the invariant.
  • feat(stats): adds lifetimeRx/lifetimeTx columns via an additive schema v12 auto-migration (DEFAULT 0); a lifetimeMutex in TrafficLooper serializes the read-modify-write between the loop coroutine and the applySelect GlobalScope coroutine so the previously flagged double-counting window is closed; the totals are surfaced as a long-press tooltip in ConfigurationGroupFragment.
  • refactor(ktx/ui): breaks the ktx → ui dependency cycle via a new FabContainer interface; splits the 2.1 k-line ConfigurationFragment into a parent + ConfigurationGroupFragment; moves UI-coupled helpers to UiExt.kt; gates allowMainThreadQueries() behind BuildConfig.ALLOW_MAIN_THREAD_DB for a staged StrictMode migration.

Confidence Score: 5/5

Safe to merge; the double-counting window in lifetime traffic accumulation is correctly closed by the new lifetimeMutex, the schema migration is purely additive, and the ProtocolRegistry refactor is guarded by a round-trip dispatch test.

The double-counting concern flagged in earlier reviews has been addressed: persist() and the applySelect GlobalScope coroutine both gate their lifetime read-modify-write behind lifetimeMutex, so whichever runs second sees the advanced flushedRx marker and no-ops. The schema v12 auto-migration adds DEFAULT 0 columns and is not destructive. The ProtocolRegistry consolidation is behaviorally equivalent to the removed when-ladders, verified by ProtocolRegistryDispatchTest. The one remaining observation (missing @volatile on item.rx/rxBase) is a JVM spec technicality that has no practical consequence on Android hardware.

TrafficLooper.kt — the lifetimeMutex logic is new and correctness-critical; the sequence diagram illustrates the two-coroutine ordering, but a second pair of eyes on the stop()/persist() interplay with in-flight GlobalScope coroutines is worthwhile before merge.

Important Files Changed

Filename Overview
app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt Adds lifetimeMutex to serialize flushLifetimeDelta; correctly closes the double-counting window. Minor: item.rx/txBase read inside the mutex but not declared @volatile, so visibility against concurrent loop writes is not guaranteed by the JVM spec (though atomic on Android hardware in practice).
app/src/main/java/io/nekohasekai/sagernet/database/ProtocolRegistry.kt New file: single-source descriptor table for all 22 protocol types; startup duplicate-id check; forType/forBean/clearAllBeans helpers. All wire-format constraints documented and tested.
app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt Removes ~130 lines of parallel when-ladders; delegates to ProtocolRegistry. Adds lifetimeRx/lifetimeTx columns with @ColumnInfo(defaultValue="0"). Behaviorally equivalent to prior code per the ProtocolRegistryDispatchTest.
app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt Bumps DB version to 12, adds additive AutoMigration(11→12), gates allowMainThreadQueries() behind BuildConfig.ALLOW_MAIN_THREAD_DB (debug=false, release=true for staged migration).
app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt New file: paged-group fragment extracted from ConfigurationFragment. Guards onViewCreated with isInitialized check; state is saved/restored via Bundle. Lifetime stats surfaced via TooltipCompat long-press.
app/src/main/java/io/nekohasekai/sagernet/ktx/FabContainer.kt New interface seam breaking ktx→ui dependency. FixedLinearLayoutManager and FixedGridLayoutManager now cast context to FabContainer instead of MainActivity.
app/src/main/java/io/nekohasekai/sagernet/ui/UiExt.kt New file: UI-coupled extension helpers (snackbar, needReload, needRestart, startFilesForResult, triggerFullRestart) moved from ktx/Utils.kt to break the ktx→ui cycle.
scripts/check-lint-baseline.sh New script: counts <issue elements in app/lint-baseline.xml and fails if the count grew vs. merge-base with main. Handles missing baseline, shallow clone, and no-match gracefully.
app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/12.json Room schema snapshot for v12: adds lifetimeRx/lifetimeTx INTEGER NOT NULL DEFAULT 0 columns to proxy_entities. Identity hash regenerated by CI build.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant LoopCo as LoopCoroutine
    participant GSCo as GlobalScopeCoroutine
    participant Mutex as lifetimeMutex
    participant DB as ProfileManager

    LoopCo->>GSCo: runOnDefaultDispatcher flushLifetimeDelta
    LoopCo->>LoopCo: continues updateAll/delay

    GSCo->>Mutex: withLock acquire
    GSCo->>GSCo: "delta = rx - rxBase - flushedRx"
    GSCo->>DB: addLifetimeTraffic(id, delta)
    GSCo->>GSCo: "flushedRx += delta"
    GSCo->>Mutex: release

    LoopCo->>LoopCo: stop called cancelAndJoin
    LoopCo->>LoopCo: persist iterates idMap

    alt GSCo already finished
        LoopCo->>Mutex: withLock acquire
        LoopCo->>LoopCo: "delta=0 flushedRx already advanced"
        LoopCo->>Mutex: release
    else GSCo still in-flight
        LoopCo->>Mutex: withLock blocks
        GSCo->>Mutex: release
        LoopCo->>Mutex: acquire
        LoopCo->>LoopCo: "delta=0 flushedRx advanced by GSCo"
        LoopCo->>Mutex: release
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant LoopCo as LoopCoroutine
    participant GSCo as GlobalScopeCoroutine
    participant Mutex as lifetimeMutex
    participant DB as ProfileManager

    LoopCo->>GSCo: runOnDefaultDispatcher flushLifetimeDelta
    LoopCo->>LoopCo: continues updateAll/delay

    GSCo->>Mutex: withLock acquire
    GSCo->>GSCo: "delta = rx - rxBase - flushedRx"
    GSCo->>DB: addLifetimeTraffic(id, delta)
    GSCo->>GSCo: "flushedRx += delta"
    GSCo->>Mutex: release

    LoopCo->>LoopCo: stop called cancelAndJoin
    LoopCo->>LoopCo: persist iterates idMap

    alt GSCo already finished
        LoopCo->>Mutex: withLock acquire
        LoopCo->>LoopCo: "delta=0 flushedRx already advanced"
        LoopCo->>Mutex: release
    else GSCo still in-flight
        LoopCo->>Mutex: withLock blocks
        GSCo->>Mutex: release
        LoopCo->>Mutex: acquire
        LoopCo->>LoopCo: "delta=0 flushedRx advanced by GSCo"
        LoopCo->>Mutex: release
    end
Loading

Comments Outside Diff (1)

  1. app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt, line 109-122 (link)

    P1 Lifetime delta can be double-counted on disconnect after a selector switch

    runOnDefaultDispatcher launches on GlobalScope (see Asyncs.kt), so its coroutine is not a child of the loop's Job. When stop() calls job?.cancelAndJoin(), it waits only for the loop coroutine to finish, then immediately calls persist(). If the GlobalScope coroutine from applySelect is still in-flight at that point, both it and persist() read item.lifetimeFlushedRx == 0 (the update from the GlobalScope path hasn't landed yet), compute the same positive delta, and each call addLifetimeTraffic(id, delta, ...). Because the SQL update is additive (lifetimeRx + :rxDelta), the delta is written twice — the last selector-switch interval before disconnect gets double-counted in the lifetime total. Awaiting the Job returned by runOnDefaultDispatcher, or collecting those jobs in a list and joining them inside stop() before persist(), would close this window.

Reviews (4): Last reviewed commit: "fix: address Greptile findings (lifetime..." | Re-trigger Greptile

hawkff added 10 commits July 3, 2026 08:34
Repoint the libneko clone and the sing-vmess module replace at the project's
own org mirrors (identical code at the pinned commit/tag, verified byte-for-byte).
Adds the same set-url/fetch guard the sibling source already uses so a cached
checkout repoints cleanly. go.sum regenerated (not hand-edited).
Move the capture-free chain resolvers (resolveChain/resolveChainInternal) and
the DNS domain-strategy helpers (genDomainStrategy/autoDnsDomainStrategy) to
top-level private functions with explicit params. Thin nested wrappers keep the
existing call sites unchanged, so this is a behavior-preserving move. The heavily
entangled buildChain (captures 13+ mutable accumulators) is intentionally left
nested — parameterizing it would harm clarity without benefit (Plan 028 STOP).
…hrink-only

Promote TrustAllX509TrustManager from ignore to error - app code has no
all-trusting X509TrustManager today, so fail-fast if one is ever introduced
(a real MITM vector for a privacy-focused app). Raise RestrictedApi to warning
so cross-version internal misuse is surfaced rather than hidden; leave the
cosmetic/i18n ignores as intentional noise suppression.

Add scripts/check-lint-baseline.sh (count-only, may-only-shrink vs the merge
base) wired into the Depot and CI guard jobs, and document the baseline policy
at the top of lint.xml. Guard checkouts fetch full history for the merge-base.
Move the Fragment/Activity UI-extension helpers (snackbar, startFilesForResult,
needReload, needRestart, triggerFullRestart) out of ktx into a new ui/UiExt.kt
so ktx no longer imports MainActivity/ThemedActivity. The layout managers in
ktx/Layouts.kt now drive the FAB through a new leaf-level FabContainer interface
(implemented by MainActivity) instead of casting to MainActivity directly. All
call sites are in the ui package and resolve same-package; stale ktx imports and
now-dead imports removed. Pure relocation - no behavior change.
… its own file

Move the nested ConfigurationFragment.GroupFragment (with its ConfigurationAdapter/
ConfigurationHolder) into a top-level ConfigurationGroupFragment.kt. It is recreated
by the FragmentStateAdapter via createFragment(), so the class-name change does not
affect saved-state restoration. It never lexically captured the outer fragment (it
reaches the parent via parentFragment as ConfigurationFragment), so the move is
behavior-preserving. exportConfig visibility relaxed private -> internal for the
launcher hand-off; SelectCallback reference qualified. Now-unused imports pruned from
ConfigurationFragment.kt. Pure move - no behavior change.
…bean types

Pure-JVM golden net (runs on the Depot unit-tests workflow) covering all 22
persistable ProxyEntity bean types: asserts serialize -> deserialize -> serialize
byte-stability and full ProxyEntity type dispatch (putBean sets the right TYPE_* id
and typed field; requireBean()/putByteArray round-trip to identical bytes). This is
the safety net gating the protocol-registry refactor - if dispatch ever routes a type
to the wrong (de)serializer the byte-arrays diverge and the test fails.
…tor registry

Introduce ProtocolRegistry: each protocol is declared once as a ProtocolDescriptor
(type id, Kryo deserializer, bean class, typed-field get/set, displayType, needExternal).
Route putByteArray (deserialize dispatch), requireBean, displayType, needExternal and
putBean (clear + assign + type id) through it, replacing the parallel when(type)/when(bean)
ladders.

Wire format and TYPE_* ids are unchanged: each descriptor references the same
KryoConverters.*Deserialize the old ladder used and read/writes the same typed field, so
serialize(requireBean()) and putByteArray(type, bytes) produce identical bytes. Bean
dispatch keys on exact class (no registered bean is subclassed, so equivalent to the old
is-checks). Unknown-id / unregistered-bean paths preserve the old no-op / error behavior.
Guarded by ProtocolRegistryDispatchTest across all 22 bean types. singMux, toStdLink and
settingIntent stay in ProxyEntity (per-encoder logic / would couple database->ui).
… v12)

Add additive lifetimeRx/lifetimeTx columns to proxy_entities (default 0, schema v12
via a pure-column AutoMigration 11->12; never destructive). TrafficLooper accumulates
the per-session delta into them on persist() and on selector switch, tracking a
per-session flushed marker so the re-entrant persist()/switch paths never double-count;
tx/rx keep their existing live/session meaning. Surface the totals read-only as a
long-press tooltip on the profile row's traffic text (shown only when history exists).
Completes the deferred half of the fa02117 capture spike.

Note: 12.json carries the additive columns; its Room identityHash is regenerated by
KSP on the CI build (cannot be computed without a build; no 11->12 migration test and
runtime uses the compiled hash, so this is safe).
Stage 1: debug StrictMode ThreadPolicy (detect main-thread disk reads/writes,
penaltyLog only) so remaining main-thread DAO access is observable on device.
Stage 2: move the proven main-thread DB reads off the UI thread - pingTest/urlTest
now read DataStore.currentGroup() inside their runOnDefaultDispatcher block instead of
before it (audited the DataStore group-helper callers and every ui-package DAO site;
the rest already run in suspend/dispatched scopes).
Stage 3: gate allowMainThreadQueries() behind BuildConfig.ALLOW_MAIN_THREAD_DB -
debug ships with it OFF (so a missed site surfaces as a catchable IllegalStateException
during the device StrictMode pass), release keeps it ON until debug has run clean, after
which the flag and the call are deleted. The flag is the revert switch if the device
pass surfaces a site the static audit missed.
- ConfigurationFragment pingTest/urlTest: after moving DataStore.currentGroup() off
  the main thread, the group name is still needed by the later test.minimize callback;
  cache it in a var assigned inside the worker (was a compile-scope break).
- TrafficLooper.flushLifetimeDelta: make it suspend and advance the flushed markers
  only AFTER ProfileManager.addLifetimeTraffic() completes, so a failed/incomplete DB
  write cannot silently drop the delta; both persist() and applySelect() await it.
- check-lint-baseline.sh: drop the redundant '|| echo 0' that could emit a second
  line; tolerate grep -c's no-match exit under set -e via '|| true'.

Declined (pre-existing, unchanged by the pure moves; not this PR's scope): moveLinear
upward-range, onUpdated RecyclerView main-thread access, showShareMenu holder.entity,
onViewStateRestored->onViewCreated re-run, UiExt needRestart requireContext lifetime.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds lifetime traffic tracking (schema v12, DAO/ProfileManager methods, TrafficLooper delta flushing), introduces a ProtocolRegistry replacing hardcoded protocol dispatch in ProxyEntity, extracts ConfigurationGroupFragment/FabContainer/UiExt from monolithic UI code, adds StrictMode disk I/O detection, and updates CI lint-baseline enforcement plus dependency forks.

Changes

Core feature and refactor changes

Layer / File(s) Summary
Database schema v12 and migration
app/schemas/.../12.json, SagerDatabase.kt, Helpers.kt
Adds Room schema v12 with lifetimeRx/lifetimeTx columns, version bump, auto-migration, and conditional main-thread query allowance via build config.
Lifetime traffic accumulation logic
ProxyEntity.kt, ProfileManager.kt, TrafficUpdater.kt, TrafficLooper.kt, strings.xml
Adds DAO increment method, ProfileManager delegate, flushed markers, and idempotent delta flushing on persist and selector switch.
ProtocolRegistry dispatch refactor
ProtocolRegistry.kt, ProxyEntity.kt, ProtocolRegistryDispatchTest.kt
Centralizes protocol type-to-bean dispatch via descriptors, replacing when-ladders; adds byte-stability and dispatch-parity tests.
ConfigBuilder chain/DNS strategy extraction
ConfigBuilder.kt
Extracts chain resolution and DNS domain-strategy logic into top-level helpers.
FabContainer abstraction and layout wiring
FabContainer.kt, Layouts.kt, MainActivity.kt
Adds FabContainer interface decoupling FAB control from MainActivity; layout managers use it during scroll.
UiExt extraction from Utils.kt
UiExt.kt, Utils.kt, BackupFragment.kt
Moves snackbar/file-picker/reload/restart helpers from ktx to ui layer.
ConfigurationGroupFragment extraction
ConfigurationGroupFragment.kt, ConfigurationFragment.kt
Introduces standalone group list fragment with adapter/holder logic; updates parent fragment references and caching.
StrictMode main-thread disk I/O detection
SagerNet.kt
Adds ThreadPolicy logging disk reads/writes on main thread.
CI lint-baseline guard and dependency fork updates
guard.yml, ci.yml, check-lint-baseline.sh, lint.xml, get_source.sh, go.mod
Enforces shrinking lint baseline and repoints dependencies to hawkff forks.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

Poem

A rabbit counts each byte with glee,
Lifetime traffic, flushed and free~
Registries sort the beans in rows,
While fragments split where feature grows.
Guard scripts watch the lint baseline tight,
Hop, hop, hooray—this build feels right! 🐇📦

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is broad, but it accurately reflects the main refactor, stats, and DX work in the PR.
Description check ✅ Passed The description clearly matches the changeset and summarizes the refactors, stats feature, and staged DB work.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

…y scope

ProxyEntity resolved isTLS/canUseSingBox via package wildcards and chainName via its
own companion; the extracted registry needs explicit imports. Add fmt.v2ray.isTLS and
fmt.hysteria.canUseSingBox, and reference the chain label as ProxyEntity.chainName
(companion). Fixes the compileOssDebugKotlin failure.
Comment thread app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt Outdated
Comment thread app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt
Comment thread app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt (1)

184-184: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Delegating shims add indirection but preserve call-site syntax correctly.

Each shim (resolveChainInternal, resolveChain, genDomainStrategy, autoDnsDomainStrategy) resolves unambiguously to its top-level counterpart due to differing arity/signature, so no accidental self-recursion. Behavior-preserving as intended.

Once call sites (e.g. entity.resolveChain() at line 394, genDomainStrategy(...) at line 380, autoDnsDomainStrategy(...) at lines 1058/1071/1184) are updated to call the top-level functions directly, these four local wrappers could be removed to avoid the extra indirection layer.

Also applies to: 196-196, 248-248, 289-289

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt` at line 184,
The local extension shims are now redundant because the matching top-level
functions are being called directly at the listed call sites. Remove the wrapper
methods like ProxyEntity.resolveChainInternal, ProxyEntity.resolveChain,
genDomainStrategy, and autoDnsDomainStrategy from ConfigBuilder once all usages
point to the top-level counterparts, and verify any remaining references resolve
to the intended top-level functions without the extra indirection.
app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt (1)

114-134: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

StrictMode policy installed after several main-thread startup calls.

The ThreadPolicy is set near the end of onCreate(), after JavaUtil.handleWebviewDir(this), Theme.apply(this), Theme.applyNightTheme(), and AppLocale.apply() have already executed on the main thread. Any disk I/O performed by those calls won't be caught by detectDiskReads()/detectDiskWrites(), undermining the stated goal of surfacing main-thread disk access during startup.

Consider moving the StrictMode.setThreadPolicy(...) call to the very start of the BuildConfig.DEBUG block (or even before it, alongside Thread.setDefaultUncaughtExceptionHandler), so it's active before any of the earlier startup work runs.

♻️ Proposed reordering
+        if (BuildConfig.DEBUG) {
+            System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)
+            StrictMode.setThreadPolicy(
+                StrictMode.ThreadPolicy.Builder()
+                    .detectDiskReads()
+                    .detectDiskWrites()
+                    .penaltyLog()
+                    .build(),
+            )
+        }
+
         if (isMainProcess || isBgProcess) {
             externalAssets.mkdirs()
             ...
         }
         ...
         if (BuildConfig.DEBUG) {
-            System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)
-            StrictMode.setThreadPolicy(
-                StrictMode.ThreadPolicy.Builder()
-                    .detectDiskReads()
-                    .detectDiskWrites()
-                    .penaltyLog()
-                    .build(),
-            )
             StrictMode.setVmPolicy(
                 StrictMode.VmPolicy.Builder()
                     ...
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt` around lines 114 -
134, The StrictMode setup in SagerNet.onCreate is being applied too late, after
startup work like JavaUtil.handleWebviewDir, Theme.apply, Theme.applyNightTheme,
and AppLocale.apply has already run. Move the StrictMode.setThreadPolicy call to
the beginning of the BuildConfig.DEBUG initialization block (or before it with
the other early startup setup) so disk reads/writes during those earlier calls
are actually detected; keep the existing ThreadPolicy.Builder and
VmPolicy.Builder configuration unchanged.
app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt (1)

132-133: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Satisfy detekt without an empty override body.

onSwiped is intentionally a no-op; express it as = Unit.

Proposed fix
-            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
-            }
+            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt`
around lines 132 - 133, The override of onSwiped in ConfigurationGroupFragment
is an intentional no-op, but detekt flags the empty body; change this method to
an expression-bodied override using Unit so the intent is explicit while keeping
the same behavior.

Source: Linters/SAST tools

app/src/main/java/io/nekohasekai/sagernet/ui/UiExt.kt (1)

98-99: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Satisfy detekt without an empty override body.

stateChanged is required by the callback interface, but this can be expressed as = Unit.

Proposed fix
-    override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {
-    }
+    override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) = Unit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/UiExt.kt` around lines 98 - 99,
The stateChanged override in UiExt.kt has an empty body, which detekt flags;
update the BaseService.State callback implementation to use an expression body
with = Unit instead of an empty block. Keep the same stateChanged signature and
preserve its role as the required callback while making the implementation
explicitly no-op.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt`:
- Around line 112-118: The selector-switch traffic flush is launched
asynchronously in `applySelect()`, so `flushLifetimeDelta()` can race with
shutdown persistence and double-count the same delta. Make `applySelect()`
suspend and await the `runOnDefaultDispatcher` work in
`TrafficLooper.applySelect`, ensuring `ProfileManager.updateTraffic(...)` and
`flushLifetimeDelta(...)` complete before returning and the marker advances
deterministically.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt`:
- Around line 817-824: Avoid sharing the mutable groupName across dispatchers in
ConfigurationFragment’s minimize-to-notification flow, since it is written
inside runOnDefaultDispatcher and later read by the callback. Update the logic
so the name is assigned on the main thread before the callback uses it, or store
it in a thread-safe holder such as AtomicReference, and keep a fallback title
until the value is available. Apply the same fix pattern to the related test
callbacks that read the same captured name.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt`:
- Around line 528-589: Keep all adapter and RecyclerView interactions on the
main thread. In ConfigurationGroupFragment’s onUpdated(profile, noTraffic), move
the configurationIdList lookup and related state reads/writes into the
configurationListView.post/onMainDispatcher block instead of doing them before
the hop; likewise in onUpdated(TrafficData) and onUpdated(List<TrafficData>),
defer layoutManager.findViewByPosition and
configurationListView.getChildViewHolder until inside onMainDispatcher so they
run together on main. Also audit reloadProfiles() so any configurationList
mutation happens on the same main-thread dispatch as the view update, using the
existing configurationListView, layoutManager, and undoManager flow to keep
access consistent.
- Around line 285-287: Listener cleanup in ConfigurationGroupFragment is scoped
too late, so the old ConfigurationAdapter can still receive callbacks after the
view is detached. Move the listener removal for ProfileManager and GroupManager,
along with undoManager.flush(), from onDestroy to onDestroyView, and keep the
addListener setup in onViewCreated aligned with the adapter lifecycle.

---

Nitpick comments:
In `@app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt`:
- Line 184: The local extension shims are now redundant because the matching
top-level functions are being called directly at the listed call sites. Remove
the wrapper methods like ProxyEntity.resolveChainInternal,
ProxyEntity.resolveChain, genDomainStrategy, and autoDnsDomainStrategy from
ConfigBuilder once all usages point to the top-level counterparts, and verify
any remaining references resolve to the intended top-level functions without the
extra indirection.

In `@app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt`:
- Around line 114-134: The StrictMode setup in SagerNet.onCreate is being
applied too late, after startup work like JavaUtil.handleWebviewDir,
Theme.apply, Theme.applyNightTheme, and AppLocale.apply has already run. Move
the StrictMode.setThreadPolicy call to the beginning of the BuildConfig.DEBUG
initialization block (or before it with the other early startup setup) so disk
reads/writes during those earlier calls are actually detected; keep the existing
ThreadPolicy.Builder and VmPolicy.Builder configuration unchanged.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt`:
- Around line 132-133: The override of onSwiped in ConfigurationGroupFragment is
an intentional no-op, but detekt flags the empty body; change this method to an
expression-bodied override using Unit so the intent is explicit while keeping
the same behavior.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/UiExt.kt`:
- Around line 98-99: The stateChanged override in UiExt.kt has an empty body,
which detekt flags; update the BaseService.State callback implementation to use
an expression body with = Unit instead of an empty block. Keep the same
stateChanged signature and preserve its role as the required callback while
making the implementation explicitly no-op.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7342e035-1493-4c10-915a-34fcf4df300d

📥 Commits

Reviewing files that changed from the base of the PR and between 4c0ac93 and 8b4808b.

⛔ Files ignored due to path filters (1)
  • libcore/go.sum is excluded by !**/*.sum
📒 Files selected for processing (26)
  • .depot/workflows/guard.yml
  • .github/workflows/ci.yml
  • app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/12.json
  • app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt
  • app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt
  • app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt
  • app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt
  • app/src/main/java/io/nekohasekai/sagernet/database/ProtocolRegistry.kt
  • app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
  • app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
  • app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
  • app/src/main/java/io/nekohasekai/sagernet/ktx/FabContainer.kt
  • app/src/main/java/io/nekohasekai/sagernet/ktx/Layouts.kt
  • app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt
  • app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt
  • app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
  • app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt
  • app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt
  • app/src/main/java/io/nekohasekai/sagernet/ui/UiExt.kt
  • app/src/main/res/values/strings.xml
  • app/src/test/java/io/nekohasekai/sagernet/fmt/ProtocolRegistryDispatchTest.kt
  • buildScript/lib/core/get_source.sh
  • buildSrc/src/main/kotlin/Helpers.kt
  • libcore/go.mod
  • lint.xml
  • scripts/check-lint-baseline.sh
💤 Files with no reviewable changes (2)
  • app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt
  • app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt

Comment on lines +112 to +118
val switchedFrom = this
data.proxy?.config?.trafficMap?.get(tag)?.firstOrNull()?.let {
it.rx = rx
it.tx = tx
runOnDefaultDispatcher {
ProfileManager.updateTraffic(it.id, it.rx, it.tx)
flushLifetimeDelta(it.id, switchedFrom)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect whether runOnDefaultDispatcher awaits the block or launches it asynchronously.
rg -n -C4 'fun\s+runOnDefaultDispatcher|runOnDefaultDispatcher\s*=' app/src/main/java buildSrc

# Inspect call sites for assumptions around fire-and-forget vs awaited behavior.
rg -n -C3 'runOnDefaultDispatcher\s*\{' app/src/main/java

Repository: hawkff/NekoBoxForAndroid

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant section of TrafficLooper.kt.
nl -ba app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt | sed -n '1,260p'

# Find flush/persist state mutations in the file.
rg -n -C3 'flushLifetimeDelta|persist\(|lifetimeFlushed|applySelect|switchedFrom' app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt

Repository: hawkff/NekoBoxForAndroid

Length of output: 202


🏁 Script executed:

set -euo pipefail

# Show the relevant section with line numbers using portable tools.
sed -n '1,260p' app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt | cat -n

# Find every occurrence of the state and helper names in this file.
rg -n -C3 'flushLifetimeDelta|persist\(|lifetimeFlushed|applySelect|switchedFrom' app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt

Repository: hawkff/NekoBoxForAndroid

Length of output: 15640


Await the selector-switch flush before returning
runOnDefaultDispatcher launches this work asynchronously, so flushLifetimeDelta() can still overlap with persist() on shutdown and double-count the same delta. Make applySelect() suspend and wait for the DB write and marker advance here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt` around
lines 112 - 118, The selector-switch traffic flush is launched asynchronously in
`applySelect()`, so `flushLifetimeDelta()` can race with shutdown persistence
and double-count the same delta. Make `applySelect()` suspend and await the
`runOnDefaultDispatcher` work in `TrafficLooper.applySelect`, ensuring
`ProfileManager.updateTraffic(...)` and `flushLifetimeDelta(...)` complete
before returning and the marker advances deterministically.

Comment on lines +817 to +824
// Group display name for the minimize->notification callback. The DAO read must stay off
// the main thread, so it is fetched inside the worker below and cached here for the
// callback (which fires later, on user tap).
var groupName = ""

val mainJob = runOnDefaultDispatcher {
val group = DataStore.currentGroup()
groupName = group.displayName()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant fragment and nearby usages.
file="app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt"

echo "== line count =="
wc -l "$file"

echo
echo "== AST outline =="
ast-grep outline "$file" --view expanded | sed -n '1,220p'

echo
echo "== Relevant line ranges around 817-824, 956-960, 972-977, 1041-1045 =="
sed -n '790,860p' "$file"
echo
sed -n '930,990p' "$file"
echo
sed -n '1020,1060p' "$file"

echo
echo "== Searches for dispatch helpers and minimize callback =="
rg -n "runOnDefaultDispatcher|onMainDispatcher|minimize|notification|groupName|displayName" "$file"

Repository: hawkff/NekoBoxForAndroid

Length of output: 11552


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect definitions/usages of the helper functions if they are in the repo.
rg -n "fun runOnDefaultDispatcher|val runOnDefaultDispatcher|suspend fun runOnDefaultDispatcher|fun onMainDispatcher|val onMainDispatcher|suspend fun onMainDispatcher" app/src/main/java

# Find where groupName is captured/used outside the listed snippet locations.
rg -n "groupName" app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt

Repository: hawkff/NekoBoxForAndroid

Length of output: 605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show only the surrounding blocks for the listed locations so we can compare
# how `groupName` is produced and consumed.
file="app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt"

for range in "817,824" "956,960" "972,977" "1041,1045"; do
  echo "== $range =="
  sed -n "${range}p" "$file"
  echo
done

Repository: hawkff/NekoBoxForAndroid

Length of output: 1347


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the implementations of the coroutine helpers to determine thread-hopping semantics.
rg -n "runOnDefaultDispatcher|onMainDispatcher" app/src/main/java -g '!**/build/**' -g '!**/out/**'

Repository: hawkff/NekoBoxForAndroid

Length of output: 28119


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrowly inspect the helper definitions and the exact callback wiring.
# This is read-only and should show whether the callback is always reached on the main thread.
rg -n -C 3 "fun runOnDefaultDispatcher|fun onMainDispatcher|groupName =|groupName\b" app/src/main/java/io/nekohasekai/sagernet | sed -n '1,220p'

Repository: hawkff/NekoBoxForAndroid

Length of output: 12180


Avoid sharing groupName across dispatchers here
groupName is written on Dispatchers.Default and later read from the minimize callback, so the notification can still render [] ... or a stale name. Set it on the main thread or use an AtomicReference, and keep a fallback title until the name is ready. Same pattern in the other test callbacks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt` around
lines 817 - 824, Avoid sharing the mutable groupName across dispatchers in
ConfigurationFragment’s minimize-to-notification flow, since it is written
inside runOnDefaultDispatcher and later read by the callback. Update the logic
so the name is assigned on the main thread before the callback uses it, or store
it in a thread-safe holder such as AtomicReference, and keep a fallback title
until the value is available. Apply the same fix pattern to the related test
callbacks that read the same captured name.

Comment on lines +285 to +287
adapter = ConfigurationAdapter()
ProfileManager.addListener(adapter!!)
GroupManager.addListener(adapter!!)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the fragment lifecycle methods and related listener usage.
file="app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt"

echo "=== Outline ==="
ast-grep outline "$file" --view expanded || true

echo
echo "=== Relevant lines around lifecycle methods ==="
nl -ba "$file" | sed -n '240,360p'

echo
echo "=== Search for listener registration/removal and undoManager usage in this file ==="
rg -n "addListener|removeListener|undoManager|onDestroyView|onDestroy" "$file"

echo
echo "=== Search for adapter/view binding references in this file ==="
rg -n "binding|adapter|RecyclerView|viewLifecycleOwner|requireView|onViewCreated" "$file"

Repository: hawkff/NekoBoxForAndroid

Length of output: 1590


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt"

echo "=== onViewCreated / onDestroy / nearby code ==="
python3 - <<'PY'
from pathlib import Path
path = Path("app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt")
lines = path.read_text().splitlines()
for start, end in [(279, 336), (1, 120), (150, 240)]:
    print(f"\n--- lines {start}-{end} ---")
    for i in range(start, min(end, len(lines)) + 1):
        print(f"{i:4}: {lines[i-1]}")
PY

echo
echo "=== listener and binding references in file ==="
rg -n "addListener|removeListener|undoManager|binding\\.|configurationListView|onDestroyView|onDestroy" "$file"

echo
echo "=== any onDestroyView override in this fragment or nearby classes ==="
rg -n "override fun onDestroyView|override fun onDestroy\\(" app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt app/src/main/java/io/nekohasekai/sagernet/ui -g '*.kt'

Repository: hawkff/NekoBoxForAndroid

Length of output: 17628


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt"

python3 - <<'PY'
from pathlib import Path
path = Path("app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt")
lines = path.read_text().splitlines()
for start, end in [(337, 520), (520, 760), (760, 940)]:
    print(f"\n--- lines {start}-{end} ---")
    for i in range(start, min(end, len(lines)) + 1):
        print(f"{i:4}: {lines[i-1]}")
PY

echo
echo "=== Adapter and manager-specific calls ==="
rg -n "class ConfigurationAdapter|ProfileManager\\.|GroupManager\\.|notify|reloadProfiles|commitMove|onUpdate|onRemove|onAdd|listener" "$file"

Repository: hawkff/NekoBoxForAndroid

Length of output: 29933


Scope listener cleanup to onDestroyView. Because the adapter is recreated in onViewCreated, leaving the old listeners registered until onDestroy lets callbacks hit a detached view after onDestroyView. Move listener removal and undoManager.flush() there.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt`
around lines 285 - 287, Listener cleanup in ConfigurationGroupFragment is scoped
too late, so the old ConfigurationAdapter can still receive callbacks after the
view is detached. Move the listener removal for ProfileManager and GroupManager,
along with undoManager.flush(), from onDestroy to onDestroyView, and keep the
addListener setup in onViewCreated aligned with the adapter lifecycle.

Comment on lines +528 to +589
override suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) {
if (profile.groupId != proxyGroup.id) return
val index = configurationIdList.indexOf(profile.id)
if (index < 0) return
configurationListView.post {
if (::undoManager.isInitialized) {
undoManager.flush()
}
val oldProfile = configurationList[profile.id]
configurationList[profile.id] = profile
notifyItemChanged(index)
if (noTraffic && oldProfile != null) {
runOnDefaultDispatcher {
onUpdated(
TrafficData(
id = profile.id,
rx = oldProfile.rx,
tx = oldProfile.tx,
),
)
}
}
}
}

override suspend fun onUpdated(data: TrafficData) {
try {
val index = configurationIdList.indexOf(data.id)
if (index != -1) {
val holder = layoutManager.findViewByPosition(index)
?.let { configurationListView.getChildViewHolder(it) } as ConfigurationHolder?
if (holder != null) {
onMainDispatcher {
holder.bind(holder.entity, data)
}
}
}
} catch (e: Exception) {
Logs.w(e)
}
}

override suspend fun onUpdated(data: List<TrafficData>) {
try {
val positions = HashMap<Long, Int>(configurationIdList.size)
configurationIdList.forEachIndexed { index, id -> positions[id] = index }
val updates = ArrayList<Pair<ConfigurationHolder, TrafficData>>()
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)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt'

# Show structure around the relevant region
ast-grep outline "$FILE" --view expanded || true

# Read the target lines with context
sed -n '470,700p' "$FILE"

# Find related dispatcher / adapter helpers in the same file
rg -n "onMainDispatcher|runOnDefaultDispatcher|configurationIdList|configurationList|layoutManager\.findViewByPosition|getChildViewHolder|post \{" "$FILE"

# Inspect surrounding definitions for the adapter/list state
rg -n "class .*Holder|class .*Adapter|configurationListView|layoutManager" "$FILE"

Repository: hawkff/NekoBoxForAndroid

Length of output: 17172


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the callback interface and dispatcher helpers
rg -n "interface .*onUpdated|suspend fun onUpdated|fun onMainDispatcher|fun runOnDefaultDispatcher" app/src/main/java

# Read the dispatcher helper implementations if present
for f in $(rg -l "fun onMainDispatcher|fun runOnDefaultDispatcher" app/src/main/java); do
  echo "===== $f ====="
  sed -n '1,220p' "$f"
done

# Read the interface / base class that defines the update callbacks
for f in $(rg -l "suspend fun onUpdated\\(" app/src/main/java); do
  echo "===== $f ====="
  sed -n '1,260p' "$f"
done

Repository: hawkff/NekoBoxForAndroid

Length of output: 41974


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find callers of the update APIs to infer dispatcher/context
rg -n "ProfileManager\.(postUpdate|updateProfile|updateTraffic|createProfile|deleteProfile|updateProfileQuietly)|iterator \{" app/src/main/java

# Read the surrounding call sites that may trigger these callbacks
for f in $(rg -l "ProfileManager\.(postUpdate|updateProfile|updateTraffic|deleteProfile|updateProfileQuietly)|iterator \{" app/src/main/java); do
  echo "===== $f ====="
  sed -n '1,260p' "$f"
done

Repository: hawkff/NekoBoxForAndroid

Length of output: 50380


Keep adapter and RecyclerView access on the main thread. onUpdated(profile, noTraffic) reads configurationIdList before the post {} hop, onUpdated(TrafficData) / onUpdated(List<TrafficData>) call layoutManager.findViewByPosition(...) and getChildViewHolder(...) before onMainDispatcher {}, and reloadProfiles() mutates configurationList before posting back to the view. Move those lookups and mutations onto the main thread together.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationGroupFragment.kt`
around lines 528 - 589, Keep all adapter and RecyclerView interactions on the
main thread. In ConfigurationGroupFragment’s onUpdated(profile, noTraffic), move
the configurationIdList lookup and related state reads/writes into the
configurationListView.post/onMainDispatcher block instead of doing them before
the hop; likewise in onUpdated(TrafficData) and onUpdated(List<TrafficData>),
defer layoutManager.findViewByPosition and
configurationListView.getChildViewHolder until inside onMainDispatcher so they
run together on main. Also audit reloadProfiles() so any configurationList
mutation happens on the same main-thread dispatch as the view update, using the
existing configurationListView, layoutManager, and undoManager flow to keep
access consistent.

hawkff added 2 commits July 3, 2026 10:37
spotlessApply output only: expand single-line functions, wrap the long @query,
fix test import ordering, drop an unused Fragment import in Utils.kt. No logic change.
…sage)

- TrafficLooper: guard the lifetime read-modify-write with a Mutex so the loop
  coroutine (persist) and the GlobalScope selector-switch coroutine cannot interleave
  the check-and-advance of lifetimeFlushed* and double-count an interval (P1).
- ProxyEntity.putBean: report the offending bean class in the error instead of the
  stale entity type id, which was misleading for an unregistered bean (P2).
@hawkff hawkff merged commit 25bf0fc into main Jul 3, 2026
8 checks passed
@hawkff hawkff deleted the chore/remaining-debt-features branch July 3, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant