From f8e64182f66b097feb700f2f6ec161c2f95c3b19 Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 15:22:32 +0800 Subject: [PATCH 01/29] docs: define custom proxy groups design --- .../2026-09-04-custom-proxy-groups-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-custom-proxy-groups-design.md diff --git a/docs/superpowers/specs/2026-09-04-custom-proxy-groups-design.md b/docs/superpowers/specs/2026-09-04-custom-proxy-groups-design.md new file mode 100644 index 0000000000..dea1b19d53 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-custom-proxy-groups-design.md @@ -0,0 +1,170 @@ +# NekoBox Custom Proxy Groups Design + +## Goal + +Add user-defined proxy groups to NekoBox for Android with FlClash-like source selection and filtering. A user can create any number of groups, select one or more subscription sources, filter their nodes, choose `selector` or `url-test`, and select the group as a route outbound. Existing subscription, routing, AdBlock, App routing, DNS, TUN, and ordinary node behavior must remain unchanged unless the user explicitly uses a custom group. + +## Confirmed Scope + +- Groups are created, named, edited, reordered, enabled, and deleted by the user. +- No predefined `US`, `US low`, `SG`, or `JP` groups are created. +- A node may belong to multiple custom groups. +- Each group selects one or more existing subscription groups as sources. +- The first version supports an optional include regular expression and an optional exclude regular expression against the displayed node name. +- An empty include expression includes every node from the selected sources. The exclude expression is applied after the include expression and wins when both match. +- Invalid regular expressions prevent saving and show a validation error. +- The first version supports only `selector` and `url-test` modes. +- `fallback`, `load-balance`, group nesting, protocol filtering, multiplier filtering, region enums, and nested logical condition trees are out of scope. +- Existing route rules can explicitly select a custom group as their outbound. +- No route is assigned to a custom group by its name or content automatically. +- Full Clash/Mihomo YAML import is out of scope. + +## FlClash Reference Boundary + +Use FlClash's proxy-group model and editor as behavioral references, especially its `use`, `filter`, `exclude-filter`, `url`, `interval`, and group-type fields. NekoBox remains a Kotlin/Room/sing-box application, so Flutter widgets and Mihomo runtime code will not be copied mechanically. Any concrete copied code must be compatible with GPL-3.0 and retain appropriate attribution. + +References inspected at FlClash commit `62addf738a76b1a492e19af2dbabdb6d572b9e72`: + +- `lib/models/clash_config.dart` +- `lib/database/groups.dart` +- `lib/views/profiles/overwrite/custom/groups.dart` + +## Data Model + +### Custom group + +Reuse the existing Router group concept but make it fully user-defined. Each record stores: + +- database ID; +- immutable stable tag generated at creation and never derived from the display name; +- unique non-blank display name; +- mode: `selector` or `url-test`; +- enabled state and user order; +- include regex and exclude regex; +- URL-test URL, interval, tolerance, and timeout using safe project defaults when omitted; +- selected node stable identity for selector mode. + +The UI calls this feature "Proxy groups" or its Chinese equivalent. Internal Router naming may remain in implementation types where changing it would create unnecessary risk. + +### Source relationship + +Store a many-to-many relationship between a custom group and subscription `ProxyGroup` records. A group can use multiple subscriptions, and one subscription can feed multiple groups. + +### Materialized membership + +Persist the latest resolved members so selection state and error reporting survive refreshes. Membership is many-to-many: there is no uniqueness constraint on `proxyId` across different custom groups. Stable node identity is scoped by subscription source so identical nodes in different subscriptions are not conflated. + +### Route relationship + +Add an optional custom-group reference to `RuleEntity`. Preserve the existing numeric `outbound` field and its semantics for legacy proxy, direct, block, and profile targets. A route selects either its legacy outbound or a custom-group ID, never both. Configuration generation resolves the ID to the group's immutable stable tag. Renaming a group therefore does not break routes. + +Database and backup versions must migrate incrementally. Existing debug installations using the current Router schema must also migrate without deleting subscriptions or rules. + +## Membership Resolution + +For each enabled group: + +1. Load nodes belonging to the selected subscription sources. +2. Preserve source order and node order deterministically. +3. Apply the include regex when non-empty. +4. Apply the exclude regex and remove every match. +5. Deduplicate the same node within that group only. +6. Allow the same node to appear independently in other groups. +7. Materialize the result and remap the selected node using stable identity after subscription refresh. + +Membership is recomputed after importing, updating, deleting, or clearing a subscription and before configuration generation if stored membership is stale. A failed or empty subscription refresh does not erase the last valid membership snapshot. Deleting a source removes that source from group criteria and triggers recomputation. + +## Runtime Configuration + +For every enabled non-empty group, `ConfigBuilder` emits one sing-box outbound using the stable tag: + +- `selector`: member outbound tags plus the persisted selected member as `default`; +- `urltest`: member outbound tags plus the configured test URL, interval, tolerance, and timeout supported by the pinned sing-box version. + +Normal node outbounds are built once and may be referenced by multiple groups. A group tag must not collide with system or node tags. + +Changing a selector choice attempts a targeted runtime switch for that group. If safe targeted switching is unavailable, perform a full service reload and report failure visibly. Editing criteria or changing mode performs a full reload because group topology changed. + +## Route Editing + +Extend the existing route outbound picker with a "Proxy group" choice. Selecting it opens a list of enabled non-empty custom groups and stores the group ID. Rule summaries display the current group name. + +No semantic name matching is permitted. Existing Google, Telegram, YouTube, App, AdBlock, `.invalid`, direct, block, and profile-target rules retain their stored outbound until the user edits them. + +Deleting a group referenced by routes is blocked with a message listing the number of references. The user must reassign those routes first. This prevents silent traffic diversion. + +## UI Flow + +Add a dedicated custom-group list and editor rather than embedding fixed cards in the subscription list. + +The editor contains: + +- group name; +- mode (`selector` or `url-test`); +- subscription sources, multi-select; +- include regex; +- exclude regex; +- URL-test settings shown only in `url-test` mode; +- live preview showing the matched node count and node names; +- enabled switch and save/delete actions. + +The runtime group view shows mode, member count, selected/current node, and the latest resolution error. A selector group opens its member list for manual selection. A URL-test group shows the core-selected current node and supports triggering the existing group test behavior where available. + +## Error Handling + +- Invalid regex: reject save and identify the invalid field. +- No source selected: allow saving a disabled draft only; an enabled group requires at least one source. +- No matched nodes: save the group but mark it unavailable. If an enabled route references it, service configuration/start fails with a clear group-specific error rather than silently using another outbound. +- Subscription refresh failure: retain the last valid members and show the refresh error. +- Missing source or member: remove stale relationships during reconciliation and keep the group itself. +- Missing referenced group: preserve the rule record, show it as invalid, and refuse to generate a silently altered route. +- Native core mismatch: clean builds must verify `libcore.aar`, its JNI libraries, and the compiled `newHttpClient()` descriptor before producing an APK. + +## Migration from the Current Incorrect Implementation + +Retain only reusable infrastructure: + +- Router Room entities and DAOs where their schema remains suitable; +- stable node identity and refresh reconciliation; +- selector/url-test outbound generation; +- targeted selector switching in libcore; +- native-core build verification. + +Remove or replace: + +- automatic creation of four fixed groups; +- node exclusivity across groups; +- semantic route-name mapping to fixed tags; +- the fixed group cards and manual-only membership dialog; +- tests that assert four predefined groups or non-overlapping membership. + +Existing routes, subscriptions, and settings outside this feature are not normalized or rewritten. + +## Testing and Acceptance + +### Automated + +- Matcher tests for multiple sources, include/exclude precedence, invalid regex, deterministic order, and overlapping groups. +- Reconciliation tests for refreshed node IDs, renamed nodes, failed/empty refreshes, source deletion, and selected-node preservation. +- Room migration and DAO tests for current database versions through the new version. +- Backup round-trip and old-backup import tests. +- Config tests proving correct selector/url-test JSON, shared nodes across groups, stable tags, no dangling references, and no changes to unrelated routes. +- Route editor/model tests proving explicit group selection and preservation of legacy outbound values. +- Clean-build ABI check proving app bytecode and packaged libcore use the same `HTTPClient` descriptor. +- JVM tests, Android lint assessment, and debug APK build. + +### Real Android device + +1. Upgrade without losing existing subscriptions or routes. +2. Import and refresh at least two subscriptions. +3. Create `US1` using both subscriptions and a US include regex. +4. Create another group reusing at least one of the same nodes. +5. Verify selector mode changes only the selected group. +6. Verify URL-test mode chooses a reachable member. +7. Assign a route explicitly to `US1` and confirm runtime routing/log output. +8. Refresh both subscriptions and confirm both groups and the route remain valid. +9. Confirm AdBlock, `.invalid` load rules, App routing, DNS/TUN, and ordinary node operation remain unchanged. + +## Delivery Boundary + +Do not commit or ship an APK until the clean-build ABI check and automated tests pass. Static tests do not replace the real-device acceptance steps. Keep the current installed debug build recoverable until its subscriptions and settings have been migrated or backed up. From bdc6392863399e13341b92ad8c6cde14cf05bc2b Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 15:31:42 +0800 Subject: [PATCH 02/29] docs: plan custom proxy groups implementation --- .../plans/2026-09-04-custom-proxy-groups.md | 763 ++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-custom-proxy-groups.md diff --git a/docs/superpowers/plans/2026-09-04-custom-proxy-groups.md b/docs/superpowers/plans/2026-09-04-custom-proxy-groups.md new file mode 100644 index 0000000000..40601411ad --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-custom-proxy-groups.md @@ -0,0 +1,763 @@ +# NekoBox Custom Proxy Groups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build user-defined, overlapping proxy groups sourced from one or more subscriptions, filtered by include/exclude regex, emitted as sing-box `selector` or `urltest` outbounds, and explicitly selectable by route rules without changing unrelated NekoBox behavior. + +**Architecture:** Keep the existing internal Router layer but remove the incorrect fixed-group, exclusive-membership, and semantic-route assumptions. Persist group definitions, subscription-source relations, materialized members, stable selector identity, and explicit `RuleEntity` references in Room; resolve them through a focused repository before `ConfigBuilder` emits stable sing-box tags. Add a dedicated list/editor UI and route-group picker while preserving every legacy outbound path. + +**Tech Stack:** Kotlin, AndroidX Room 2.6.1, AndroidX Preference/RecyclerView, Kotlin coroutines, JUnit 4, Android instrumentation tests, sing-box Java bindings, gomobile/libcore AAR, Gradle Kotlin DSL. + +**Spec:** `docs/superpowers/specs/2026-09-04-custom-proxy-groups-design.md` + +## Global Constraints + +- User-created groups only; never create predefined `US`, `US low`, `SG`, or `JP` groups. +- A node may belong to any number of custom groups; deduplication is local to one group. +- Sources are existing subscription `ProxyGroup` rows only, and each custom group may select multiple sources. +- Empty include regex means all source nodes; exclude regex is evaluated second and wins. +- Invalid regex blocks saving and identifies the invalid field. +- Modes are exactly `selector` and `url-test`; no fallback, load-balance, nesting, region, protocol, multiplier, or Clash YAML import. +- Routes reference a custom-group database ID explicitly; no rule-name/domain semantic inference is allowed. +- `RuleEntity.outbound` keeps its existing `0`, `-1`, `-2`, and positive-profile semantics when `routerGroupId == 0`. +- Stable group tags are generated once as `router.` and never change on rename. +- An enabled referenced group with no current members, or a missing referenced group, must stop configuration generation with a group-specific error; never silently fall back. +- Failed/empty subscription refresh retains the last valid materialized members; successful refresh recomputes them. +- Preserve subscription behavior, ordinary profiles, App routing, AdBlock, `.invalid` load rules, DNS/TUN, settings, and existing routes unless the user explicitly edits a group reference. +- Preserve and work around all pre-existing worktree changes. Do not touch `A7.yaml`, `nekobox_isA8.json`, or files outside this repository. +- Do not commit or ship an APK before JVM tests, migration/backup tests, clean-build ABI verification, lint assessment, and debug assembly succeed. +- The pinned Java binding exposes URL-test `url`, `interval`, and `tolerance`, but no outbound request-timeout field. Store no fictitious JSON field; keep the existing core probe timeout for an explicit manual test action and treat this as the implementation limit of the pinned core. + +--- + +## File Map + +- `route/RouterFilter.kt`: validated include/exclude configuration and JSON codec. +- `route/RouterMatcher.kt`: pure, independently evaluated membership matching. +- `database/RouterGroup.kt`: group record, stable tag, mode, selected stable key, latest error. +- `database/RouterGroupSource.kt`: many-to-many group-to-subscription relation and DAO. +- `database/RouterMember.kt`: materialized many-to-many group membership. +- `database/RouterGroupRepository.kt`: validation, CRUD, preview, recomputation, deletion guard, and runtime resolution. +- `database/GroupManager.kt`: subscription lifecycle hooks only; delegates group logic to the repository. +- `route/RouterReconciler.kt`: stable-identity remap scoped by source and last-valid snapshot behavior. +- `route/RouterRuntime.kt`: strict runtime outbound descriptions and empty/missing error values. +- `fmt/ConfigBuilder.kt`: build node outbounds once, emit custom groups, resolve explicit route references. +- `ui/RouterGroupListActivity.kt` and `ui/RouterGroupListFragment.kt`: dedicated custom-group list and runtime actions. +- `ui/RouterGroupSettingsActivity.kt`: focused editor with validation and live preview. +- `ui/RouterGroupSelectActivity.kt`: enabled/non-empty group picker for route editing. +- `ui/GroupFragment.kt`: one entry point to the dedicated group list; no fixed cards. +- `ui/RouteSettingsActivity.kt` and `widget/OutboundPreference.kt`: explicit group selection while preserving legacy choices. +- `ui/BackupFragment.kt` and `fmt/BackupSerializer.kt`: versioned group/source/member/reference round trip. +- `build.gradle.kts`: Room test setup and libcore ABI/package gate. + +### Task 1: Replace Fixed and Exclusive Matching with the Confirmed Filter Contract + +**Files:** +- Create: `app/src/main/java/io/nekohasekai/sagernet/route/RouterFilter.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/route/RouterMatcher.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/route/RouterMembership.kt` +- Delete: `app/src/main/java/io/nekohasekai/sagernet/route/RouterDefaults.kt` +- Test: `app/src/test/java/io/nekohasekai/sagernet/route/RouterMatcherTest.kt` +- Test: `app/src/test/java/io/nekohasekai/sagernet/route/RouterMembershipTest.kt` +- Delete: `app/src/test/java/io/nekohasekai/sagernet/route/RouterDefaultsTest.kt` + +**Interfaces:** +- Produces: `RouterFilterConfig(includeRegex: String, excludeRegex: String, testUrl: String, intervalSeconds: Long, toleranceMs: Int)`. +- Produces: `RouterFilterValidation(include: Regex?, exclude: Regex?)` and `RouterFilterException(field: Field, cause: Throwable)`. +- Produces: `RouterMatcher.match(nodes: Iterable, requests: Iterable): Map>`. +- Produces: `RouterMatchRequest(routerId: Long, sourceGroupIds: Set, filter: RouterFilterValidation)`. + +- [ ] **Step 1: Replace the matcher tests with the approved behavior** + +```kotlin +@Test fun sameNodeMayAppearInTwoGroups() { + val node = RouterNodeSnapshot(7, "source:10/node:a", "US A", subscriptionId = 10) + val requests = listOf( + RouterMatchRequest(1, setOf(10), RouterFilterConfig("US", "").validate()), + RouterMatchRequest(2, setOf(10), RouterFilterConfig("A", "").validate()), + ) + assertEquals(mapOf(1L to listOf(7L), 2L to listOf(7L)), RouterMatcher.match(listOf(node), requests)) +} + +@Test fun excludeWinsAndEmptyIncludeMeansAll() { + val nodes = listOf( + RouterNodeSnapshot(1, "10/a", "US Premium", subscriptionId = 10), + RouterNodeSnapshot(2, "10/b", "US Expired", subscriptionId = 10), + ) + val request = RouterMatchRequest(3, setOf(10), RouterFilterConfig("", "Expired").validate()) + assertEquals(listOf(1L), RouterMatcher.match(nodes, listOf(request)).getValue(3)) +} + +@Test fun invalidIncludeAndExcludeIdentifyTheirFields() { + assertEquals(RouterFilterException.Field.INCLUDE, assertThrows { + RouterFilterConfig("[", "").validate() + }.field) + assertEquals(RouterFilterException.Field.EXCLUDE, assertThrows { + RouterFilterConfig("", "[").validate() + }.field) +} +``` + +- [ ] **Step 2: Run the focused tests and confirm the old API fails** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.route.RouterMatcherTest" --tests "io.nekohasekai.sagernet.route.RouterMembershipTest"` + +Expected: FAIL because `RouterMatchRequest`, validation, and overlapping membership do not exist and the old test expects reserved-node exclusion. + +- [ ] **Step 3: Implement the minimal filter and independent matcher** + +```kotlin +data class RouterFilterConfig( + val includeRegex: String = "", + val excludeRegex: String = "", + val testUrl: String = "https://www.gstatic.com/generate_204", + val intervalSeconds: Long = 300, + val toleranceMs: Int = 50, +) { + fun validate() = RouterFilterValidation( + include = includeRegex.takeIf(String::isNotBlank)?.compile(RouterFilterException.Field.INCLUDE), + exclude = excludeRegex.takeIf(String::isNotBlank)?.compile(RouterFilterException.Field.EXCLUDE), + ) +} + +data class RouterMatchRequest( + val routerId: Long, + val sourceGroupIds: Set, + val filter: RouterFilterValidation, +) + +fun match(nodes: Iterable, requests: Iterable) = + requests.associate { request -> + request.routerId to nodes.asSequence() + .filter { it.enabled && it.available && it.subscriptionId in request.sourceGroupIds } + .filter { request.filter.include?.containsMatchIn(it.name) != false } + .filterNot { request.filter.exclude?.containsMatchIn(it.name) == true } + .distinctBy { it.id } + .map { it.id } + .toList() + } +``` + +Remove `RouterRegion`, stable-ID/manual lists, multiplier fields, global assigned-ID sets, and `reservedProxyIds`. Preserve input order instead of sorting IDs. + +- [ ] **Step 4: Run focused tests** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.route.RouterMatcherTest" --tests "io.nekohasekai.sagernet.route.RouterMembershipTest"` + +Expected: PASS; the same proxy ID appears in both results. + +- [ ] **Step 5: Commit the matching contract** + +```powershell +git add app/src/main/java/io/nekohasekai/sagernet/route app/src/test/java/io/nekohasekai/sagernet/route +git commit -m "feat: support overlapping custom group filters" +``` + +### Task 2: Add Source Relations, Stable Selection, Errors, and Explicit Route References + +**Files:** +- Create: `app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupSource.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt` +- Create: `app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json` +- Modify: `app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt` + +**Interfaces:** +- Produces: `RouterGroupSource(routerId: Long, sourceGroupId: Long)` with composite primary key. +- Produces: `RouterGroup.selectedNodeKey: String` and `RouterGroup.lastError: String`. +- Produces: `RuleEntity.routerGroupId: Long`, where `0L` means use legacy `outbound`. +- Produces: `RouterGroupSource.Dao.sourcesFor(routerId)`, `routersForSource(sourceGroupId)`, `replaceSources(routerId, sourceIds)`, and cleanup methods. + +- [ ] **Step 1: Write migration and DAO tests** + +```kotlin +@Test fun migratesNineToTenWithoutCreatingDefaultGroupsOrChangingLegacyRoutes() { + migrationHelper.createDatabase(TEST_DB, 9).apply { + execSQL("INSERT INTO rules (id,name,userOrder,enabled,domains,ip,port,sourcePort,network,source,protocol,outbound,packages,config,ruleset) VALUES (1,'legacy',0,1,'','','','','','','',-1,'','','')") + close() + } + migrationHelper.runMigrationsAndValidate(TEST_DB, 10, true, SagerDatabase_AutoMigration_9_10_Impl()).use { db -> + assertEquals(0L, db.singleLong("SELECT routerGroupId FROM rules WHERE id=1")) + assertEquals(-1L, db.singleLong("SELECT outbound FROM rules WHERE id=1")) + assertEquals(0L, db.singleLong("SELECT COUNT(*) FROM router_groups")) + assertEquals(0L, db.singleLong("SELECT COUNT(*) FROM router_group_sources")) + } +} + +@Test fun oneSubscriptionAndOneNodeCanBelongToMultipleRouters() { + val a = database.routerGroupDao().create(RouterGroup(stableTag = "router.a", name = "A")) + val b = database.routerGroupDao().create(RouterGroup(stableTag = "router.b", name = "B")) + database.routerGroupSourceDao().insert(listOf(RouterGroupSource(a, 10), RouterGroupSource(b, 10))) + database.routerMemberDao().insert(listOf(RouterMember(a, 20), RouterMember(b, 20))) + assertEquals(listOf(a, b), database.routerGroupSourceDao().routersForSource(10).map { it.routerId }) +} +``` + +- [ ] **Step 2: Run instrumentation compilation/test and verify failure** + +Run: `.\gradlew.bat :app:compileOssDebugAndroidTestKotlin` + +Expected: FAIL because schema version 10, `RouterGroupSource`, and `routerGroupId` do not exist. + +- [ ] **Step 3: Implement schema version 10** + +```kotlin +@Entity(tableName = "router_group_sources", primaryKeys = ["routerId", "sourceGroupId"], indices = [Index("sourceGroupId")]) +data class RouterGroupSource(var routerId: Long = 0, var sourceGroupId: Long = 0) : Serializable() +``` + +Add `selectedNodeKey` and `lastError` to `RouterGroup`, bump its buffer payload version from 0 to 1, and read the extra values only when `version >= 1`. Add this at the end of `RuleEntity` so Room gives old rows zero: + +```kotlin +@IgnoredOnParcel +@ColumnInfo(defaultValue = "0") +var routerGroupId: Long = 0L, +``` + +`@IgnoredOnParcel` intentionally preserves the legacy `RuleEntity` Parcel layout; Task 8 exports route references separately. + +- [ ] **Step 4: Register the entity/DAO and generate the schema** + +Change `SagerDatabase` to version 10, add `AutoMigration(from = 9, to = 10)`, add `RouterGroupSource::class`, and expose `routerGroupSourceDao`. Run: + +`.\gradlew.bat :app:kspOssDebugKotlin` + +Expected: `app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json` exists and contains `router_group_sources`, `selectedNodeKey`, `lastError`, and `routerGroupId`. + +- [ ] **Step 5: Run database tests** + +Run: `.\gradlew.bat :app:compileOssDebugAndroidTestKotlin` + +If an emulator/device is connected, also run: `.\gradlew.bat :app:connectedOssDebugAndroidTest` + +Expected: compilation PASS; connected migration tests PASS when a device is present. + +- [ ] **Step 6: Commit the persistent model** + +```powershell +git add app/src/main/java/io/nekohasekai/sagernet/database app/src/androidTest/java/io/nekohasekai/sagernet/database app/schemas +git commit -m "feat: persist custom group sources and route references" +``` + +### Task 3: Centralize CRUD, Preview, Reconciliation, and Deletion Safety + +**Files:** +- Create: `app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt` +- Test: `app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt` +- Create: `app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterGroupRepositoryTest.kt` + +**Interfaces:** +- Consumes: Task 1 filter/matcher and Task 2 DAOs. +- Produces: `RouterGroupDraft`, `RouterGroupPreview`, `RouterDeleteResult`, and repository methods `preview`, `save`, `delete`, `reconcileAfterRefresh`, `reconcileBeforeBuild`. +- Produces: `RouterNodeKey.of(sourceGroupId: Long, stableId: String): String`. + +- [ ] **Step 1: Add repository behavior tests** + +```kotlin +@Test fun enabledGroupRequiresSourceButDisabledDraftDoesNot() { + assertThrows { + repository.save(RouterGroupDraft(name = "A", mode = RouterGroup.MODE_SELECTOR, enabled = true, sourceGroupIds = emptySet(), filter = RouterFilterConfig())) + } + assertTrue(repository.save(RouterGroupDraft(name = "Draft", mode = RouterGroup.MODE_SELECTOR, enabled = false, sourceGroupIds = emptySet(), filter = RouterFilterConfig())).id > 0) +} + +@Test fun stableTagDoesNotChangeWhenDisplayNameChanges() { + val created = repository.save(enabledDraft("A", setOf(subscriptionId))) + val updated = repository.save(enabledDraft("Renamed", setOf(subscriptionId)).copy(id = created.id)) + assertEquals(created.stableTag, updated.stableTag) +} + +@Test fun deleteIsBlockedWhenRulesReferenceTheGroup() { + val group = repository.save(enabledDraft("A", setOf(subscriptionId))) + database.rulesDao().createRule(RuleEntity(name = "r", routerGroupId = group.id)) + assertEquals(RouterDeleteResult.Referenced(1), repository.delete(group.id)) + assertNotNull(database.routerGroupDao().getById(group.id)) +} + +@Test fun failedRefreshPreservesMembersAndSuccessfulRefreshRemapsSelection() { + val group = repository.save(enabledDraft("A", setOf(subscriptionId))) + repository.reconcileAfterRefresh(subscriptionId, refreshSucceeded = false, previous = repository.snapshot()) + assertTrue(database.routerMemberDao().getByRouter(group.id).isNotEmpty()) + assertTrue(database.routerGroupDao().getById(group.id)!!.lastError.isNotBlank()) + repository.reconcileAfterRefresh(subscriptionId, refreshSucceeded = true, previous = repository.snapshot()) + assertTrue(database.routerGroupDao().getById(group.id)!!.lastError.isBlank()) +} +``` + +The test class `@Before` creates an in-memory `SagerDatabase`, one subscription group, ordered proxy rows, and `RouterGroupRepository(database)`; `enabledDraft` returns a draft with include `""` and exclude `""`. Add a separate assertion that saving `A` and then `a` throws `RouterGroupValidationException(Field.NAME)`, and a two-source deletion case asserting only the deleted source relation disappears. + +- [ ] **Step 2: Run tests and verify failure** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.route.RouterReconcilerTest" :app:compileOssDebugAndroidTestKotlin` + +Expected: FAIL because repository contracts and source-scoped selection are absent. + +- [ ] **Step 3: Implement source-scoped stable identities and repository validation** + +```kotlin +data class RouterGroupDraft( + val id: Long = 0, + val name: String, + val mode: Int, + val enabled: Boolean, + val sourceGroupIds: Set, + val filter: RouterFilterConfig, +) + +sealed interface RouterDeleteResult { + data object Deleted : RouterDeleteResult + data class Referenced(val ruleCount: Int) : RouterDeleteResult +} +``` + +Generate new tags with `"router." + UUID.randomUUID().toString().replace("-", "").lowercase()`; never regenerate on update. Check sources against `groupDao.subscriptions()`. Validate name, uniqueness, mode, sources, regex, interval `>= 10`, and tolerance `0..65535` before opening the transaction. + +- [ ] **Step 4: Implement deterministic preview and materialization** + +Load source groups in the editor-selected order and nodes in each source's `userOrder, id` order. On successful recomputation, replace only that router's rows, preserve existing `userOrder` for surviving source-scoped stable keys, append new rows, remap `selectedProxyId`, update `selectedNodeKey`, and clear `lastError`. On failed or truly empty refresh, retain prior members and write the error; on a valid non-empty source snapshot whose regex matches zero, materialize empty and write `No nodes match `. + +- [ ] **Step 5: Replace lifecycle logic with repository calls** + +Remove `ensureDefaultRouterGroups`, region inference, multiplier parsing, and reserved-node collection from `GroupManager`. Keep snapshot-before-update and call `reconcileAfterRefresh(sourceGroupId, refreshSucceeded, previous)` after `GroupUpdater` and `RawUpdater`; source delete calls `removeSourceAndReconcile(sourceGroupId)`. + +- [ ] **Step 6: Run repository and reconciler tests** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.route.*" :app:compileOssDebugAndroidTestKotlin` + +Expected: PASS. + +- [ ] **Step 7: Commit repository behavior** + +```powershell +git add app/src/main/java/io/nekohasekai/sagernet/database app/src/main/java/io/nekohasekai/sagernet/group app/src/main/java/io/nekohasekai/sagernet/route app/src/test app/src/androidTest +git commit -m "feat: reconcile custom groups from subscriptions" +``` + +### Task 4: Emit Strict Selector/URL-Test Outbounds and Resolve Explicit Routes + +**Files:** +- Modify: `app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt` +- Modify: `app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt` +- Modify: `app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt` +- Replace: `app/src/test/java/io/nekohasekai/sagernet/fmt/RouterRouteSemanticTest.kt` + +**Interfaces:** +- Consumes: `RuleEntity.routerGroupId`, repository runtime snapshot, Task 1 URL-test settings. +- Produces: `resolveRouteOutbound(rule, mainProxyTag, proxyTags, routerTagsById): String`. +- Produces: `RouterRuntimeException(groupId: Long, groupName: String, reason: Reason)`. + +- [ ] **Step 1: Add config and route regression tests** + +```kotlin +@Test fun routeUsesOnlyExplicitCustomGroupReference() { + val legacy = RuleEntity(name = "Google", outbound = -1, routerGroupId = 0) + assertEquals(TAG_BYPASS, resolveRouteOutbound(legacy, "proxy", emptyMap(), mapOf(5L to "router.x"))) + assertEquals("router.x", resolveRouteOutbound(legacy.copy(routerGroupId = 5), "proxy", emptyMap(), mapOf(5L to "router.x"))) +} + +@Test fun sharedNodeTagCanBeReferencedByMultipleRouterOutbounds() { + val built = buildRouterOutbounds( + listOf( + RouterRuntimeGroup(1, "A", "router.a", RouterRuntimeMode.SELECTOR, listOf(9), 9, RouterFilterConfig()), + RouterRuntimeGroup(2, "B", "router.b", RouterRuntimeMode.URL_TEST, listOf(9), -1, RouterFilterConfig()), + ), + proxyTags = mapOf(9L to "node-9"), + ) + assertEquals(listOf("node-9"), built[0].asMap()["outbounds"]) + assertEquals(listOf("node-9"), built[1].asMap()["outbounds"]) +} + +@Test fun referencedMissingGroupThrowsInsteadOfFallingBack() { + val error = assertThrows { + resolveRouteOutbound(RuleEntity(routerGroupId = 88), "proxy", emptyMap(), emptyMap()) + } + assertEquals(88L, error.groupId) + assertEquals(RouterRuntimeException.Reason.MISSING, error.reason) +} + +@Test fun unreferencedEmptyGroupIsOmitted() { + assertTrue(buildRouterOutbounds(listOf(emptyRuntimeGroup), emptyMap()).isEmpty()) +} +``` + +For URL-test, assert the generated map contains the configured URL, `300_000_000_000L` interval, and `50` tolerance. Define `emptyRuntimeGroup` in the test as an enabled group with no member IDs. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.fmt.*Router*" --tests "io.nekohasekai.sagernet.route.RouterRuntimeTest"` + +Expected: FAIL because semantic matching remains and URL-test settings/group-ID resolution are missing. + +- [ ] **Step 3: Remove semantic mapping and default creation** + +Delete `ROUTER_US_TAG`, `ROUTER_US_LOW_TAG`, `ROUTER_SG_TAG`, `ROUTER_JP_TAG`, `routerSemanticTag()`, and the call to `ensureDefaultRouterGroups()`. Replace route resolution with: + +```kotlin +internal fun resolveRouteOutbound( + rule: RuleEntity, + mainProxyTag: String, + proxyTags: Map, + routerTagsById: Map, + primaryProxyId: Long = Long.MIN_VALUE, +): String { + if (rule.routerGroupId > 0) return routerTagsById[rule.routerGroupId] + ?: throw RouterRuntimeException(rule.routerGroupId, "", RouterRuntimeException.Reason.MISSING) + return when (val id = rule.outbound) { + 0L -> mainProxyTag + -1L -> TAG_BYPASS + -2L -> TAG_BLOCK + else -> if (id == primaryProxyId) mainProxyTag else proxyTags[id].orEmpty() + } +} +``` + +- [ ] **Step 4: Make runtime group generation strict only for referenced groups** + +Build all nodes once into `tagMap`; references from multiple groups reuse those tags. Emit non-empty enabled groups. Before adding route rules, calculate referenced IDs and throw a named error if any reference is absent, disabled, or has no resolved member. Populate `Outbound_URLTestOptions.url`, `.interval` (nanoseconds expected by the generated binding), and `.tolerance`; do not emit an unsupported timeout key. + +- [ ] **Step 5: Run focused and full JVM tests** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest` + +Expected: PASS, including proof that a rule merely named Google keeps its original outbound. + +- [ ] **Step 6: Commit configuration behavior** + +```powershell +git add app/src/main/java/io/nekohasekai/sagernet/fmt app/src/main/java/io/nekohasekai/sagernet/route app/src/test +git commit -m "feat: route explicitly through custom proxy groups" +``` + +### Task 5: Preserve Targeted Selector Switching and Visible Runtime Errors + +**Files:** +- Modify: `app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/route/RouterSelection.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt` +- Modify: `app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt` +- Modify: `libcore/box.go` +- Test: `app/src/test/java/io/nekohasekai/sagernet/route/RouterSelectionTest.kt` + +**Interfaces:** +- Produces: `RouterGroupRepository.select(routerId: Long, proxyId: Long): RouterSelectionPlan`. +- Uses native `SelectOutboundFor(selectorTag: String, outboundTag: String): Boolean` only for selector groups. + +- [ ] **Step 1: Update selection tests for arbitrary stable tags and overlap** + +Add tests proving selection in group A does not mutate group B even when both contain the same node, non-members are rejected, selector uses targeted switch, and URL-test topology changes request reload. + +- [ ] **Step 2: Run selection tests and verify failure** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.route.RouterSelectionTest"` + +Expected: FAIL on independent persisted selection/error behavior. + +- [ ] **Step 3: Implement selection transaction and runtime action** + +Validate group enabled/mode/member, update only that row's `selectedProxyId` and `selectedNodeKey`, call `selectOutboundFor(group.stableTag, proxyTag)` for active selector topology, and reload only when targeted switching is unavailable. Surface native false/exception through the existing service/UI error channel and never change another group. + +- [ ] **Step 4: Keep the native API change minimal** + +Retain the existing Go `SelectOutboundFor` method and Kotlin wrapper. Do not add new HTTP client APIs. Ensure all Java/Kotlin code imports `libcore.HTTPClient`, never the stale placeholder `libcore.HttpClient`. + +- [ ] **Step 5: Run tests** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.route.RouterSelectionTest"` + +Expected: PASS. + +- [ ] **Step 6: Commit runtime selection** + +```powershell +git add app/src/main/java/io/nekohasekai/sagernet/bg app/src/main/java/io/nekohasekai/sagernet/route app/src/main/java/moe/matsuri/nb4a libcore +git commit -m "feat: switch custom selector groups independently" +``` + +### Task 6: Add the Dedicated Custom Group List and Editor + +**Files:** +- Create: `app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListActivity.kt` +- Create: `app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt` +- Create: `app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt` +- Create: `app/src/main/res/layout/layout_router_group_list.xml` +- Create: `app/src/main/res/layout/layout_router_group_row.xml` +- Create: `app/src/main/res/xml/router_group_preferences.xml` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt` +- Modify: `app/src/main/res/layout/layout_group.xml` +- Modify: `app/src/main/AndroidManifest.xml` +- Modify: `app/src/main/res/values/strings.xml` +- Modify: `app/src/main/res/values-zh-rCN/strings.xml` +- Create: `app/src/androidTest/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivityTest.kt` + +**Interfaces:** +- Consumes: repository `all`, `preview`, `save`, `delete`, `select`. +- Produces: `EXTRA_ROUTER_ID = "router_id"`; zero means create. + +- [ ] **Step 1: Write editor validation tests** + +Test that create starts blank, enabled save requires sources, disabled draft may have no source, invalid include/exclude shows the correct preference error, URL-test fields are visible only in URL-test mode, preview lists exact names/count, rename retains ID/tag, and delete displays reference count rather than deleting. + +- [ ] **Step 2: Compile Android tests and confirm missing UI** + +Run: `.\gradlew.bat :app:compileOssDebugAndroidTestKotlin` + +Expected: FAIL because the activities/resources do not exist. + +- [ ] **Step 3: Replace fixed cards with a single navigation entry** + +Remove `layout_router_item.xml`, fixed router card adapter/state, `ensureDefaultRouterGroups()`, and manual member dialog from `GroupFragment`. Keep the normal subscription RecyclerView unchanged. Add one “代理组” row/button that opens `RouterGroupListActivity`. + +- [ ] **Step 4: Implement the list screen** + +Show user order, name, selector/url-test mode, enabled/unavailable state, materialized member count, selected/current node, and `lastError`. Add create, edit, drag/reorder using the project's existing RecyclerView patterns. Selector row tap opens member selection; URL-test row exposes the existing test action when the service supports it. + +- [ ] **Step 5: Implement the editor** + +Use existing Preference widgets where possible. Source selection is a multi-choice dialog containing only `groupDao.subscriptions()`, stored as ordered IDs. On every name/mode/source/regex change, debounce a repository `preview` call and render `N nodes: name1, name2…`. Save calls repository validation; error text stays on the responsible field. Do not expose fallback/load-balance/nesting options. + +- [ ] **Step 6: Compile resources and tests** + +Run: `.\gradlew.bat :app:processOssDebugResources :app:compileOssDebugKotlin :app:compileOssDebugAndroidTestKotlin` + +Expected: PASS. + +- [ ] **Step 7: Commit group UI** + +```powershell +git add app/src/main/AndroidManifest.xml app/src/main/java/io/nekohasekai/sagernet/ui app/src/main/res app/src/androidTest/java/io/nekohasekai/sagernet/ui +git commit -m "feat: add custom proxy group editor" +``` + +### Task 7: Add Explicit Proxy Group Selection to Route Editing + +**Files:** +- Create: `app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSelectActivity.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/Constants.kt` +- Modify: `app/src/main/res/values/arrays.xml` +- Modify: `app/src/main/res/values/strings.xml` +- Modify: `app/src/main/res/values-zh-rCN/strings.xml` +- Modify: `app/src/main/AndroidManifest.xml` +- Modify: `app/src/test/java/io/nekohasekai/sagernet/fmt/RouterRouteSemanticTest.kt` + +**Interfaces:** +- Produces: `OutboundPreference.VALUE_SELECT_ROUTER = "4"`. +- Produces: `DataStore.routeOutboundRouter: Long` backed by `Key.ROUTE_OUTBOUND + "Router"`. +- Produces: `RouterGroupSelectActivity.EXTRA_ROUTER_ID`. + +- [ ] **Step 1: Add route serialization/model tests** + +```kotlin +@Test fun selectingRouterAndLegacyTargetsAreMutuallyExclusive() { + val router = serializeRouteChoice(value = "4", legacyProfileId = 99, routerGroupId = 7) + assertEquals(0L, router.outbound) + assertEquals(7L, router.routerGroupId) + val direct = serializeRouteChoice(value = "1", legacyProfileId = 99, routerGroupId = 7) + assertEquals(-1L, direct.outbound) + assertEquals(0L, direct.routerGroupId) +} + +@Test fun missingRouterSummaryIsInvalidInsteadOfProxy() { + val rule = RuleEntity(outbound = 0, routerGroupId = 404) + assertEquals(app.getString(R.string.router_reference_invalid), rule.displayOutbound()) +} +``` + +Extract the pure `serializeRouteChoice(value, legacyProfileId, routerGroupId): RouteOutboundChoice` helper into `RouteOutboundChoice.kt` so this JVM test does not instantiate an Activity. + +- [ ] **Step 2: Run route tests and verify failure** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.fmt.RouterRouteSemanticTest"` + +Expected: FAIL because the route editor cannot store a group ID. + +- [ ] **Step 3: Add the fifth outbound choice and picker** + +Append `@string/route_proxy_group` / value `4` to the arrays. The picker queries enabled groups whose materialized membership is non-empty, returns an ID, and visually marks the current ID. Do not include disabled or unavailable groups. + +- [ ] **Step 4: Serialize mutually exclusive route targets** + +On init, choose value 4 when `routerGroupId > 0`. On selecting a group set `routeOutboundRouter`; on serialize set `routerGroupId` only for value 4 and set `outbound = 0L`. For values 0–3 set `routerGroupId = 0L` and retain the current legacy logic. Update `RuleEntity.displayOutbound()` and `OutboundPreference.getSummary()` to resolve the current group name or display an invalid-reference message. + +- [ ] **Step 5: Run route and config tests** + +Run: `.\gradlew.bat :app:testOssDebugUnitTest --tests "io.nekohasekai.sagernet.fmt.*Router*"` + +Expected: PASS. + +- [ ] **Step 6: Commit route UI** + +```powershell +git add app/src/main/java/io/nekohasekai/sagernet/ui app/src/main/java/io/nekohasekai/sagernet/widget app/src/main/java/io/nekohasekai/sagernet/database app/src/main/java/io/nekohasekai/sagernet/Constants.kt app/src/main/res app/src/main/AndroidManifest.xml app/src/test +git commit -m "feat: select custom groups in route rules" +``` + +### Task 8: Make Backup Import/Export Round-Trip the New Relations Safely + +**Files:** +- Modify: `app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt` +- Modify: `app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt` +- Modify: `app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt` + +**Interfaces:** +- Produces backup version 3 keys: `routerGroups`, `routerSources`, `routerMembers`, `routerRuleRefs`. +- `routerRuleRefs` is a JSON array of `{ "ruleId": Long, "routerGroupId": Long }`, avoiding any change to the legacy `RuleEntity` Parcel payload. + +- [ ] **Step 1: Add round-trip and old-backup tests** + +```kotlin +@Test fun versionThreeRoundTripsRelationsAndVersionTwoDefaultsThem() { + val json = JSONObject().put("version", 3) + BackupSerializer.putParcelableArray(json, "routerSources", listOf(RouterGroupSource(1, 10), RouterGroupSource(2, 10))) + assertEquals( + listOf(RouterGroupSource(1, 10), RouterGroupSource(2, 10)), + BackupSerializer.getParcelableArray(json, "routerSources", RouterGroupSource.CREATOR), + ) + val old = JSONObject().put("version", 2) + assertTrue(BackupSerializer.getParcelableArray(old, "routerSources", RouterGroupSource.CREATOR).isEmpty()) +} + +@Test fun routeReferencesUseASeparateBackwardCompatibleArray() { + val refs = listOf(RouterRuleRef(3, 1), RouterRuleRef(4, 404)) + val json = BackupSerializer.putRouterRuleRefs(JSONObject(), refs) + assertEquals(refs, BackupSerializer.getRouterRuleRefs(json)) +} +``` + +The integration import test inserts referenced group `1` but not `404`, asserts both rule rows remain, and asserts rule `4` displays an invalid reference rather than being rewritten. + +- [ ] **Step 2: Run Android test compilation and verify failure** + +Run: `.\gradlew.bat :app:compileOssDebugAndroidTestKotlin` + +Expected: FAIL because version 3 fields are absent. + +- [ ] **Step 3: Implement version 3 export/import** + +Set `BACKUP_VERSION = 3`. Export all three Router tables and non-zero rule references. Import in one Room transaction in dependency order: clear members/sources/groups, insert groups, insert valid source/member rows, restore rule references without changing any rule's legacy outbound. Old backups produce empty custom-group relations and leave their decoded rules unchanged. + +- [ ] **Step 4: Run backup tests** + +Run: `.\gradlew.bat :app:compileOssDebugAndroidTestKotlin` + +If a device is connected: `.\gradlew.bat :app:connectedOssDebugAndroidTest --tests "io.nekohasekai.sagernet.ui.BackupSerializationTest"` + +Expected: compile PASS and device test PASS when available. + +- [ ] **Step 5: Commit backup compatibility** + +```powershell +git add app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt +git commit -m "feat: back up custom proxy group relations" +``` + +### Task 9: Turn the Native HTTPClient Mismatch into a Clean-Build Gate + +**Files:** +- Modify: `app/build.gradle.kts` +- Create: `app/src/test/java/moe/matsuri/nb4a/LibcoreAbiTest.kt` +- Inspect only: `app/libs/libcore.aar` + +**Interfaces:** +- Produces Gradle task `verifyLibcoreAbi` and makes `preBuild` depend on it. +- Verifies `Libcore.newHttpClient:()Llibcore/HTTPClient;` consistently in AAR classes and compiled app references. + +- [ ] **Step 1: Add an ABI test against the real AAR** + +The test loads `libcore.Libcore` and asserts `newHttpClient().javaClass.name == "libcore.HTTPClient"`, plus checks `RawUpdater`/callers contain no `libcore.HttpClient` descriptor. + +- [ ] **Step 2: Run from a clean app build directory and reproduce the gate** + +Run: `.\gradlew.bat :app:clean :app:testOssDebugUnitTest --tests "moe.matsuri.nb4a.LibcoreAbiTest"` + +Expected before completing the gate: FAIL if a placeholder jar or stale descriptor is on the compile/runtime classpath; otherwise PASS and record that the original installed APK was stale incremental output. + +- [ ] **Step 3: Strengthen `verifyLibcore` into `verifyLibcoreAbi`** + +Keep the existing checks for `classes.jar` and four JNI `libgojni.so` entries. Add class inspection using Gradle/JDK tooling to require `libcore/HTTPClient.class`, reject `libcore/HttpClient.class`, and inspect the compiled caller descriptor after Kotlin compilation. Do not generate or copy a placeholder libcore jar. + +- [ ] **Step 4: Verify the packaged APK** + +Run: `.\gradlew.bat :app:clean :app:assembleOssDebug` + +Then inspect `app/build/outputs/apk/oss/debug/*.apk` and assert it contains all expected ABI libraries and exactly the `HTTPClient` class descriptor used by app bytecode. + +Expected: build PASS; no `Llibcore/HttpClient;` string in compiled DEX/classes; `Llibcore/HTTPClient;` present. + +- [ ] **Step 5: Commit the ABI gate** + +```powershell +git add app/build.gradle.kts app/src/test/java/moe/matsuri/nb4a/LibcoreAbiTest.kt +git commit -m "build: reject mismatched libcore HTTP client ABI" +``` + +### Task 10: Full Regression, Scope Audit, and Android Handoff + +**Files:** +- Modify if needed: only files already listed above +- Create: `docs/superpowers/verification/2026-09-04-custom-proxy-groups.md` + +**Interfaces:** +- Produces a reproducible verification record and the APK path/hash. + +- [ ] **Step 1: Run all JVM and Android-test compilation checks** + +```powershell +.\gradlew.bat :app:testOssDebugUnitTest :app:compileOssDebugAndroidTestKotlin +``` + +Expected: PASS with no fixed-default/non-overlap/semantic-route tests remaining. + +- [ ] **Step 2: Run lint and classify only real regressions** + +Run: `.\gradlew.bat :app:lintOssDebug` + +Expected: PASS, or record pre-existing unrelated findings separately without broad cleanup. + +- [ ] **Step 3: Perform the final clean debug build** + +Run: `.\gradlew.bat :app:clean :app:assembleOssDebug` + +Expected: PASS and ABI gate runs automatically. + +- [ ] **Step 4: Audit scope and protected files** + +```powershell +git status --short +git diff --stat f8e6418 +git diff -- app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt +git status --short -- ../A7.yaml ../nekobox_isA8.json +``` + +Expected: no protected-file change; no automatic route reassignment; no unrelated DNS/TUN, AdBlock, `.invalid`, subscription link, or ordinary-profile mutation. + +- [ ] **Step 5: Record hashes and static verification** + +Write the exact Gradle exit results, APK absolute path, SHA-256, schema version, ABI descriptor check, test totals, lint result, and any unavailable emulator checks to the verification document. + +- [ ] **Step 6: Install only when a connected authorized device is visible** + +Run: `adb devices -l`. If exactly one authorized device is present, run `.\gradlew.bat :app:installOssDebug`; otherwise do not guess a target and leave the APK ready. + +- [ ] **Step 7: Execute the real-device acceptance checklist** + +On Android: upgrade without clearing data; refresh subscription 1 and 2; create `US1` with both sources and a US regex; create a second overlapping group; verify selector isolation; verify URL-test selects a reachable node; explicitly route one test rule to `US1`; refresh both sources; confirm route/group survival; confirm AdBlock, `.invalid`, App routing, DNS/TUN, subscriptions, and ordinary nodes remain functional. Capture the exact log line for any failure. + +- [ ] **Step 8: Request code review and address only confirmed issues** + +Use `superpowers:requesting-code-review`, compare implementation to the approved spec and this plan, rerun the affected test after each correction, then rerun Steps 1–4. + +- [ ] **Step 9: Commit the verification record** + +```powershell +git add docs/superpowers/verification/2026-09-04-custom-proxy-groups.md +git commit -m "docs: record custom proxy group verification" +``` From 1ca60853f02338eb5d76d2efd5671e2da8bd492b Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 16:27:52 +0800 Subject: [PATCH 03/29] feat: implement user-defined custom proxy groups --- app/build.gradle.kts | 94 ++- .../10.json | 584 ++++++++++++++++++ .../9.json | 519 ++++++++++++++++ .../sagernet/database/RouterMigrationTest.kt | 152 +++++ .../sagernet/ui/BackupSerializationTest.kt | 97 +++ app/src/main/AndroidManifest.xml | 9 + .../java/io/nekohasekai/sagernet/Constants.kt | 5 + .../java/io/nekohasekai/sagernet/SagerNet.kt | 14 +- .../io/nekohasekai/sagernet/bg/BaseService.kt | 63 +- .../sagernet/database/DataStore.kt | 1 + .../sagernet/database/GroupManager.kt | 191 +++++- .../sagernet/database/ProfileManager.kt | 2 + .../sagernet/database/RouterGroup.kt | 117 ++++ .../database/RouterGroupRepository.kt | 182 ++++++ .../sagernet/database/RouterGroupSource.kt | 81 +++ .../sagernet/database/RouterMember.kt | 88 +++ .../sagernet/database/RuleEntity.kt | 11 + .../sagernet/database/SagerDatabase.kt | 16 +- .../sagernet/fmt/BackupSerializer.kt | 107 ++++ .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 169 ++++- .../sagernet/fmt/RouteOutboundChoice.kt | 19 + .../sagernet/group/GroupUpdater.kt | 7 +- .../nekohasekai/sagernet/group/RawUpdater.kt | 12 +- .../sagernet/route/RouterFilter.kt | 59 ++ .../sagernet/route/RouterMatcher.kt | 32 + .../sagernet/route/RouterMembership.kt | 22 + .../sagernet/route/RouterReconciler.kt | 129 ++++ .../sagernet/route/RouterRuntime.kt | 70 +++ .../sagernet/route/RouterSelection.kt | 57 ++ .../nekohasekai/sagernet/ui/BackupFragment.kt | 131 ++-- .../nekohasekai/sagernet/ui/GroupFragment.kt | 8 +- .../sagernet/ui/RouteSettingsActivity.kt | 38 +- .../sagernet/ui/RouterGroupListActivity.kt | 27 + .../sagernet/ui/RouterGroupListFragment.kt | 60 ++ .../sagernet/ui/RouterGroupSelectActivity.kt | 58 ++ .../ui/RouterGroupSettingsActivity.kt | 216 +++++++ .../sagernet/widget/OutboundPreference.kt | 15 +- .../java/moe/matsuri/nb4a/NativeInterface.kt | 56 +- app/src/main/res/layout/layout_group.xml | 26 +- app/src/main/res/values-zh-rCN/strings.xml | 24 + app/src/main/res/values/arrays.xml | 2 + app/src/main/res/values/strings.xml | 33 + .../database/RouterGroupRepositoryTest.kt | 82 +++ .../sagernet/fmt/RouterOutboundConfigTest.kt | 82 +++ .../sagernet/fmt/RouterRouteSemanticTest.kt | 85 +++ .../sagernet/route/RouterFilterTest.kt | 18 + .../sagernet/route/RouterMatcherTest.kt | 71 +++ .../sagernet/route/RouterMembershipTest.kt | 56 ++ .../sagernet/route/RouterReconcilerTest.kt | 132 ++++ .../sagernet/route/RouterRuntimeTest.kt | 72 +++ .../sagernet/route/RouterSelectionTest.kt | 130 ++++ buildScript/compile-hevtun.sh | 43 +- buildScript/init/env_ndk.sh | 4 + libcore/box.go | 12 + 54 files changed, 4249 insertions(+), 141 deletions(-) create mode 100644 app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json create mode 100644 app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json create mode 100644 app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt create mode 100644 app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupSource.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/fmt/RouteOutboundChoice.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/route/RouterFilter.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/route/RouterMatcher.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/route/RouterMembership.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/route/RouterSelection.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListActivity.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSelectActivity.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/database/RouterGroupRepositoryTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/fmt/RouterRouteSemanticTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/route/RouterFilterTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/route/RouterMatcherTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/route/RouterMembershipTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/route/RouterSelectionTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b97a51770c..5727dd8f63 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,8 @@ @file:Suppress("UnstableApiUsage") +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream + plugins { id("com.android.application") id("kotlin-android") @@ -10,6 +13,9 @@ plugins { setupApp() android { + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } compileOptions { isCoreLibraryDesugaringEnabled = true } @@ -36,6 +42,11 @@ android { androidResources { generateLocaleConfig = true } + sourceSets { + getByName("androidTest") { + assets.srcDir("$projectDir/schemas") + } + } } dependencies { @@ -81,6 +92,11 @@ dependencies { implementation("androidx.room:room-runtime:2.6.1") ksp("androidx.room:room-compiler:2.6.1") implementation("androidx.room:room-ktx:2.6.1") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.room:room-testing:2.6.1") + androidTestImplementation("androidx.test:core:1.6.1") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") implementation("com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4") ksp("com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4") @@ -89,16 +105,92 @@ dependencies { val buildHevTun by tasks.registering { val hevAbis = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + val bashExecutable = System.getenv("BASH_EXE") + ?.takeIf { file(it).isFile } + ?: listOf( + "C:/Program Files/Git/bin/bash.exe", + "C:/Program Files (x86)/Git/bin/bash.exe" + ).firstOrNull { file(it).isFile } + ?: "bash" doLast { val missing = hevAbis.any { !file("src/main/jniLibs/$it/libhev-socks5-tunnel.so").exists() } if (missing || System.getenv("FORCE_HEV") == "1") { exec { - commandLine("bash", rootProject.file("buildScript/compile-hevtun.sh").absolutePath) + val script = rootProject.file("buildScript/compile-hevtun.sh") + .absolutePath.replace('\\', '/') + commandLine(bashExecutable, "-lc", "\"$script\"") } } } } +val verifyLibcore by tasks.registering { + val libcoreAar = file("libs/libcore.aar") + doLast { + if (!libcoreAar.isFile) { + throw GradleException("Missing app/libs/libcore.aar. Build the native core with './run lib core' before building the APK.") + } + val requiredEntries = listOf( + "classes.jar", + "jni/armeabi-v7a/libgojni.so", + "jni/arm64-v8a/libgojni.so", + "jni/x86/libgojni.so", + "jni/x86_64/libgojni.so" + ) + ZipFile(libcoreAar).use { archive -> + val missingEntries = requiredEntries.filter { archive.getEntry(it) == null } + if (missingEntries.isNotEmpty()) { + throw GradleException("Invalid app/libs/libcore.aar; missing: ${missingEntries.joinToString()}") + } + val classesJar = archive.getInputStream(archive.getEntry("classes.jar")).readBytes() + var libcoreClass: ByteArray? = null + val classNames = linkedSetOf() + ZipInputStream(classesJar.inputStream()).use { jar -> + var entry = jar.nextEntry + while (entry != null) { + classNames += entry.name + if (entry.name == "libcore/Libcore.class") libcoreClass = jar.readBytes() + entry = jar.nextEntry + } + } + if ("libcore/HTTPClient.class" !in classNames || "libcore/HttpClient.class" in classNames) { + throw GradleException("Invalid libcore Java ABI: expected libcore.HTTPClient and no legacy libcore.HttpClient") + } + val libcoreSymbols = libcoreClass?.toString(Charsets.ISO_8859_1).orEmpty() + if ("newHttpClient" !in libcoreSymbols || "libcore/HTTPClient" !in libcoreSymbols) { + throw GradleException("Invalid libcore Java ABI: newHttpClient must return libcore.HTTPClient") + } + } + } +} + +val verifyOssDebugLibcoreCallers by tasks.registering { + dependsOn("compileOssDebugKotlin") + doLast { + val classesDir = layout.buildDirectory.dir("tmp/kotlin-classes/ossDebug").get().asFile + val classFiles = fileTree(classesDir) { include("**/*.class") }.files + val obsolete = classFiles.filter { file -> + file.readBytes().toString(Charsets.ISO_8859_1).contains("libcore/HttpClient") + } + if (obsolete.isNotEmpty()) { + throw GradleException( + "Stale libcore.HttpClient bytecode detected; run a clean rebuild: " + + obsolete.joinToString { it.relativeTo(classesDir).path } + ) + } + if (classFiles.none { file -> + file.readBytes().toString(Charsets.ISO_8859_1).contains("libcore/HTTPClient") + }) { + throw GradleException("No compiled caller references the current libcore.HTTPClient ABI") + } + } +} + +tasks.matching { it.name == "assembleOssDebug" }.configureEach { + dependsOn(verifyOssDebugLibcoreCallers) +} + tasks.named("preBuild") { dependsOn(buildHevTun) + dependsOn(verifyLibcore) } diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json new file mode 100644 index 0000000000..61c4135368 --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json @@ -0,0 +1,584 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "fbef0abc54268d326eafac4ae8af1bb4", + "entities": [ + { + "tableName": "proxy_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ungrouped", + "columnName": "ungrouped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscription", + "columnName": "subscription", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelector", + "columnName": "isSelector", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frontProxy", + "columnName": "frontProxy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "landingProxy", + "columnName": "landingProxy", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "proxy_entities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `ssrBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `juicityBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB, `snellBean` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tx", + "columnName": "tx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rx", + "columnName": "rx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ping", + "columnName": "ping", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "socksBean", + "columnName": "socksBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "httpBean", + "columnName": "httpBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ssBean", + "columnName": "ssBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ssrBean", + "columnName": "ssrBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "vmessBean", + "columnName": "vmessBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanBean", + "columnName": "trojanBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanGoBean", + "columnName": "trojanGoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "mieruBean", + "columnName": "mieruBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "naiveBean", + "columnName": "naiveBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "hysteriaBean", + "columnName": "hysteriaBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "tuicBean", + "columnName": "tuicBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "juicityBean", + "columnName": "juicityBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "sshBean", + "columnName": "sshBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "wgBean", + "columnName": "wgBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "shadowTLSBean", + "columnName": "shadowTLSBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "anyTLSBean", + "columnName": "anyTLSBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "chainBean", + "columnName": "chainBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "nekoBean", + "columnName": "nekoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "configBean", + "columnName": "configBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "snellBean", + "columnName": "snellBean", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `config` TEXT NOT NULL DEFAULT '', `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `ruleset` TEXT NOT NULL DEFAULT '', `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL, `routerGroupId` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domains", + "columnName": "domains", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ip", + "columnName": "ip", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePort", + "columnName": "sourcePort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ruleset", + "columnName": "ruleset", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "outbound", + "columnName": "outbound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packages", + "columnName": "packages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "routerGroupId", + "columnName": "routerGroupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "router_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stableTag` TEXT NOT NULL, `name` TEXT NOT NULL, `mode` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `matchConfig` TEXT NOT NULL, `selectedProxyId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `selectedNodeKey` TEXT NOT NULL DEFAULT '', `lastError` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stableTag", + "columnName": "stableTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "matchConfig", + "columnName": "matchConfig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedProxyId", + "columnName": "selectedProxyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "selectedNodeKey", + "columnName": "selectedNodeKey", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_router_groups_stableTag", + "unique": true, + "columnNames": [ + "stableTag" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_router_groups_stableTag` ON `${TABLE_NAME}` (`stableTag`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "router_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`routerId` INTEGER NOT NULL, `proxyId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `lastMatchedAt` INTEGER NOT NULL, PRIMARY KEY(`routerId`, `proxyId`))", + "fields": [ + { + "fieldPath": "routerId", + "columnName": "routerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyId", + "columnName": "proxyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMatchedAt", + "columnName": "lastMatchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "routerId", + "proxyId" + ] + }, + "indices": [ + { + "name": "index_router_members_proxyId", + "unique": false, + "columnNames": [ + "proxyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_router_members_proxyId` ON `${TABLE_NAME}` (`proxyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "router_group_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`routerId` INTEGER NOT NULL, `sourceGroupId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`routerId`, `sourceGroupId`))", + "fields": [ + { + "fieldPath": "routerId", + "columnName": "routerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceGroupId", + "columnName": "sourceGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "routerId", + "sourceGroupId" + ] + }, + "indices": [ + { + "name": "index_router_group_sources_sourceGroupId", + "unique": false, + "columnNames": [ + "sourceGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_router_group_sources_sourceGroupId` ON `${TABLE_NAME}` (`sourceGroupId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fbef0abc54268d326eafac4ae8af1bb4')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json new file mode 100644 index 0000000000..928bc8e6cc --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json @@ -0,0 +1,519 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "bc0c2d4a295add48a24cb60099fbfbfe", + "entities": [ + { + "tableName": "proxy_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ungrouped", + "columnName": "ungrouped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscription", + "columnName": "subscription", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelector", + "columnName": "isSelector", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frontProxy", + "columnName": "frontProxy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "landingProxy", + "columnName": "landingProxy", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "proxy_entities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `ssrBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `juicityBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB, `snellBean` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tx", + "columnName": "tx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rx", + "columnName": "rx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ping", + "columnName": "ping", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "socksBean", + "columnName": "socksBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "httpBean", + "columnName": "httpBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ssBean", + "columnName": "ssBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ssrBean", + "columnName": "ssrBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "vmessBean", + "columnName": "vmessBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanBean", + "columnName": "trojanBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanGoBean", + "columnName": "trojanGoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "mieruBean", + "columnName": "mieruBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "naiveBean", + "columnName": "naiveBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "hysteriaBean", + "columnName": "hysteriaBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "tuicBean", + "columnName": "tuicBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "juicityBean", + "columnName": "juicityBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "sshBean", + "columnName": "sshBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "wgBean", + "columnName": "wgBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "shadowTLSBean", + "columnName": "shadowTLSBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "anyTLSBean", + "columnName": "anyTLSBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "chainBean", + "columnName": "chainBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "nekoBean", + "columnName": "nekoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "configBean", + "columnName": "configBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "snellBean", + "columnName": "snellBean", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `config` TEXT NOT NULL DEFAULT '', `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `ruleset` TEXT NOT NULL DEFAULT '', `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domains", + "columnName": "domains", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ip", + "columnName": "ip", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePort", + "columnName": "sourcePort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ruleset", + "columnName": "ruleset", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "outbound", + "columnName": "outbound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packages", + "columnName": "packages", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "router_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stableTag` TEXT NOT NULL, `name` TEXT NOT NULL, `mode` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `matchConfig` TEXT NOT NULL, `selectedProxyId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stableTag", + "columnName": "stableTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "matchConfig", + "columnName": "matchConfig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedProxyId", + "columnName": "selectedProxyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_router_groups_stableTag", + "unique": true, + "columnNames": [ + "stableTag" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_router_groups_stableTag` ON `${TABLE_NAME}` (`stableTag`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "router_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`routerId` INTEGER NOT NULL, `proxyId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `lastMatchedAt` INTEGER NOT NULL, PRIMARY KEY(`routerId`, `proxyId`))", + "fields": [ + { + "fieldPath": "routerId", + "columnName": "routerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyId", + "columnName": "proxyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMatchedAt", + "columnName": "lastMatchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "routerId", + "proxyId" + ] + }, + "indices": [ + { + "name": "index_router_members_proxyId", + "unique": false, + "columnNames": [ + "proxyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_router_members_proxyId` ON `${TABLE_NAME}` (`proxyId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc0c2d4a295add48a24cb60099fbfbfe')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt b/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt new file mode 100644 index 0000000000..c1dc1a2a72 --- /dev/null +++ b/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt @@ -0,0 +1,152 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RouterMigrationTest { + + @get:Rule + val migrationHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + SagerDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + fun migratesVersion8WithoutChangingLegacyRowsAndLeavesRouterStateEmpty() { + migrationHelper.createDatabase(TEST_DB, 8).apply { + execSQL( + "INSERT INTO proxy_groups " + + "(id, userOrder, ungrouped, name, type, subscription, `order`, isSelector, frontProxy, landingProxy) " + + "VALUES (1, 5, 0, 'legacy group', 0, NULL, 0, 0, -1, -1)" + ) + execSQL( + "INSERT INTO proxy_entities " + + "(id, groupId, type, userOrder, tx, rx, status, ping, uuid) " + + "VALUES (2, 1, 0, 7, 11, 13, 0, 42, 'legacy-proxy')" + ) + execSQL( + "INSERT INTO rules " + + "(id, name, userOrder, enabled, domains, ip, port, sourcePort, network, source, protocol, outbound, packages) " + + "VALUES (3, 'legacy rule', 9, 1, 'example.com', '', '', '', '', '', '', 2, '')" + ) + close() + } + + migrationHelper.runMigrationsAndValidate( + TEST_DB, + 9, + true, + SagerDatabase_AutoMigration_8_9_Impl() + ).use { database -> + assertEquals(1L, database.singleLong("SELECT COUNT(*) FROM proxy_groups")) + assertEquals(1L, database.singleLong("SELECT COUNT(*) FROM proxy_entities")) + assertEquals(1L, database.singleLong("SELECT COUNT(*) FROM rules")) + assertEquals(1L, database.singleLong("SELECT id FROM proxy_groups WHERE name = 'legacy group'")) + assertEquals(2L, database.singleLong("SELECT id FROM proxy_entities WHERE uuid = 'legacy-proxy'")) + assertEquals(3L, database.singleLong("SELECT id FROM rules WHERE name = 'legacy rule'")) + assertEquals(0L, database.singleLong("SELECT COUNT(*) FROM router_groups")) + assertEquals(0L, database.singleLong("SELECT COUNT(*) FROM router_members")) + } + } + + @Test + fun migratesVersion9WithoutCreatingGroupsOrChangingLegacyRoutes() { + migrationHelper.createDatabase(TEST_DB, 9).apply { + execSQL( + "INSERT INTO rules " + + "(id, name, userOrder, enabled, domains, ip, port, sourcePort, network, source, protocol, outbound, packages, config, ruleset) " + + "VALUES (1, 'legacy', 0, 1, '', '', '', '', '', '', '', -1, '', '', '')" + ) + close() + } + + migrationHelper.runMigrationsAndValidate( + TEST_DB, + 10, + true, + SagerDatabase_AutoMigration_9_10_Impl() + ).use { database -> + assertEquals(0L, database.singleLong("SELECT routerGroupId FROM rules WHERE id = 1")) + assertEquals(-1L, database.singleLong("SELECT outbound FROM rules WHERE id = 1")) + assertEquals(0L, database.singleLong("SELECT COUNT(*) FROM router_groups")) + assertEquals(0L, database.singleLong("SELECT COUNT(*) FROM router_group_sources")) + } + } + + @Test + fun sourcesAndMembersCanBeSharedAcrossRouters() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, SagerDatabase::class.java) + .allowMainThreadQueries() + .build() + + try { + val routerA = database.routerGroupDao().create(RouterGroup(stableTag = "router.a")) + val routerB = database.routerGroupDao().create(RouterGroup(stableTag = "router.b")) + database.routerGroupSourceDao().insert( + listOf(RouterGroupSource(routerA, 10), RouterGroupSource(routerB, 10)) + ) + database.routerMemberDao().insert( + listOf(RouterMember(routerA, 20), RouterMember(routerB, 20)) + ) + + assertEquals( + listOf(routerA, routerB), + database.routerGroupSourceDao().routersForSource(10).map { it.routerId } + ) + assertEquals(listOf(20L), database.routerMemberDao().getByRouter(routerA).map { it.proxyId }) + assertEquals(listOf(20L), database.routerMemberDao().getByRouter(routerB).map { it.proxyId }) + } finally { + database.close() + } + } + + @Test + fun memberDaoOrdersReplacesAndCleansMembersByProxy() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, SagerDatabase::class.java) + .allowMainThreadQueries() + .build() + + try { + val routerId = database.routerGroupDao().create(RouterGroup(stableTag = "router.us")) + val members = database.routerMemberDao() + + members.replaceMembers( + routerId, + listOf( + RouterMember(proxyId = 30, userOrder = 2), + RouterMember(proxyId = 10, userOrder = 1) + ) + ) + assertEquals(listOf(10L, 30L), members.getByRouter(routerId).map { it.proxyId }) + + assertEquals(1, members.deleteByProxy(10)) + assertEquals(listOf(30L), members.getByRouter(routerId).map { it.proxyId }) + + members.replaceMembers(routerId, listOf(RouterMember(proxyId = 50, userOrder = 0))) + assertEquals(listOf(50L), members.getByRouter(routerId).map { it.proxyId }) + } finally { + database.close() + } + } + + private fun SupportSQLiteDatabase.singleLong(sql: String): Long = query(sql).use { cursor -> + check(cursor.moveToFirst()) { "Expected one row for query: $sql" } + cursor.getLong(0) + } + + private companion object { + const val TEST_DB = "router-migration-test" + } +} diff --git a/app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt b/app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt new file mode 100644 index 0000000000..a5974e9deb --- /dev/null +++ b/app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt @@ -0,0 +1,97 @@ +package io.nekohasekai.sagernet.ui + +import io.nekohasekai.sagernet.database.RouterGroup +import io.nekohasekai.sagernet.database.RouterMember +import io.nekohasekai.sagernet.database.RouterGroupSource +import io.nekohasekai.sagernet.database.RuleEntity +import io.nekohasekai.sagernet.fmt.BackupSerializer +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BackupSerializationTest { + + @Test + fun routerGroupsMembersSourcesAndRuleReferencesSurviveBackupRoundTrip() { + val group = RouterGroup( + id = 42L, + stableTag = "router.us", + name = "US", + mode = RouterGroup.MODE_URL_TEST, + enabled = true, + matchConfig = "{\"regions\":[\"US\"]}", + selectedProxyId = 7L, + userOrder = 3L + ) + val member = RouterMember( + routerId = group.id, + proxyId = group.selectedProxyId, + userOrder = 1L, + lastMatchedAt = 123456789L + ) + val source = RouterGroupSource(group.id, 99L, 2L) + val rule = RuleEntity(id = 8L, name = "AI", routerGroupId = group.id) + + val backup = JSONObject().apply { + BackupSerializer.putParcelableArray(this, "routerGroups", listOf(group)) + BackupSerializer.putParcelableArray(this, "routerMembers", listOf(member)) + BackupSerializer.putParcelableArray(this, "routerSources", listOf(source)) + BackupSerializer.putRouterRuleReferences(this, listOf(rule)) + } + + assertEquals( + listOf(group), + BackupSerializer.getParcelableArray(backup, "routerGroups", RouterGroup.CREATOR) + ) + assertEquals( + listOf(member), + BackupSerializer.getParcelableArray(backup, "routerMembers", RouterMember.CREATOR) + ) + assertEquals( + listOf(source), + BackupSerializer.getParcelableArray(backup, "routerSources", RouterGroupSource.CREATOR) + ) + assertEquals(mapOf(rule.id to group.id), BackupSerializer.getRouterRuleReferences(backup)) + } + + @Test + fun legacyBackupWithoutRouterArraysIsAcceptedAndLeavesLegacySectionsUntouched() { + val legacyRules = JSONArray().put("legacy-adblock").put("legacy.invalid") + val legacySettings = JSONArray().put("base-setting") + val backup = JSONObject().apply { + put("version", 1) + put("profiles", JSONArray()) + put("groups", JSONArray()) + put("rules", legacyRules) + put("settings", legacySettings) + } + + assertFalse(backup.has("routerGroups")) + assertFalse(backup.has("routerMembers")) + assertTrue(BackupSerializer.getRouterRuleReferences(backup).isEmpty()) + assertTrue( + BackupSerializer.getParcelableArray( + backup, + "routerGroups", + RouterGroup.CREATOR + ).isEmpty() + ) + assertEquals(legacyRules.toString(), backup.getJSONArray("rules").toString()) + assertEquals(legacySettings.toString(), backup.getJSONArray("settings").toString()) + } + + @Test + fun versionThreeRoundTripsRelationsAndVersionTwoDefaultsThem() { + val json = JSONObject().put("version", 3) + BackupSerializer.putParcelableArray(json, "routerSources", listOf(RouterGroupSource(1, 10), RouterGroupSource(2, 10))) + assertEquals( + listOf(RouterGroupSource(1, 10), RouterGroupSource(2, 10)), + BackupSerializer.getParcelableArray(json, "routerSources", RouterGroupSource.CREATOR), + ) + val old = JSONObject().put("version", 2) + assertTrue(BackupSerializer.getParcelableArray(old, "routerSources", RouterGroupSource.CREATOR).isEmpty()) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a693284357..0677d341b3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -211,6 +211,15 @@ + + + diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index 1b2e8347aa..f67f72d103 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -155,6 +155,7 @@ object Key { const val ROUTE_PROTOCOL = "routeProtocol" const val ROUTE_RULESET = "routeRuleset" const val ROUTE_OUTBOUND = "routeOutbound" + const val ROUTE_OUTBOUND_ROUTER = "routeOutboundRouter" const val ROUTE_PACKAGES = "routePackages" const val GROUP_NAME = "groupName" @@ -232,4 +233,8 @@ object Action { // const val SWITCH_WAKE_LOCK = "io.nekohasekai.sagernet.SWITCH_WAKELOCK" const val RESET_UPSTREAM_CONNECTIONS = "moe.nb4a.RESET_UPSTREAM_CONNECTIONS" + + const val EXTRA_ROUTER_TAG = "routerTag" + const val EXTRA_ROUTER_PROXY_ID = "routerProxyId" + const val EXTRA_FORCE_FULL_RELOAD = "forceFullReload" } diff --git a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt index e86ec9463d..0c1e751dbf 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt @@ -195,8 +195,18 @@ class SagerNet : Application(), application, Intent(application, SagerConnection.serviceClass) ) - fun reloadService() = - application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName)) + fun reloadService(routerTag: String? = null, routerProxyId: Long? = null) = + application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName).apply { + if (routerTag != null && routerProxyId != null) { + putExtra(Action.EXTRA_ROUTER_TAG, routerTag) + putExtra(Action.EXTRA_ROUTER_PROXY_ID, routerProxyId) + } + }) + + fun reloadServiceFully() = + application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName).apply { + putExtra(Action.EXTRA_FORCE_FULL_RELOAD, true) + }) fun stopService() = application.sendBroadcast(Intent(Action.CLOSE).setPackage(application.packageName)) 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 c028fa610f..2af106e676 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -15,9 +15,16 @@ import io.nekohasekai.sagernet.aidl.ISagerNetService import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback import io.nekohasekai.sagernet.bg.proto.ProxyInstance import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.RouterGroup import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager +import io.nekohasekai.sagernet.route.RouterRuntimeMode +import io.nekohasekai.sagernet.route.RouterSelection +import io.nekohasekai.sagernet.route.RouterSelectionPlan +import io.nekohasekai.sagernet.route.RouterSelectionRequest +import io.nekohasekai.sagernet.route.routerNodeKey +import io.nekohasekai.sagernet.route.routerStableIdOrFallback import io.nekohasekai.sagernet.utils.DefaultNetworkListener import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -50,7 +57,11 @@ class BaseService { val receiver = broadcastReceiver { ctx, intent -> when (intent.action) { Intent.ACTION_SHUTDOWN -> service.persistStats() - Action.RELOAD -> service.reload() + Action.RELOAD -> service.reload( + intent.getStringExtra(Action.EXTRA_ROUTER_TAG), + intent.getLongExtra(Action.EXTRA_ROUTER_PROXY_ID, 0L).takeIf { it > 0L }, + intent.getBooleanExtra(Action.EXTRA_FORCE_FULL_RELOAD, false), + ) // Action.SWITCH_WAKE_LOCK -> runOnDefaultDispatcher { service.switchWakeLock() } PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -184,11 +195,17 @@ class BaseService { fun onBind(intent: Intent): IBinder? = if (intent.action == Action.SERVICE) data.binder else null - fun reload() { + fun reload( + routerTag: String? = null, + routerProxyId: Long? = null, + forceFullReload: Boolean = false, + ) { if (DataStore.selectedProxy == 0L) { stopRunner(false, (this as Context).getString(R.string.profile_empty)) } - if (canReloadSelector()) { + val routerReloadRequested = routerTag != null && routerProxyId != null + if (routerReloadRequested && trySelectRouter(routerTag!!, routerProxyId!!)) return + if (!forceFullReload && !routerReloadRequested && canReloadSelector()) { val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) val tag = data.proxy!!.config.profileTagMap[ent?.id] ?: "" if (tag.isNotBlank() && ent != null) { @@ -207,6 +224,46 @@ class BaseService { } } + private fun trySelectRouter(routerTag: String, proxyId: Long): Boolean { + val runningProxy = data.proxy ?: return false + if (routerTag.isBlank()) return false + val router = SagerDatabase.routerGroupDao.getByStableTag(routerTag) + ?.takeIf { it.enabled && it.stableTag.isNotBlank() && it.stableTag == routerTag } + ?: return false + if (runningProxy.config.routerSelectorTags[routerTag].isNullOrBlank()) return false + val plan = RouterSelection.plan( + request = RouterSelectionRequest( + routerTag = routerTag, + proxyId = proxyId, + mode = if (router.mode == RouterGroup.MODE_URL_TEST) { + RouterRuntimeMode.URL_TEST + } else { + RouterRuntimeMode.SELECTOR + }, + routerEnabled = router.enabled, + ), + routerSelectorTags = runningProxy.config.routerSelectorTags, + routerMemberIds = runningProxy.config.routerMemberIds, + profileTags = runningProxy.config.profileTagMap, + selectorGroupId = runningProxy.config.selectorGroupId, + ) + if (plan !is RouterSelectionPlan.HotSwitch) return false + if (!runningProxy.isInitialized() || !runningProxy.box.selectOutboundFor(plan.selectorTag, plan.targetTag)) { + return false + } + val selected = SagerDatabase.proxyDao.getById(proxyId) ?: return false + SagerDatabase.routerGroupDao.update( + router.copy( + selectedProxyId = proxyId, + selectedNodeKey = routerNodeKey( + selected.groupId, + routerStableIdOrFallback(selected.uuid, selected.id), + ), + ) + ) + return true + } + fun canReloadSelector(): Boolean { if ((data.proxy?.config?.selectorGroupId ?: -1L) < 0) return false val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) ?: return false 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 ce1d57bf73..5d659486f1 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -279,6 +279,7 @@ object DataStore : OnPreferenceDataStoreChangeListener { var routeRuleset by profileCacheStore.string(Key.ROUTE_RULESET) var routeOutbound by profileCacheStore.stringToInt(Key.ROUTE_OUTBOUND) var routeOutboundRule by profileCacheStore.long(Key.ROUTE_OUTBOUND + "Long") + var routeOutboundRouter by profileCacheStore.long(Key.ROUTE_OUTBOUND_ROUTER) var routePackages by profileCacheStore.string(Key.ROUTE_PACKAGES) var frontProxy by profileCacheStore.long(Key.GROUP_FRONT_PROXY + "Long") 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 b2688f498f..3c10ee439f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt @@ -2,10 +2,26 @@ package io.nekohasekai.sagernet.database import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.bg.SubscriptionUpdater +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.ktx.applyDefaultValues +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.route.RouterFilterConfig +import io.nekohasekai.sagernet.route.RouterMembership +import io.nekohasekai.sagernet.route.RouterMemberSnapshot +import io.nekohasekai.sagernet.route.RouterNodeSnapshot +import io.nekohasekai.sagernet.route.RouterReconcileGroup +import io.nekohasekai.sagernet.route.RouterReconciler +import io.nekohasekai.sagernet.route.danglingRouterMemberProxyIds +import io.nekohasekai.sagernet.route.routerStableIdOrFallback +import io.nekohasekai.sagernet.route.routerNodeKey object GroupManager { + data class RouterRefreshSnapshot( + val membersByRouterId: Map> + ) + interface Listener { suspend fun groupAdd(group: ProxyGroup) suspend fun groupUpdated(group: ProxyGroup) @@ -56,6 +72,7 @@ object GroupManager { suspend fun clearGroup(groupId: Long) { DataStore.selectedProxy = 0L SagerDatabase.proxyDao.deleteAll(groupId) + cleanupDanglingRouterMembers() iterator { groupUpdated(groupId) } } @@ -79,6 +96,156 @@ object GroupManager { iterator { groupUpdated(groupId) } } + fun replaceRouterMembers( + router: RouterGroup, + availableProxyIds: List, + requestedProxyIds: Iterable, + ) { + val plan = RouterMembership.plan( + availableProxyIds = availableProxyIds, + requestedProxyIds = requestedProxyIds, + currentSelectedProxyId = router.selectedProxyId + .takeIf { it != RouterGroup.NO_SELECTION }, + ) + SagerDatabase.instance.runInTransaction { + SagerDatabase.routerMemberDao.replaceMembers( + router.id, + plan.memberProxyIds.mapIndexed { index, proxyId -> + RouterMember( + routerId = router.id, + proxyId = proxyId, + userOrder = index.toLong(), + ) + }, + ) + SagerDatabase.routerGroupDao.update( + router.copy(selectedProxyId = plan.selectedProxyId ?: RouterGroup.NO_SELECTION), + ) + } + } + + fun snapshotRouterMembers(): RouterRefreshSnapshot { + val proxies = SagerDatabase.proxyDao.getAll().associateBy { it.id } + val sourceGroups = SagerDatabase.groupDao.allGroups().associateBy { it.id } + val members = SagerDatabase.routerGroupDao.all().associate { router -> + router.id to SagerDatabase.routerMemberDao.getByRouter(router.id).mapNotNull { member -> + proxies[member.proxyId]?.let { proxy -> + RouterMemberSnapshot( + proxyId = proxy.id, + stableId = proxy.routerStableId(), + sourceGroupId = sourceGroups[proxy.groupId] + ?.takeIf { it.type == GroupType.SUBSCRIPTION } + ?.id, + userOrder = member.userOrder + ) + } + } + } + return RouterRefreshSnapshot(members) + } + + suspend fun reconcileRouterMembers(previous: RouterRefreshSnapshot) { + cleanupDanglingRouterMembers() + val routers = SagerDatabase.routerGroupDao.all() + .filter { it.enabled && it.stableTag.isNotBlank() } + if (routers.isEmpty()) return + + val groups = routers.mapNotNull { router -> + runCatching { + RouterReconcileGroup( + routerId = router.id, + stableTag = router.stableTag, + sourceGroupIds = SagerDatabase.routerGroupSourceDao.sourcesFor(router.id) + .map { it.sourceGroupId }, + filter = RouterFilterConfig.fromJson(router.matchConfig).validate(), + selectedProxyId = router.selectedProxyId.takeIf { it != RouterGroup.NO_SELECTION } + ) + }.onFailure { error -> + Logs.e("Router ${router.stableTag} match configuration is invalid", error) + }.getOrNull() + } + if (groups.size != routers.size) return + + val sourceGroups = SagerDatabase.groupDao.allGroups().associateBy { it.id } + val nodes = SagerDatabase.proxyDao.getAll().mapNotNull { proxy -> + runCatching { + RouterNodeSnapshot( + id = proxy.id, + stableId = proxy.routerStableId(), + name = proxy.displayName(), + subscriptionId = sourceGroups[proxy.groupId] + ?.takeIf { it.type == GroupType.SUBSCRIPTION } + ?.id, + available = proxy.error == null + ) + }.getOrNull() + } + + val result = RouterReconciler.reconcile(nodes, groups, previous.membersByRouterId) + if (result.error != null) { + Logs.e("Router reconciliation preserved existing members: ${result.error}") + routers.forEach { router -> + SagerDatabase.routerGroupDao.update(router.copy(lastError = result.error)) + } + return + } + + val matchedAt = System.currentTimeMillis() + result.membersByRouterId.forEach { (routerId, members) -> + SagerDatabase.routerMemberDao.replaceMembers( + routerId, + members.map { member -> + RouterMember( + routerId = routerId, + proxyId = member.proxyId, + userOrder = member.userOrder, + lastMatchedAt = matchedAt + ) + } + ) + routers.firstOrNull { it.id == routerId }?.let { router -> + val selectedProxyId = result.selectedProxyIdsByRouterId[routerId] + ?: RouterGroup.NO_SELECTION + val selectedNodeKey = members.firstOrNull { it.proxyId == selectedProxyId } + ?.let { routerNodeKey(it.sourceGroupId, it.stableId) } + .orEmpty() + val lastError = if (members.isEmpty()) "No nodes match ${router.name}" else "" + SagerDatabase.routerGroupDao.update( + router.copy( + selectedProxyId = selectedProxyId, + selectedNodeKey = selectedNodeKey, + lastError = lastError, + ) + ) + } + } + } + + fun markRouterRefreshFailed(sourceGroupId: Long, message: String) { + val error = message.ifBlank { "Subscription refresh failed" } + SagerDatabase.routerGroupSourceDao.routersForSource(sourceGroupId) + .mapNotNull { SagerDatabase.routerGroupDao.getById(it.routerId) } + .forEach { router -> + SagerDatabase.routerGroupDao.update(router.copy(lastError = error)) + } + } + + fun cleanupDanglingRouterMembers() { + runCatching { + val currentProxyIds = SagerDatabase.proxyDao.getAll().map { it.id }.toSet() + val members = SagerDatabase.routerGroupDao.all().flatMap { router -> + SagerDatabase.routerMemberDao.getByRouter(router.id) + } + danglingRouterMemberProxyIds(members.map { member -> + RouterMemberSnapshot(member.proxyId, "proxy:${member.proxyId}") + }, currentProxyIds).forEach { proxyId -> + SagerDatabase.routerMemberDao.deleteByProxy(proxyId) + } + }.onFailure { error -> + Logs.e("Unable to clean dangling router members", error) + } + } + suspend fun createGroup(group: ProxyGroup): ProxyGroup { group.userOrder = SagerDatabase.groupDao.nextOrder() ?: 1 group.id = SagerDatabase.groupDao.createGroup(group.applyDefaultValues()) @@ -98,17 +265,39 @@ object GroupManager { } suspend fun deleteGroup(groupId: Long) { + val routerSnapshot = snapshotRouterMembers() + SagerDatabase.routerGroupSourceDao.deleteBySource(groupId) SagerDatabase.groupDao.deleteById(groupId) SagerDatabase.proxyDao.deleteByGroup(groupId) + reconcileRouterMembers(routerSnapshot) iterator { groupRemoved(groupId) } SubscriptionUpdater.reconfigureUpdater() } suspend fun deleteGroup(group: List) { + val routerSnapshot = snapshotRouterMembers() + group.forEach { SagerDatabase.routerGroupSourceDao.deleteBySource(it.id) } SagerDatabase.groupDao.deleteGroup(group) SagerDatabase.proxyDao.deleteByGroup(group.map { it.id }.toLongArray()) + reconcileRouterMembers(routerSnapshot) for (proxyGroup in group) iterator { groupRemoved(proxyGroup.id) } SubscriptionUpdater.reconfigureUpdater() } -} \ No newline at end of file +} + +private fun ProxyEntity.routerStableId(): String { + return routerStableIdOrFallback( + uuid.takeIf { it.isNotBlank() } + ?: runCatching { requireBean().routerStableIdentity() }.getOrNull(), + id + ) +} + +internal fun AbstractBean.routerStableIdentity(): String { + return clone().apply { + name = "" + customOutboundJson = "" + customConfigJson = "" + }.toUniversalLink() +} 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 661db42492..a3a6d190c8 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -108,6 +108,7 @@ object ProfileManager { } suspend fun deleteProfile2(groupId: Long, profileId: Long) { + SagerDatabase.routerMemberDao.deleteByProxy(profileId) if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L @@ -115,6 +116,7 @@ object ProfileManager { } suspend fun deleteProfile(groupId: Long, profileId: Long) { + SagerDatabase.routerMemberDao.deleteByProxy(profileId) if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt new file mode 100644 index 0000000000..9fcf730e12 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt @@ -0,0 +1,117 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.Dao +import androidx.room.ColumnInfo +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Update +import com.esotericsoftware.kryo.io.ByteBufferInput +import com.esotericsoftware.kryo.io.ByteBufferOutput +import io.nekohasekai.sagernet.fmt.Serializable + +@Entity( + tableName = "router_groups", + indices = [Index("stableTag", unique = true)] +) +data class RouterGroup( + @PrimaryKey(autoGenerate = true) var id: Long = 0L, + var stableTag: String = "", + var name: String = "", + var mode: Int = MODE_SELECTOR, + var enabled: Boolean = true, + var matchConfig: String = "{}", + var selectedProxyId: Long = NO_SELECTION, + var userOrder: Long = 0L, + @ColumnInfo(defaultValue = "") + var selectedNodeKey: String = "", + @ColumnInfo(defaultValue = "") + var lastError: String = "", +) : Serializable() { + + override fun initializeDefaultValues() { + } + + override fun serializeToBuffer(output: ByteBufferOutput) { + output.writeInt(1) + output.writeLong(id) + output.writeString(stableTag) + output.writeString(name) + output.writeInt(mode) + output.writeBoolean(enabled) + output.writeString(matchConfig) + output.writeLong(selectedProxyId) + output.writeLong(userOrder) + output.writeString(selectedNodeKey) + output.writeString(lastError) + } + + override fun deserializeFromBuffer(input: ByteBufferInput) { + val version = input.readInt() + id = input.readLong() + stableTag = input.readString() + name = input.readString() + mode = input.readInt() + enabled = input.readBoolean() + matchConfig = input.readString() + selectedProxyId = input.readLong() + userOrder = input.readLong() + if (version >= 1) { + selectedNodeKey = input.readString() + lastError = input.readString() + } + } + + @androidx.room.Dao + interface Dao { + + @Query("SELECT * FROM router_groups ORDER BY userOrder, id") + fun all(): List + + @Query("SELECT * FROM router_groups WHERE id = :routerId") + fun getById(routerId: Long): RouterGroup? + + @Query("SELECT * FROM router_groups WHERE stableTag = :stableTag") + fun getByStableTag(stableTag: String): RouterGroup? + + @Query("SELECT MAX(userOrder) + 1 FROM router_groups") + fun nextOrder(): Long? + + @Insert + fun create(router: RouterGroup): Long + + @Update + fun update(router: RouterGroup): Int + + @Delete + fun delete(router: RouterGroup): Int + + @Query("DELETE FROM router_groups") + fun reset() + + @Insert + fun insert(routers: List) + } + + companion object { + const val MODE_SELECTOR = 0 + const val MODE_URL_TEST = 1 + const val NO_SELECTION = -1L + + @JvmField + val CREATOR = object : Serializable.CREATOR() { + + override fun newInstance(): RouterGroup { + return RouterGroup() + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt new file mode 100644 index 0000000000..685bfb8685 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt @@ -0,0 +1,182 @@ +package io.nekohasekai.sagernet.database + +import io.nekohasekai.sagernet.GroupType +import io.nekohasekai.sagernet.route.RouterFilterConfig +import io.nekohasekai.sagernet.route.RouterFilterException +import io.nekohasekai.sagernet.route.RouterMatchRequest +import io.nekohasekai.sagernet.route.RouterMatcher +import io.nekohasekai.sagernet.route.RouterNodeSnapshot +import io.nekohasekai.sagernet.route.routerNodeKey +import io.nekohasekai.sagernet.route.routerStableIdOrFallback +import java.net.URI +import java.util.UUID + +data class RouterGroupDraft( + val id: Long = 0L, + val name: String, + val mode: Int, + val enabled: Boolean, + val sourceGroupIds: List, + val filter: RouterFilterConfig, +) + +data class RouterGroupPreview( + val proxyIds: List, + val names: List, +) + +sealed interface RouterDeleteResult { + data object Deleted : RouterDeleteResult + data class Referenced(val ruleCount: Int) : RouterDeleteResult +} + +class RouterGroupValidationException( + val field: Field, + message: String, + cause: Throwable? = null, +) : IllegalArgumentException(message, cause) { + enum class Field { + NAME, + MODE, + SOURCES, + INCLUDE, + EXCLUDE, + URL, + INTERVAL, + TOLERANCE, + } +} + +fun RouterGroupDraft.validate( + existingGroups: Iterable, + validSubscriptionIds: Set, +) { + val normalizedName = name.trim() + if (normalizedName.isEmpty() || existingGroups.any { + it.id != id && it.name.trim().equals(normalizedName, ignoreCase = true) + } + ) throw RouterGroupValidationException(RouterGroupValidationException.Field.NAME, "Group name is empty or already used") + + if (mode != RouterGroup.MODE_SELECTOR && mode != RouterGroup.MODE_URL_TEST) { + throw RouterGroupValidationException(RouterGroupValidationException.Field.MODE, "Unsupported group mode") + } + if (enabled && sourceGroupIds.isEmpty() || sourceGroupIds.any { it !in validSubscriptionIds }) { + throw RouterGroupValidationException(RouterGroupValidationException.Field.SOURCES, "Select at least one existing subscription") + } + try { + filter.validate() + } catch (error: RouterFilterException) { + val field = if (error.field == RouterFilterException.Field.INCLUDE) { + RouterGroupValidationException.Field.INCLUDE + } else { + RouterGroupValidationException.Field.EXCLUDE + } + throw RouterGroupValidationException(field, error.message ?: "Invalid regular expression", error) + } + if (filter.intervalSeconds < 10) { + throw RouterGroupValidationException(RouterGroupValidationException.Field.INTERVAL, "Interval must be at least 10 seconds") + } + if (filter.toleranceMs !in 0..65535) { + throw RouterGroupValidationException(RouterGroupValidationException.Field.TOLERANCE, "Tolerance must be between 0 and 65535 ms") + } + val uri = runCatching { URI(filter.testUrl) }.getOrNull() + if (mode == RouterGroup.MODE_URL_TEST && (uri?.scheme !in setOf("http", "https") || uri?.host.isNullOrBlank())) { + throw RouterGroupValidationException(RouterGroupValidationException.Field.URL, "Test URL must be HTTP or HTTPS") + } +} + +object RouterGroupRepository { + fun all(): List = SagerDatabase.routerGroupDao.all() + + fun get(routerId: Long): RouterGroup? = SagerDatabase.routerGroupDao.getById(routerId) + + fun sourceIds(routerId: Long): List = + SagerDatabase.routerGroupSourceDao.sourcesFor(routerId).map { it.sourceGroupId } + + fun preview(draft: RouterGroupDraft): RouterGroupPreview { + draft.filter.validate() + val sourceIds = draft.sourceGroupIds.distinct() + val nodes = sourceIds.flatMap { sourceId -> + SagerDatabase.proxyDao.getByGroup(sourceId).mapNotNull { proxy -> + runCatching { + RouterNodeSnapshot( + id = proxy.id, + stableId = proxy.uuid.takeIf(String::isNotBlank), + name = proxy.displayName(), + subscriptionId = sourceId, + available = proxy.error == null, + ) + }.getOrNull() + } + } + val ids = RouterMatcher.match( + nodes, + listOf(RouterMatchRequest(draft.id, sourceIds, draft.filter.validate())), + )[draft.id].orEmpty() + val names = nodes.associateBy { it.id }.let { byId -> ids.mapNotNull { byId[it]?.name } } + return RouterGroupPreview(ids, names) + } + + suspend fun save(draft: RouterGroupDraft): RouterGroup { + val subscriptions = SagerDatabase.groupDao.allGroups() + .filter { it.type == GroupType.SUBSCRIPTION } + .mapTo(mutableSetOf()) { it.id } + draft.validate(all(), subscriptions) + val snapshot = GroupManager.snapshotRouterMembers() + val existing = draft.id.takeIf { it > 0 }?.let(SagerDatabase.routerGroupDao::getById) + val group = RouterGroup( + id = existing?.id ?: 0L, + stableTag = existing?.stableTag ?: newStableTag(), + name = draft.name.trim(), + mode = draft.mode, + enabled = draft.enabled, + matchConfig = draft.filter.toJson(), + selectedProxyId = existing?.selectedProxyId ?: RouterGroup.NO_SELECTION, + userOrder = existing?.userOrder ?: (SagerDatabase.routerGroupDao.nextOrder() ?: 1L), + selectedNodeKey = existing?.selectedNodeKey.orEmpty(), + lastError = existing?.lastError.orEmpty(), + ) + SagerDatabase.instance.runInTransaction { + if (existing == null) group.id = SagerDatabase.routerGroupDao.create(group) + else SagerDatabase.routerGroupDao.update(group) + SagerDatabase.routerGroupSourceDao.replaceSources(group.id, draft.sourceGroupIds) + } + GroupManager.reconcileRouterMembers(snapshot) + return SagerDatabase.routerGroupDao.getById(group.id) ?: group + } + + fun delete(routerId: Long): RouterDeleteResult { + val references = SagerDatabase.rulesDao.countByRouterGroup(routerId) + if (references > 0) return RouterDeleteResult.Referenced(references) + val group = SagerDatabase.routerGroupDao.getById(routerId) ?: return RouterDeleteResult.Deleted + SagerDatabase.instance.runInTransaction { + SagerDatabase.routerMemberDao.deleteByRouter(routerId) + SagerDatabase.routerGroupSourceDao.deleteByRouter(routerId) + SagerDatabase.routerGroupDao.delete(group) + } + return RouterDeleteResult.Deleted + } + + fun select(routerId: Long, proxyId: Long): RouterGroup { + val group = SagerDatabase.routerGroupDao.getById(routerId) + ?: throw IllegalArgumentException("Proxy group does not exist") + check(group.enabled && group.mode == RouterGroup.MODE_SELECTOR) { + "Only an enabled selector group accepts a manual selection" + } + check(SagerDatabase.routerMemberDao.getByRouter(routerId).any { it.proxyId == proxyId }) { + "Selected node is not a member of ${group.name}" + } + val proxy = SagerDatabase.proxyDao.getById(proxyId) + ?: throw IllegalArgumentException("Selected node does not exist") + val stableId = routerStableIdOrFallback(proxy.uuid, proxy.id) + val updated = group.copy( + selectedProxyId = proxyId, + selectedNodeKey = routerNodeKey(proxy.groupId, stableId), + ) + SagerDatabase.routerGroupDao.update(updated) + return updated + } + + private fun newStableTag(): String = + "router." + UUID.randomUUID().toString().replace("-", "").lowercase() +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupSource.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupSource.kt new file mode 100644 index 0000000000..730daa3353 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupSource.kt @@ -0,0 +1,81 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.Entity +import androidx.room.ColumnInfo +import androidx.room.Index +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import com.esotericsoftware.kryo.io.ByteBufferInput +import com.esotericsoftware.kryo.io.ByteBufferOutput +import io.nekohasekai.sagernet.fmt.Serializable + +@Entity( + tableName = "router_group_sources", + primaryKeys = ["routerId", "sourceGroupId"], + indices = [Index("sourceGroupId")], +) +data class RouterGroupSource( + var routerId: Long = 0L, + var sourceGroupId: Long = 0L, + @ColumnInfo(defaultValue = "0") + var userOrder: Long = 0L, +) : Serializable() { + + override fun initializeDefaultValues() = Unit + + override fun serializeToBuffer(output: ByteBufferOutput) { + output.writeInt(0) + output.writeLong(routerId) + output.writeLong(sourceGroupId) + output.writeLong(userOrder) + } + + override fun deserializeFromBuffer(input: ByteBufferInput) { + input.readInt() + routerId = input.readLong() + sourceGroupId = input.readLong() + userOrder = input.readLong() + } + + @androidx.room.Dao + interface Dao { + @Query("SELECT * FROM router_group_sources ORDER BY routerId, userOrder, sourceGroupId") + fun all(): List + + @Query("SELECT * FROM router_group_sources WHERE routerId = :routerId ORDER BY userOrder, sourceGroupId") + fun sourcesFor(routerId: Long): List + + @Query("SELECT * FROM router_group_sources WHERE sourceGroupId = :sourceGroupId ORDER BY routerId") + fun routersForSource(sourceGroupId: Long): List + + @Query("DELETE FROM router_group_sources WHERE routerId = :routerId") + fun deleteByRouter(routerId: Long): Int + + @Query("DELETE FROM router_group_sources WHERE sourceGroupId = :sourceGroupId") + fun deleteBySource(sourceGroupId: Long): Int + + @Insert + fun insert(sources: List) + + @Transaction + fun replaceSources(routerId: Long, sourceGroupIds: Iterable) { + deleteByRouter(routerId) + val rows = sourceGroupIds.distinct().mapIndexed { index, sourceGroupId -> + RouterGroupSource(routerId, sourceGroupId, index.toLong()) + } + if (rows.isNotEmpty()) insert(rows) + } + + @Query("DELETE FROM router_group_sources") + fun reset() + } + + companion object { + @JvmField + val CREATOR = object : Serializable.CREATOR() { + override fun newInstance() = RouterGroupSource() + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt new file mode 100644 index 0000000000..e07b9d4cb3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt @@ -0,0 +1,88 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.esotericsoftware.kryo.io.ByteBufferInput +import com.esotericsoftware.kryo.io.ByteBufferOutput +import io.nekohasekai.sagernet.fmt.Serializable + +@Entity( + tableName = "router_members", + primaryKeys = ["routerId", "proxyId"], + indices = [Index("proxyId")] +) +data class RouterMember( + var routerId: Long = 0L, + var proxyId: Long = 0L, + var userOrder: Long = 0L, + var lastMatchedAt: Long = 0L +) : Serializable() { + + override fun initializeDefaultValues() { + } + + override fun serializeToBuffer(output: ByteBufferOutput) { + output.writeInt(0) + output.writeLong(routerId) + output.writeLong(proxyId) + output.writeLong(userOrder) + output.writeLong(lastMatchedAt) + } + + override fun deserializeFromBuffer(input: ByteBufferInput) { + input.readInt() + routerId = input.readLong() + proxyId = input.readLong() + userOrder = input.readLong() + lastMatchedAt = input.readLong() + } + + @androidx.room.Dao + interface Dao { + + @Query("SELECT * FROM router_members ORDER BY routerId, userOrder, proxyId") + fun all(): List + + @Query("SELECT * FROM router_members WHERE routerId = :routerId ORDER BY userOrder, proxyId") + fun getByRouter(routerId: Long): List + + @Query("DELETE FROM router_members WHERE routerId = :routerId") + fun deleteByRouter(routerId: Long): Int + + @Query("DELETE FROM router_members WHERE proxyId = :proxyId") + fun deleteByProxy(proxyId: Long): Int + + @Insert + fun insert(members: List) + + @Transaction + fun replaceMembers(routerId: Long, members: List) { + deleteByRouter(routerId) + if (members.isNotEmpty()) { + insert(members.map { it.copy(routerId = routerId) }) + } + } + + @Query("DELETE FROM router_members") + fun reset() + } + + companion object { + @JvmField + val CREATOR = object : Serializable.CREATOR() { + + override fun newInstance(): RouterMember { + return RouterMember() + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt index e730807397..8604ef932a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.room.* import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.app +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Entity(tableName = "rules") @@ -27,6 +28,9 @@ data class RuleEntity( var ruleset: String = "", var outbound: Long = 0, var packages: Set = emptySet(), + @IgnoredOnParcel + @ColumnInfo(defaultValue = "0") + var routerGroupId: Long = 0L, ) : Parcelable { fun displayName(): String { @@ -56,6 +60,10 @@ data class RuleEntity( } fun displayOutbound(): String { + if (routerGroupId > 0L) { + return SagerDatabase.routerGroupDao.getById(routerGroupId)?.name + ?: app.getString(R.string.router_reference_invalid) + } return when (outbound) { 0L -> app.getString(R.string.route_proxy) -1L -> app.getString(R.string.route_bypass) @@ -83,6 +91,9 @@ data class RuleEntity( @Query("SELECT * FROM rules WHERE id = :ruleId") fun getById(ruleId: Long): RuleEntity? + @Query("SELECT COUNT(*) FROM rules WHERE routerGroupId = :routerGroupId") + fun countByRouterGroup(routerGroupId: Long): Int + @Query("DELETE FROM rules WHERE id = :ruleId") fun deleteById(ruleId: Long): Int diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt index ab10fedb89..50531b340d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt @@ -15,14 +15,17 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( - entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class], - version = 8, + entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class, RouterGroup::class, RouterMember::class, RouterGroupSource::class], + version = 10, + // This phase supports upgrades from v3 through v9; v1/v2 compatibility is deferred to a separate migration task. autoMigrations = [ AutoMigration(from = 3, to = 4), AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), - AutoMigration(from = 7, to = 8) + AutoMigration(from = 7, to = 8), + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10) ] ) @TypeConverters(value = [KryoConverters::class, GsonConverters::class]) @@ -39,7 +42,6 @@ abstract class SagerDatabase : RoomDatabase() { .setJournalMode(JournalMode.TRUNCATE) .allowMainThreadQueries() .enableMultiInstanceInvalidation() - .fallbackToDestructiveMigration() .setQueryExecutor { GlobalScope.launch { it.run() } } .build() } @@ -47,11 +49,17 @@ abstract class SagerDatabase : RoomDatabase() { val groupDao get() = instance.groupDao() val proxyDao get() = instance.proxyDao() val rulesDao get() = instance.rulesDao() + val routerGroupDao get() = instance.routerGroupDao() + val routerMemberDao get() = instance.routerMemberDao() + val routerGroupSourceDao get() = instance.routerGroupSourceDao() } abstract fun groupDao(): ProxyGroup.Dao abstract fun proxyDao(): ProxyEntity.Dao abstract fun rulesDao(): RuleEntity.Dao + abstract fun routerGroupDao(): RouterGroup.Dao + abstract fun routerMemberDao(): RouterMember.Dao + abstract fun routerGroupSourceDao(): RouterGroupSource.Dao } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt new file mode 100644 index 0000000000..9eccd0e43a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt @@ -0,0 +1,107 @@ +package io.nekohasekai.sagernet.fmt + +import android.os.Parcel +import android.os.Parcelable +import io.nekohasekai.sagernet.database.RuleEntity +import moe.matsuri.nb4a.utils.Util +import org.json.JSONArray +import org.json.JSONObject + +/** The Parcelable-backed JSON array format used by NekoBox backups. */ +object BackupSerializer { + + const val BACKUP_VERSION = 3 + + fun putRouterRuleReferences(json: JSONObject, rules: Iterable) { + json.put("routerRuleRefs", JSONArray().apply { + rules.filter { it.routerGroupId > 0L }.forEach { rule -> + put(JSONObject().apply { + put("ruleId", rule.id) + put("routerGroupId", rule.routerGroupId) + }) + } + }) + } + + fun getRouterRuleReferences(json: JSONObject): Map { + if (!json.has("routerRuleRefs") || json.isNull("routerRuleRefs")) { + return emptyMap() + } + val values = json.getJSONArray("routerRuleRefs") + return buildMap { + for (index in 0 until values.length()) { + val value = values.getJSONObject(index) + val ruleId = value.getLong("ruleId") + val routerGroupId = value.getLong("routerGroupId") + if (ruleId > 0L && routerGroupId > 0L) put(ruleId, routerGroupId) + } + } + } + + fun putParcelableArray( + json: JSONObject, + key: String, + values: Iterable + ) { + json.put(key, JSONArray().apply { + values.forEach { put(encode(it)) } + }) + } + + fun getParcelableArray( + json: JSONObject, + key: String, + creator: Parcelable.Creator + ): List { + if (!json.has(key) || json.isNull(key)) return emptyList() + val values = json.getJSONArray(key) + return (0 until values.length()).map { index -> + decode(values.getString(index), creator) + } + } + + fun getParcelableArray( + json: JSONObject, + key: String, + decoder: (Parcel) -> T + ): List { + if (!json.has(key) || json.isNull(key)) return emptyList() + val values = json.getJSONArray(key) + return (0 until values.length()).map { index -> + val data = Util.b64Decode(values.getString(index)) + val parcel = Parcel.obtain() + try { + parcel.unmarshall(data, 0, data.size) + parcel.setDataPosition(0) + decoder(parcel) + } finally { + parcel.recycle() + } + } + } + + private fun encode(value: Parcelable): String { + val parcel = Parcel.obtain() + return try { + value.writeToParcel(parcel, 0) + Util.b64EncodeUrlSafe(parcel.marshall()) + } finally { + parcel.recycle() + } + } + + private fun decode( + encoded: String, + creator: Parcelable.Creator + ): T { + val data = Util.b64Decode(encoded) + val parcel = Parcel.obtain() + return try { + parcel.unmarshall(data, 0, data.size) + parcel.setDataPosition(0) + creator.createFromParcel(parcel) + } finally { + parcel.recycle() + } + } +} 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 ebe2ae752e..f38275474a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -7,6 +7,8 @@ import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_CONFIG import io.nekohasekai.sagernet.database.ProxyGroup +import io.nekohasekai.sagernet.database.RouterGroup +import io.nekohasekai.sagernet.database.RuleEntity import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.fmt.ConfigBuildResult.IndexEntity import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean @@ -32,6 +34,11 @@ import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxOutboundWireguardBean import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.mkPort +import io.nekohasekai.sagernet.route.RouterRuntime +import io.nekohasekai.sagernet.route.RouterFilterConfig +import io.nekohasekai.sagernet.route.RouterRuntimeGroup +import io.nekohasekai.sagernet.route.RouterRuntimeException +import io.nekohasekai.sagernet.route.RouterRuntimeMode import io.nekohasekai.sagernet.utils.PackageCache import moe.matsuri.nb4a.* import moe.matsuri.nb4a.SingBoxOptions.* @@ -57,6 +64,91 @@ const val TAG_DNS_HOSTS = "dns-hosts" const val LOCALHOST = "127.0.0.1" +private val routerSystemReservedTags = setOf( + TAG_DIRECT, + TAG_BYPASS, + TAG_BLOCK, + TAG_PROXY, + TAG_FRAGMENT, + TAG_MIXED, + TAG_DNS_HOSTS +) + +internal fun resolveRouteOutbound( + rule: RuleEntity, + mainProxyTag: String, + proxyTags: Map, + routerTagsById: Map, + primaryProxyId: Long = Long.MIN_VALUE +): String { + if (rule.routerGroupId > 0L) { + return routerTagsById[rule.routerGroupId] + ?: throw RouterRuntimeException( + rule.routerGroupId, + "", + RouterRuntimeException.Reason.MISSING, + ) + } + return when (val outId = rule.outbound) { + 0L -> mainProxyTag + -1L -> TAG_BYPASS + -2L -> TAG_BLOCK + else -> if (outId == primaryProxyId) mainProxyTag else proxyTags[outId] ?: "" + } +} + +internal fun validateRouterReferences( + rules: Iterable, + groups: Iterable, + builtRouterIds: Set, +) { + val groupsById = groups.associateBy { it.id } + rules.asSequence().map { it.routerGroupId }.filter { it > 0 }.distinct().forEach { id -> + val group = groupsById[id] + ?: throw RouterRuntimeException(id, "", RouterRuntimeException.Reason.MISSING) + if (!group.enabled) { + throw RouterRuntimeException(id, group.name, RouterRuntimeException.Reason.DISABLED) + } + if (id !in builtRouterIds) { + throw RouterRuntimeException(id, group.name, RouterRuntimeException.Reason.EMPTY) + } + } +} + +internal fun routerReservedTags(outbounds: Iterable): Set = + outbounds.flatMap { outbound -> + listOfNotNull(outbound.asMap()["tag"] as? String, (outbound as? Outbound)?.tag) + }.toSet() + routerSystemReservedTags + +internal fun buildRouterOutbounds( + groups: Iterable, + proxyTags: Map, + reservedTags: Set = emptySet(), + includeRouterGroups: Boolean = true +): List { + if (!includeRouterGroups) return emptyList() + + return RouterRuntime.build(groups, proxyTags, reservedTags).map { router -> + when (router.mode) { + RouterRuntimeMode.SELECTOR -> Outbound_SelectorOptions().apply { + type = "selector" + tag = router.tag + outbounds = router.outbounds + default_ = router.defaultTag + } + + RouterRuntimeMode.URL_TEST -> Outbound_URLTestOptions().apply { + type = "urltest" + tag = router.tag + outbounds = router.outbounds + url = router.filter.testUrl + interval = router.filter.intervalSeconds * 1_000_000_000L + tolerance = router.filter.toleranceMs + } + } + } +} + class ConfigBuildResult( var config: String, var externalIndex: List, @@ -64,6 +156,8 @@ class ConfigBuildResult( var trafficMap: Map>, var profileTagMap: Map, val selectorGroupId: Long, + val routerSelectorTags: Map = emptyMap(), + val routerMemberIds: Map> = emptyMap(), ) { data class IndexEntity(var chain: LinkedHashMap) } @@ -165,10 +259,28 @@ fun buildConfig( } val extraRules = if (forTest) listOf() else SagerDatabase.rulesDao.enabledRules() + val includeRouterGroups = !forTest && !forExport + val allRouterGroups = if (!includeRouterGroups) { + listOf() + } else { + SagerDatabase.routerGroupDao.all() + } + val routerGroups = allRouterGroups.filter { it.enabled && it.stableTag.isNotBlank() } + val routerMembers = if (!includeRouterGroups) { + mapOf() + } else { + routerGroups.associate { router -> + router.id to SagerDatabase.routerMemberDao.getByRouter(router.id) + } + } + val extraProxyIds = extraRules.mapNotNull { rule -> + rule.outbound.takeIf { it > 0 && it != proxy.id } + }.toMutableSet().apply { + addAll(routerMembers.values.flatten().map { it.proxyId }.filter { it != proxy.id }) + } val extraProxies = - if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraRules.mapNotNull { rule -> - rule.outbound.takeIf { it > 0 && it != proxy.id } - }.toHashSet().toList()).associateBy { it.id } + if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraProxyIds.toList()) + .associateBy { it.id } val buildSelector = !forTest && group?.isSelector == true && !forExport val userDNSRuleList = mutableListOf() val domainListDNSDirectForce = mutableListOf() @@ -203,6 +315,9 @@ fun buildConfig( } } + var routerSelectorTags: Map = emptyMap() + var routerMemberIds: Map> = emptyMap() + return MyOptions().apply { if (!forTest) { experimental = ExperimentalOptions().apply { @@ -607,6 +722,43 @@ fun buildConfig( extraProxies.forEach { (key, p) -> tagMap[key] = buildChain(key, p) } + val routerOutbounds = buildRouterOutbounds( + routerGroups.map { router -> + RouterRuntimeGroup( + stableTag = router.stableTag, + mode = if (router.mode == RouterGroup.MODE_URL_TEST) { + RouterRuntimeMode.URL_TEST + } else { + RouterRuntimeMode.SELECTOR + }, + memberProxyIds = routerMembers[router.id].orEmpty().map { it.proxyId }, + selectedProxyId = router.selectedProxyId, + id = router.id, + name = router.name, + filter = RouterFilterConfig.fromJson(router.matchConfig), + ) + }, + tagMap, + reservedTags = routerReservedTags(outbounds), + includeRouterGroups = includeRouterGroups + ) + outbounds.addAll(routerOutbounds) + val builtRouterTags = routerOutbounds.mapNotNull { outbound -> + (outbound.asMap()["tag"] as? String) ?: outbound.tag + }.toSet() + val routerTagsById = routerGroups.mapNotNull { router -> + router.stableTag.takeIf(builtRouterTags::contains)?.let { router.id to it } + }.toMap() + validateRouterReferences(extraRules, allRouterGroups, routerTagsById.keys) + routerSelectorTags = routerOutbounds + .filterIsInstance() + .mapNotNull { outbound -> + outbound.tag?.takeIf { it.isNotBlank() }?.let { it to it } + } + .toMap() + routerMemberIds = routerGroups.associate { router -> + router.stableTag to routerMembers[router.id].orEmpty().map { it.proxyId }.toSet() + }.filterKeys(routerSelectorTags::containsKey) val mainProxyTag = (if (buildSelector) TAG_PROXY else tagMap[proxy.id]) ?: TAG_PROXY @@ -829,12 +981,7 @@ fun buildConfig( } } - outbound = when (val outId = rule.outbound) { - 0L -> mainProxyTag - -1L -> TAG_BYPASS - -2L -> TAG_BLOCK - else -> if (outId == proxy.id) mainProxyTag else tagMap[outId] ?: "" - } + outbound = resolveRouteOutbound(rule, mainProxyTag, tagMap, routerTagsById, proxy.id) _hack_custom_config = rule.config } @@ -1057,7 +1204,9 @@ fun buildConfig( proxy.id, trafficMap, tagMap, - if (buildSelector) group.id else -1L + if (buildSelector) group.id else -1L, + routerSelectorTags, + routerMemberIds, ) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/RouteOutboundChoice.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/RouteOutboundChoice.kt new file mode 100644 index 0000000000..fc83902ccd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/RouteOutboundChoice.kt @@ -0,0 +1,19 @@ +package io.nekohasekai.sagernet.fmt + +data class RouteOutboundChoice( + val outbound: Long, + val routerGroupId: Long, +) + +internal fun serializeRouteOutboundChoice( + value: Int, + legacyProfileId: Long, + routerGroupId: Long, + routerChoiceValue: Int, +): RouteOutboundChoice = when (value) { + 0 -> RouteOutboundChoice(0L, 0L) + 1 -> RouteOutboundChoice(-1L, 0L) + 2 -> RouteOutboundChoice(-2L, 0L) + routerChoiceValue -> RouteOutboundChoice(0L, routerGroupId) + else -> RouteOutboundChoice(legacyProfileId, 0L) +} 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 426e3d74f1..c1c60deeb3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt @@ -148,13 +148,18 @@ abstract class GroupUpdater { } try { + val routerSnapshot = GroupManager.snapshotRouterMembers() RawUpdater.doUpdate(proxyGroup, subscription, userInterface, byUser) + GroupManager.reconcileRouterMembers(routerSnapshot) true } catch (e: Throwable) { Logs.w(e) + GroupManager.markRouterRefreshFailed(proxyGroup.id, e.readableMessage) userInterface?.onUpdateFailure(proxyGroup, e.readableMessage) finishUpdate(proxyGroup) false + } finally { + GroupManager.cleanupDanglingRouterMembers() } } } @@ -168,4 +173,4 @@ abstract class GroupUpdater { } -} \ No newline at end of file +} 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 1e1644f839..b07ed37126 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -36,6 +36,7 @@ import org.yaml.snakeyaml.TypeDescription import org.yaml.snakeyaml.Yaml import org.yaml.snakeyaml.error.YAMLException import java.io.StringReader +import java.util.IdentityHashMap import androidx.core.net.toUri @Suppress("EXPERIMENTAL_API_USAGE") @@ -158,6 +159,9 @@ object RawUpdater : GroupUpdater() { error(app.getString(R.string.no_proxies_found)) } + val stableIds = IdentityHashMap() + proxies.forEach { stableIds[it] = it.routerStableIdentity() } + Logs.d("New profiles: ${proxies.size}") val nameMap = proxies.associateBy { bean -> @@ -189,9 +193,11 @@ object RawUpdater : GroupUpdater() { if (toReplace.contains(name)) { val entity = toReplace[name]!! val existsBean = entity.requireBean() + val existingStableId = entity.uuid // 更新订阅,保留自定义覆写设置 bean.customOutboundJson = existsBean.customOutboundJson bean.customConfigJson = existsBean.customConfigJson + entity.uuid = stableIds[bean] ?: bean.routerStableIdentity() when { existsBean != bean -> { changed++ @@ -202,7 +208,7 @@ object RawUpdater : GroupUpdater() { Logs.d("Updated profile: $name") } - entity.userOrder != userOrder -> { + entity.userOrder != userOrder || existingStableId != entity.uuid -> { entity.putBean(bean) toUpdate.add(entity) entity.userOrder = userOrder @@ -221,6 +227,7 @@ object RawUpdater : GroupUpdater() { groupId = proxyGroup.id, userOrder = userOrder ).apply { putBean(bean) + uuid = stableIds[bean] ?: bean.routerStableIdentity() }) added.add(name) Logs.d("Inserted profile: $name") @@ -232,6 +239,9 @@ object RawUpdater : GroupUpdater() { Logs.d("Updated profiles: $it") } + toDelete.forEach { proxy -> + SagerDatabase.routerMemberDao.deleteByProxy(proxy.id) + } SagerDatabase.proxyDao.deleteProxy(toDelete).also { Logs.d("Deleted profiles: $it") } diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterFilter.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterFilter.kt new file mode 100644 index 0000000000..9e8810e84a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterFilter.kt @@ -0,0 +1,59 @@ +package io.nekohasekai.sagernet.route + +import com.google.gson.Gson + +data class RouterFilterConfig( + val includeRegex: String = "", + val excludeRegex: String = "", + val testUrl: String = DEFAULT_TEST_URL, + val intervalSeconds: Long = DEFAULT_INTERVAL_SECONDS, + val toleranceMs: Int = DEFAULT_TOLERANCE_MS, +) { + fun validate(): RouterFilterValidation = RouterFilterValidation( + include = includeRegex.compileIfPresent(RouterFilterException.Field.INCLUDE), + exclude = excludeRegex.compileIfPresent(RouterFilterException.Field.EXCLUDE), + ) + + fun toJson(): String = Gson().toJson(this) + + companion object { + const val DEFAULT_TEST_URL = "https://www.gstatic.com/generate_204" + const val DEFAULT_INTERVAL_SECONDS = 300L + const val DEFAULT_TOLERANCE_MS = 50 + + fun fromJson(value: String): RouterFilterConfig { + if (value.isBlank()) return RouterFilterConfig() + return Gson().fromJson(value, RouterFilterConfig::class.java).let { parsed -> + parsed.copy( + testUrl = parsed.testUrl.takeIf(String::isNotBlank) ?: DEFAULT_TEST_URL, + intervalSeconds = parsed.intervalSeconds.takeIf { it > 0 } ?: DEFAULT_INTERVAL_SECONDS, + toleranceMs = parsed.toleranceMs.takeIf { it >= 0 } ?: DEFAULT_TOLERANCE_MS, + ) + } + } + } +} + +data class RouterFilterValidation( + val include: Regex?, + val exclude: Regex?, +) + +class RouterFilterException( + val field: Field, + cause: Throwable, +) : IllegalArgumentException(cause.message, cause) { + enum class Field { + INCLUDE, + EXCLUDE, + } +} + +private fun String.compileIfPresent(field: RouterFilterException.Field): Regex? { + if (isBlank()) return null + return try { + toRegex(RegexOption.IGNORE_CASE) + } catch (error: IllegalArgumentException) { + throw RouterFilterException(field, error) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterMatcher.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterMatcher.kt new file mode 100644 index 0000000000..e9707d5976 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterMatcher.kt @@ -0,0 +1,32 @@ +package io.nekohasekai.sagernet.route + +data class RouterNodeSnapshot( + val id: Long, + val stableId: String? = null, + val name: String, + val subscriptionId: Long? = null, + val enabled: Boolean = true, + val available: Boolean = true, +) + +data class RouterMatchRequest( + val routerId: Long, + val sourceGroupIds: List, + val filter: RouterFilterValidation, +) + +object RouterMatcher { + fun match( + nodes: Iterable, + requests: Iterable, + ): Map> = requests.associate { request -> + val nodesBySource = nodes.filter { it.enabled && it.available }.groupBy { it.subscriptionId } + request.routerId to request.sourceGroupIds.asSequence().distinct() + .flatMap { sourceId -> nodesBySource[sourceId].orEmpty().asSequence() } + .filter { node -> request.filter.include?.containsMatchIn(node.name) != false } + .filterNot { node -> request.filter.exclude?.containsMatchIn(node.name) == true } + .distinctBy { node -> node.id } + .map { node -> node.id } + .toList() + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterMembership.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterMembership.kt new file mode 100644 index 0000000000..107d18db0e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterMembership.kt @@ -0,0 +1,22 @@ +package io.nekohasekai.sagernet.route + +data class RouterMembershipPlan( + val memberProxyIds: List, + val selectedProxyId: Long?, +) + +object RouterMembership { + + fun plan( + availableProxyIds: Iterable, + requestedProxyIds: Iterable, + currentSelectedProxyId: Long?, + ): RouterMembershipPlan { + val requested = requestedProxyIds.toSet() + val members = availableProxyIds.distinct() + .filter { it in requested } + val selected = currentSelectedProxyId?.takeIf(members::contains) + ?: members.firstOrNull() + return RouterMembershipPlan(members, selected) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt new file mode 100644 index 0000000000..3427da58e8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt @@ -0,0 +1,129 @@ +package io.nekohasekai.sagernet.route + +data class RouterMemberSnapshot( + val proxyId: Long, + val stableId: String, + val sourceGroupId: Long? = null, + val userOrder: Long = 0L, +) + +data class RouterReconcileGroup( + val routerId: Long, + val stableTag: String, + val sourceGroupIds: List, + val filter: RouterFilterValidation, + val selectedProxyId: Long? = null, +) + +data class RouterReconciliationResult( + val membersByRouterId: Map>, + val selectedProxyIdsByRouterId: Map, + val preservedPreviousMembers: Boolean, + val error: String? = null, +) + +internal fun routerStableIdOrFallback(stableId: String?, proxyId: Long): String = + stableId?.takeIf(String::isNotBlank) ?: "proxy:$proxyId" + +internal fun routerNodeKey(sourceGroupId: Long?, stableId: String): String = + "${sourceGroupId ?: 0}:$stableId" + +internal fun danglingRouterMemberProxyIds( + members: Iterable, + currentProxyIds: Set, +): Set = members.map { it.proxyId }.filterNot(currentProxyIds::contains).toSet() + +private data class StableNodeKey(val sourceGroupId: Long?, val stableId: String) + +object RouterReconciler { + fun reconcile( + currentNodes: Iterable, + groups: Iterable, + previousMembers: Map> = emptyMap(), + ): RouterReconciliationResult { + val nodes = currentNodes.toList() + val groupList = groups.toList() + if (nodes.isEmpty()) return preserved(groupList, previousMembers, "subscription refresh returned no nodes") + + val validNodes = nodes.filter { it.enabled && it.available } + if (validNodes.isEmpty()) { + return preserved(groupList, previousMembers, "subscription refresh returned no valid nodes") + } + + val currentById = validNodes.associateBy { it.id } + val currentByStableKey = validNodes.asSequence() + .filter { !it.stableId.isNullOrBlank() } + .distinctBy { StableNodeKey(it.subscriptionId, it.stableId!!) } + .associateBy { StableNodeKey(it.subscriptionId, it.stableId!!) } + val matchedByGroup = RouterMatcher.match( + validNodes, + groupList.map { RouterMatchRequest(it.routerId, it.sourceGroupIds, it.filter) }, + ) + + val membersByRouterId = groupList.associate { group -> + val matchedIds = matchedByGroup[group.routerId].orEmpty() + val matchedIdSet = matchedIds.toSet() + val retained = previousMembers[group.routerId].orEmpty() + .withIndex() + .sortedWith(compareBy> { it.value.userOrder }.thenBy { it.index }) + .mapNotNull { indexed -> + val previous = indexed.value + val current = currentById[previous.proxyId] + ?.takeIf { it.subscriptionId == previous.sourceGroupId && it.id in matchedIdSet } + ?: currentByStableKey[StableNodeKey(previous.sourceGroupId, previous.stableId)] + ?.takeIf { it.id in matchedIdSet } + current?.let { node -> + previous.copy( + proxyId = node.id, + stableId = routerStableIdOrFallback(node.stableId, node.id), + sourceGroupId = node.subscriptionId, + ) + } + } + .distinctBy { it.proxyId } + + val retainedIds = retained.mapTo(mutableSetOf()) { it.proxyId } + val nextOrder = retained.maxOfOrNull { it.userOrder } ?: 0L + val added = matchedIds.asSequence() + .filterNot(retainedIds::contains) + .mapNotNull(currentById::get) + .map { node -> + RouterMemberSnapshot( + proxyId = node.id, + stableId = routerStableIdOrFallback(node.stableId, node.id), + sourceGroupId = node.subscriptionId, + ) + } + .mapIndexed { index, member -> member.copy(userOrder = nextOrder + index + 1) } + .toList() + group.routerId to retained + added + } + + val selectedByRouterId = groupList.associate { group -> + val members = membersByRouterId[group.routerId].orEmpty() + val previousSelected = previousMembers[group.routerId].orEmpty() + .firstOrNull { it.proxyId == group.selectedProxyId } + val selected = members.firstOrNull { it.proxyId == group.selectedProxyId } + ?: previousSelected?.let { old -> + members.firstOrNull { + it.stableId == old.stableId && it.sourceGroupId == old.sourceGroupId + } + } + ?: members.firstOrNull() + group.routerId to selected?.proxyId + } + + return RouterReconciliationResult(membersByRouterId, selectedByRouterId, false) + } + + private fun preserved( + groups: List, + previous: Map>, + error: String, + ) = RouterReconciliationResult( + membersByRouterId = previous, + selectedProxyIdsByRouterId = groups.associate { it.routerId to it.selectedProxyId }, + preservedPreviousMembers = true, + error = error, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt new file mode 100644 index 0000000000..24a0c34473 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt @@ -0,0 +1,70 @@ +package io.nekohasekai.sagernet.route + +/** Runtime-only Router group data after ConfigBuilder has read the database. */ +data class RouterRuntimeGroup( + val stableTag: String, + val mode: RouterRuntimeMode, + val memberProxyIds: List, + val selectedProxyId: Long, + val id: Long = 0L, + val name: String = "", + val filter: RouterFilterConfig = RouterFilterConfig(), +) + +enum class RouterRuntimeMode { + SELECTOR, + URL_TEST +} + +/** A sing-box-independent Router outbound description. */ +data class RouterRuntimeOutbound( + val tag: String, + val mode: RouterRuntimeMode, + val outbounds: List, + val defaultTag: String?, + val filter: RouterFilterConfig, +) + +class RouterRuntimeException( + val groupId: Long, + val groupName: String, + val reason: Reason, +) : IllegalStateException( + when (reason) { + Reason.MISSING -> "Proxy group ${groupName.ifBlank { groupId.toString() }} is missing" + Reason.DISABLED -> "Proxy group ${groupName.ifBlank { groupId.toString() }} is disabled" + Reason.EMPTY -> "Proxy group ${groupName.ifBlank { groupId.toString() }} has no available nodes" + } +) { + enum class Reason { MISSING, DISABLED, EMPTY } +} + +/** + * Resolves persisted Router member IDs against the outbound tags built for the current config. + * Router tags are persisted separately from ProxyEntity IDs, so subscription refreshes cannot + * invalidate references to a Router group. + */ +object RouterRuntime { + fun build( + groups: Iterable, + proxyTags: Map, + reservedTags: Set = emptySet() + ): List { + val usedTags = reservedTags.toMutableSet() + + return groups.mapNotNull { group -> + if (group.stableTag.isBlank() || !usedTags.add(group.stableTag)) return@mapNotNull null + + val memberTags = group.memberProxyIds.mapNotNull(proxyTags::get).distinct() + if (memberTags.isEmpty()) return@mapNotNull null + val outbounds = memberTags + val defaultTag = if (group.mode == RouterRuntimeMode.SELECTOR) { + proxyTags[group.selectedProxyId]?.takeIf { it in memberTags } ?: outbounds.first() + } else { + null + } + + RouterRuntimeOutbound(group.stableTag, group.mode, outbounds, defaultTag, group.filter) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterSelection.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterSelection.kt new file mode 100644 index 0000000000..53c3148a2f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterSelection.kt @@ -0,0 +1,57 @@ +package io.nekohasekai.sagernet.route + +/** A node selection request from the Router surface. Null routerTag means the legacy selector. */ +data class RouterSelectionRequest( + val routerTag: String?, + val proxyId: Long, + val mode: RouterRuntimeMode, + val routerEnabled: Boolean = true, +) + +sealed interface RouterSelectionPlan { + data class HotSwitch( + val routerTag: String?, + val selectorTag: String, + val targetTag: String, + ) : RouterSelectionPlan + + data object Reload : RouterSelectionPlan + + data object IgnoreMissingRouter : RouterSelectionPlan +} + +/** Decides whether a node click can use the running selector or needs a full reload. */ +object RouterSelection { + + fun plan( + request: RouterSelectionRequest, + routerSelectorTags: Map, + routerMemberIds: Map>, + profileTags: Map, + selectorGroupId: Long, + ): RouterSelectionPlan { + val selectorTag = if (request.routerTag == null) { + if (selectorGroupId < 0L) return RouterSelectionPlan.Reload + "proxy" + } else { + if (!request.routerEnabled || request.routerTag.isBlank()) { + return RouterSelectionPlan.IgnoreMissingRouter + } + routerSelectorTags[request.routerTag] + ?: return RouterSelectionPlan.IgnoreMissingRouter + } + + if (request.routerTag != null && request.mode != RouterRuntimeMode.SELECTOR) { + return RouterSelectionPlan.Reload + } + + val targetTag = profileTags[request.proxyId] + ?.takeIf { it.isNotBlank() } + ?: return RouterSelectionPlan.Reload + if (request.routerTag != null && request.proxyId !in routerMemberIds[request.routerTag].orEmpty()) { + return RouterSelectionPlan.Reload + } + + return RouterSelectionPlan.HotSwitch(request.routerTag, selectorTag, targetTag) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt index a73052913c..0a84dfa053 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt @@ -3,8 +3,6 @@ package io.nekohasekai.sagernet.ui import android.content.Intent import android.net.Uri import android.os.Bundle -import android.os.Parcel -import android.os.Parcelable import android.provider.OpenableColumns import android.view.View import androidx.activity.result.contract.ActivityResultContracts @@ -25,8 +23,7 @@ import io.nekohasekai.sagernet.databinding.LayoutImportBinding import io.nekohasekai.sagernet.databinding.LayoutProgressBinding import io.nekohasekai.sagernet.ktx.* import kotlinx.coroutines.delay -import moe.matsuri.nb4a.utils.Util -import org.json.JSONArray +import io.nekohasekai.sagernet.fmt.BackupSerializer import org.json.JSONObject import java.io.ByteArrayOutputStream import java.io.File @@ -521,49 +518,27 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) { } } - fun Parcelable.toBase64Str(): String { - val parcel = Parcel.obtain() - writeToParcel(parcel, 0) - try { - return Util.b64EncodeUrlSafe(parcel.marshall()) - } finally { - parcel.recycle() - } - } - private fun doBackup( profile: Boolean, rule: Boolean, setting: Boolean ): ByteArray { val out = JSONObject().apply { - put("version", 1) + put("version", BackupSerializer.BACKUP_VERSION) if (profile) { - put("profiles", JSONArray().apply { - SagerDatabase.proxyDao.getAll().forEach { - put(it.toBase64Str()) - } - }) - - put("groups", JSONArray().apply { - SagerDatabase.groupDao.allGroups().forEach { - put(it.toBase64Str()) - } - }) + BackupSerializer.putParcelableArray(this, "profiles", SagerDatabase.proxyDao.getAll()) + BackupSerializer.putParcelableArray(this, "groups", SagerDatabase.groupDao.allGroups()) + BackupSerializer.putParcelableArray(this, "routerGroups", SagerDatabase.routerGroupDao.all()) + BackupSerializer.putParcelableArray(this, "routerMembers", SagerDatabase.routerMemberDao.all()) + BackupSerializer.putParcelableArray(this, "routerSources", SagerDatabase.routerGroupSourceDao.all()) } if (rule) { - put("rules", JSONArray().apply { - SagerDatabase.rulesDao.allRules().forEach { - put(it.toBase64Str()) - } - }) + val rules = SagerDatabase.rulesDao.allRules() + BackupSerializer.putParcelableArray(this, "rules", rules) + BackupSerializer.putRouterRuleReferences(this, rules) } if (setting) { - put("settings", JSONArray().apply { - PublicDatabase.kvPairDao.all().forEach { - put(it.toBase64Str()) - } - }) + BackupSerializer.putParcelableArray(this, "settings", PublicDatabase.kvPairDao.all()) } } @@ -692,58 +667,46 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) { fun finishImport( content: JSONObject, profile: Boolean, rule: Boolean, setting: Boolean ) { - if (profile && content.has("profiles")) { - val profiles = mutableListOf() - val jsonProfiles = content.getJSONArray("profiles") - for (i in 0 until jsonProfiles.length()) { - val data = Util.b64Decode(jsonProfiles[i] as String) - val parcel = Parcel.obtain() - parcel.unmarshall(data, 0, data.size) - parcel.setDataPosition(0) - profiles.add(ProxyEntity.CREATOR.createFromParcel(parcel)) - parcel.recycle() + SagerDatabase.instance.runInTransaction { + if (profile && content.has("profiles")) { + val profiles = BackupSerializer.getParcelableArray(content, "profiles", ProxyEntity.CREATOR) + val groups = BackupSerializer.getParcelableArray(content, "groups", ProxyGroup.CREATOR) + val routerGroups = BackupSerializer.getParcelableArray(content, "routerGroups", RouterGroup.CREATOR) + val routerMembers = BackupSerializer.getParcelableArray(content, "routerMembers", RouterMember.CREATOR) + val routerSources = BackupSerializer.getParcelableArray(content, "routerSources", RouterGroupSource.CREATOR) + + SagerDatabase.routerGroupSourceDao.reset() + SagerDatabase.routerMemberDao.reset() + SagerDatabase.routerGroupDao.reset() + SagerDatabase.proxyDao.reset() + SagerDatabase.groupDao.reset() + + SagerDatabase.groupDao.insert(groups) + SagerDatabase.proxyDao.insert(profiles) + if (routerGroups.isNotEmpty()) SagerDatabase.routerGroupDao.insert(routerGroups) + val validRouterIds = SagerDatabase.routerGroupDao.all().mapTo(hashSetOf()) { it.id } + val validGroupIds = SagerDatabase.groupDao.allGroups().mapTo(hashSetOf()) { it.id } + val validProxyIds = SagerDatabase.proxyDao.getAll().mapTo(hashSetOf()) { it.id } + val validMembers = routerMembers.filter { it.routerId in validRouterIds && it.proxyId in validProxyIds } + val validSources = routerSources.filter { it.routerId in validRouterIds && it.sourceGroupId in validGroupIds } + if (validMembers.isNotEmpty()) SagerDatabase.routerMemberDao.insert(validMembers) + if (validSources.isNotEmpty()) SagerDatabase.routerGroupSourceDao.insert(validSources) } - SagerDatabase.proxyDao.reset() - SagerDatabase.proxyDao.insert(profiles) - - val groups = mutableListOf() - val jsonGroups = content.getJSONArray("groups") - for (i in 0 until jsonGroups.length()) { - val data = Util.b64Decode(jsonGroups[i] as String) - val parcel = Parcel.obtain() - parcel.unmarshall(data, 0, data.size) - parcel.setDataPosition(0) - groups.add(ProxyGroup.CREATOR.createFromParcel(parcel)) - parcel.recycle() - } - SagerDatabase.groupDao.reset() - SagerDatabase.groupDao.insert(groups) - } - if (rule && content.has("rules")) { - val rules = mutableListOf() - val jsonRules = content.getJSONArray("rules") - for (i in 0 until jsonRules.length()) { - val data = Util.b64Decode(jsonRules[i] as String) - val parcel = Parcel.obtain() - parcel.unmarshall(data, 0, data.size) - parcel.setDataPosition(0) - rules.add(ParcelizeBridge.createRule(parcel)) - parcel.recycle() + + if (rule && content.has("rules")) { + val routerReferences = BackupSerializer.getRouterRuleReferences(content) + val rules = BackupSerializer.getParcelableArray(content, "rules") { + ParcelizeBridge.createRule(it) + }.map { imported -> + val routerGroupId = routerReferences[imported.id] ?: 0L + imported.copy(routerGroupId = routerGroupId) + } + SagerDatabase.rulesDao.reset() + SagerDatabase.rulesDao.insert(rules) } - SagerDatabase.rulesDao.reset() - SagerDatabase.rulesDao.insert(rules) } if (setting && content.has("settings")) { - val settings = mutableListOf() - val jsonSettings = content.getJSONArray("settings") - for (i in 0 until jsonSettings.length()) { - val data = Util.b64Decode(jsonSettings[i] as String) - val parcel = Parcel.obtain() - parcel.unmarshall(data, 0, data.size) - parcel.setDataPosition(0) - settings.add(KeyValuePair.CREATOR.createFromParcel(parcel)) - parcel.recycle() - } + val settings = BackupSerializer.getParcelableArray(content, "settings", KeyValuePair.CREATOR) PublicDatabase.kvPairDao.reset() PublicDatabase.kvPairDao.insert(settings) } 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 840234b549..a33bbd9d6e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt @@ -37,6 +37,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), lateinit var activity: MainActivity lateinit var groupListView: RecyclerView + lateinit var routerSection: LinearLayout lateinit var layoutManager: LinearLayoutManager lateinit var groupAdapter: GroupAdapter lateinit var undoManager: UndoSnackbarManager @@ -51,6 +52,11 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), toolbar.setOnMenuItemClickListener(this) groupListView = view.findViewById(R.id.group_list) + routerSection = view.findViewById(R.id.router_section) + routerSection.isVisible = true + routerSection.setOnClickListener { + startActivity(Intent(requireContext(), RouterGroupListActivity::class.java)) + } layoutManager = FixedLinearLayoutManager(groupListView) groupListView.layoutManager = layoutManager groupAdapter = GroupAdapter() @@ -544,4 +550,4 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt index 94e0b8f7d4..a721070a41 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt @@ -30,6 +30,7 @@ import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.RuleEntity import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.fmt.serializeRouteOutboundChoice import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.onMainDispatcher @@ -68,7 +69,10 @@ class RouteSettingsActivity( DataStore.routeProtocol = protocol DataStore.routeRuleset = ruleset DataStore.routeOutboundRule = outbound - DataStore.routeOutbound = when (outbound) { + DataStore.routeOutboundRouter = routerGroupId + DataStore.routeOutbound = if (routerGroupId > 0L) { + OutboundPreference.VALUE_SELECT_ROUTER.toInt() + } else when (outbound) { 0L -> 0 -1L -> 1 -2L -> 2 @@ -88,12 +92,14 @@ class RouteSettingsActivity( source = DataStore.routeSource protocol = DataStore.routeProtocol ruleset = DataStore.routeRuleset - outbound = when (DataStore.routeOutbound) { - 0 -> 0L - 1 -> -1L - 2 -> -2L - else -> DataStore.routeOutboundRule - } + val outboundChoice = serializeRouteOutboundChoice( + DataStore.routeOutbound, + DataStore.routeOutboundRule, + DataStore.routeOutboundRouter, + OutboundPreference.VALUE_SELECT_ROUTER.toInt(), + ) + outbound = outboundChoice.outbound + routerGroupId = outboundChoice.routerGroupId packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() }.toSet() if (DataStore.editingId == 0L) { @@ -146,6 +152,17 @@ class RouteSettingsActivity( apps.postUpdate() } + val selectRouterGroup = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { (resultCode, data) -> + if (resultCode == Activity.RESULT_OK) { + DataStore.routeOutboundRouter = data?.getLongExtra( + RouterGroupSelectActivity.EXTRA_ROUTER_ID, 0L + ) ?: 0L + outbound.value = OutboundPreference.VALUE_SELECT_ROUTER + } + } + lateinit var outbound: OutboundPreference lateinit var apps: AppListPreference @@ -165,6 +182,13 @@ class RouteSettingsActivity( } ) false + } else if (newValue.toString() == OutboundPreference.VALUE_SELECT_ROUTER) { + selectRouterGroup.launch( + Intent(this@RouteSettingsActivity, RouterGroupSelectActivity::class.java).apply { + putExtra(RouterGroupSelectActivity.EXTRA_SELECTED, DataStore.routeOutboundRouter) + } + ) + false } else { true } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListActivity.kt new file mode 100644 index 0000000000..653cec43d3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListActivity.kt @@ -0,0 +1,27 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import androidx.appcompat.widget.Toolbar +import io.nekohasekai.sagernet.R + +class RouterGroupListActivity : ThemedActivity(R.layout.layout_settings_activity) { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.apply { + setTitle(R.string.router_groups_title) + setDisplayHomeAsUpEnabled(true) + } + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .replace(R.id.settings, RouterGroupListFragment()) + .commit() + } + } + + override fun onSupportNavigateUp(): Boolean { + finish() + return true + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt new file mode 100644 index 0000000000..ff552d916a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt @@ -0,0 +1,60 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.os.Bundle +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.RouterGroup +import io.nekohasekai.sagernet.database.RouterGroupRepository +import io.nekohasekai.sagernet.database.SagerDatabase + +class RouterGroupListFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) = rebuild() + + override fun onResume() { + super.onResume() + rebuild() + } + + private fun rebuild() { + val screen = preferenceManager.createPreferenceScreen(requireContext()) + screen.addPreference(Preference(requireContext()).apply { + title = getString(R.string.router_group_add) + summary = getString(R.string.router_group_add_summary) + setIcon(R.drawable.ic_action_note_add) + setOnPreferenceClickListener { + startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java)) + true + } + }) + val category = PreferenceCategory(requireContext()).apply { + title = getString(R.string.router_groups_title) + } + screen.addPreference(category) + RouterGroupRepository.all().forEach { group -> category.addPreference(group.toPreference()) } + preferenceScreen = screen + } + + private fun RouterGroup.toPreference() = Preference(requireContext()).apply { + title = name.ifBlank { stableTag } + val members = SagerDatabase.routerMemberDao.getByRouter(id) + val modeName = getString( + if (mode == RouterGroup.MODE_URL_TEST) R.string.router_mode_automatic + else R.string.router_mode_manual + ) + val state = when { + !enabled -> getString(R.string.router_group_disabled) + lastError.isNotBlank() -> lastError + else -> getString(R.string.router_status, modeName, members.size) + } + summary = state + setOnPreferenceClickListener { + startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java).apply { + putExtra(RouterGroupSettingsActivity.EXTRA_ROUTER_ID, id) + }) + true + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSelectActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSelectActivity.kt new file mode 100644 index 0000000000..b9c13e2976 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSelectActivity.kt @@ -0,0 +1,58 @@ +package io.nekohasekai.sagernet.ui + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.widget.Toolbar +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.RouterGroup +import io.nekohasekai.sagernet.database.RouterGroupRepository +import io.nekohasekai.sagernet.database.SagerDatabase + +class RouterGroupSelectActivity : ThemedActivity(R.layout.layout_settings_activity) { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.apply { + setTitle(R.string.route_proxy_group) + setDisplayHomeAsUpEnabled(true) + } + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction().replace(R.id.settings, PickerFragment()).commit() + } + } + + override fun onSupportNavigateUp(): Boolean { finish(); return true } + + class PickerFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val selected = requireActivity().intent.getLongExtra(EXTRA_SELECTED, 0L) + val screen = preferenceManager.createPreferenceScreen(requireContext()) + RouterGroupRepository.all().filter { group -> + group.enabled && SagerDatabase.routerMemberDao.getByRouter(group.id).isNotEmpty() + }.forEach { group -> + screen.addPreference(Preference(requireContext()).apply { + title = group.name + summary = if (group.id == selected) getString(R.string.router_group_selected) else getString( + R.string.router_status, + getString(if (group.mode == RouterGroup.MODE_URL_TEST) R.string.router_mode_automatic else R.string.router_mode_manual), + SagerDatabase.routerMemberDao.getByRouter(group.id).size, + ) + setOnPreferenceClickListener { + requireActivity().setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_ROUTER_ID, group.id)) + requireActivity().finish() + true + } + }) + } + preferenceScreen = screen + } + } + + companion object { + const val EXTRA_SELECTED = "selected" + const val EXTRA_ROUTER_ID = "router_id" + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt new file mode 100644 index 0000000000..056a44af95 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt @@ -0,0 +1,216 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.widget.Toast +import androidx.appcompat.widget.Toolbar +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import io.nekohasekai.sagernet.GroupType +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.RouterDeleteResult +import io.nekohasekai.sagernet.database.RouterGroup +import io.nekohasekai.sagernet.database.RouterGroupDraft +import io.nekohasekai.sagernet.database.RouterGroupRepository +import io.nekohasekai.sagernet.database.RouterGroupValidationException +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.route.RouterFilterConfig + +class RouterGroupSettingsActivity : ThemedActivity(R.layout.layout_settings_activity) { + private val editor get() = supportFragmentManager.findFragmentById(R.id.settings) as? EditorFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.apply { + setTitle(R.string.router_group_settings) + setDisplayHomeAsUpEnabled(true) + } + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction().replace(R.id.settings, EditorFragment()).commit() + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.profile_config_menu, menu) + menu.findItem(R.id.action_delete).isVisible = intent.getLongExtra(EXTRA_ROUTER_ID, 0) > 0 + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { finish(); true } + R.id.action_apply -> { editor?.save(); true } + R.id.action_delete -> { editor?.delete(); true } + else -> super.onOptionsItemSelected(item) + } + + class EditorFragment : PreferenceFragmentCompat() { + private val routerId get() = requireActivity().intent.getLongExtra(EXTRA_ROUTER_ID, 0L) + private lateinit var name: EditTextPreference + private lateinit var enabled: SwitchPreferenceCompat + private lateinit var mode: ListPreference + private lateinit var sources: MultiSelectListPreference + private lateinit var include: EditTextPreference + private lateinit var exclude: EditTextPreference + private lateinit var urlCategory: PreferenceCategory + private lateinit var testUrl: EditTextPreference + private lateinit var interval: EditTextPreference + private lateinit var tolerance: EditTextPreference + private lateinit var selected: ListPreference + private lateinit var preview: Preference + private var sourceOrder = emptyList() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val group = routerId.takeIf { it > 0 }?.let(RouterGroupRepository::get) + val filter = group?.matchConfig?.let(RouterFilterConfig::fromJson) ?: RouterFilterConfig() + val subscriptions = SagerDatabase.groupDao.allGroups().filter { it.type == GroupType.SUBSCRIPTION } + sourceOrder = subscriptions.map { it.id } + val screen = preferenceManager.createPreferenceScreen(requireContext()) + name = EditTextPreference(requireContext()).nonPersistent().apply { + title = getString(R.string.router_group_name) + text = group?.name.orEmpty() + summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() + } + enabled = SwitchPreferenceCompat(requireContext()).nonPersistent().apply { + title = getString(R.string.router_group_enabled) + isChecked = group?.enabled ?: true + } + mode = ListPreference(requireContext()).nonPersistent().apply { + title = getString(R.string.router_group_mode) + entries = arrayOf(getString(R.string.router_mode_manual), getString(R.string.router_mode_automatic)) + entryValues = arrayOf(RouterGroup.MODE_SELECTOR.toString(), RouterGroup.MODE_URL_TEST.toString()) + value = (group?.mode ?: RouterGroup.MODE_SELECTOR).toString() + summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + } + sources = MultiSelectListPreference(requireContext()).nonPersistent().apply { + title = getString(R.string.router_group_sources) + entries = subscriptions.map { it.displayName() }.toTypedArray() + entryValues = subscriptions.map { it.id.toString() }.toTypedArray() + values = RouterGroupRepository.sourceIds(routerId).map(Long::toString).toSet() + } + include = textPreference(R.string.router_group_include, filter.includeRegex) + exclude = textPreference(R.string.router_group_exclude, filter.excludeRegex) + urlCategory = PreferenceCategory(requireContext()).apply { title = getString(R.string.router_url_test_settings) } + testUrl = textPreference(R.string.router_test_url, filter.testUrl) + interval = textPreference(R.string.router_test_interval, filter.intervalSeconds.toString()) + tolerance = textPreference(R.string.router_test_tolerance, filter.toleranceMs.toString()) + selected = ListPreference(requireContext()).nonPersistent().apply { + title = getString(R.string.router_select_node) + val members = SagerDatabase.routerMemberDao.getByRouter(routerId) + .mapNotNull { SagerDatabase.proxyDao.getById(it.proxyId) } + entries = members.map { it.displayName() }.toTypedArray() + entryValues = members.map { it.id.toString() }.toTypedArray() + value = group?.selectedProxyId?.takeIf { it > 0 }?.toString() + summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + setOnPreferenceChangeListener { _, newValue -> + val proxyId = newValue.toString().toLong() + runOnDefaultDispatcher { + runCatching { RouterGroupRepository.select(routerId, proxyId) } + .onSuccess { updated -> if (DataStore.serviceState.started) SagerNet.reloadService(updated.stableTag, proxyId) } + .onFailure { error -> onMainDispatcher { toast(error.message) } } + } + true + } + } + preview = Preference(requireContext()).apply { + title = getString(R.string.router_group_preview) + isSelectable = false + } + listOf(name, enabled, mode, sources, include, exclude).forEach(screen::addPreference) + screen.addPreference(urlCategory) + listOf(testUrl, interval, tolerance).forEach(urlCategory::addPreference) + screen.addPreference(selected) + screen.addPreference(preview) + preferenceScreen = screen + + listOf(name, enabled, mode, sources, include, exclude, testUrl, interval, tolerance).forEach { preference -> + preference.setOnPreferenceChangeListener { _, _ -> + view?.post { updateDynamicState() } + true + } + } + updateDynamicState() + } + + private fun updateDynamicState() { + val automatic = mode.value == RouterGroup.MODE_URL_TEST.toString() + urlCategory.isVisible = automatic + selected.isVisible = !automatic && routerId > 0 + runCatching { RouterGroupRepository.preview(draft()) } + .onSuccess { result -> + preview.summary = if (result.names.isEmpty()) getString(R.string.router_no_members) + else getString(R.string.router_group_preview_count, result.names.size, result.names.take(8).joinToString("\n")) + } + .onFailure { preview.summary = it.message } + } + + fun save() { + val draft = draft() + runOnDefaultDispatcher { + runCatching { RouterGroupRepository.save(draft) } + .onSuccess { + if (DataStore.serviceState.started) SagerNet.reloadServiceFully() + onMainDispatcher { requireActivity().finish() } + } + .onFailure { error -> onMainDispatcher { toast(validationMessage(error)) } } + } + } + + fun delete() { + runOnDefaultDispatcher { + when (val result = RouterGroupRepository.delete(routerId)) { + RouterDeleteResult.Deleted -> { + if (DataStore.serviceState.started) SagerNet.reloadServiceFully() + onMainDispatcher { requireActivity().finish() } + } + is RouterDeleteResult.Referenced -> onMainDispatcher { + toast(getString(R.string.router_group_delete_referenced, result.ruleCount)) + } + } + } + } + + private fun draft() = RouterGroupDraft( + id = routerId, + name = name.text.orEmpty(), + mode = mode.value?.toIntOrNull() ?: RouterGroup.MODE_SELECTOR, + enabled = enabled.isChecked, + sourceGroupIds = sourceOrder.filter { it.toString() in sources.values }, + filter = RouterFilterConfig( + includeRegex = include.text.orEmpty(), + excludeRegex = exclude.text.orEmpty(), + testUrl = testUrl.text.orEmpty(), + intervalSeconds = interval.text?.toLongOrNull() ?: 0, + toleranceMs = tolerance.text?.toIntOrNull() ?: -1, + ), + ) + + private fun textPreference(titleRes: Int, initial: String) = + EditTextPreference(requireContext()).nonPersistent().apply { + title = getString(titleRes) + text = initial + summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() + } + + private fun T.nonPersistent(): T = apply { isPersistent = false } + + private fun validationMessage(error: Throwable): String = when (error) { + is RouterGroupValidationException -> getString(R.string.router_group_validation_field, error.field.name, error.message) + else -> error.message ?: getString(R.string.error_title) + } + + private fun toast(message: String?) = Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + } + + companion object { const val EXTRA_ROUTER_ID = "router_id" } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt index f7f4fc6bcf..c3eb4701a4 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt @@ -9,6 +9,7 @@ import androidx.preference.PreferenceViewHolder import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.database.SagerDatabase import moe.matsuri.nb4a.ui.SimpleMenuPreference class OutboundPreference @@ -18,6 +19,7 @@ class OutboundPreference companion object { const val VALUE_SELECT_PROFILE = "3" + const val VALUE_SELECT_ROUTER = "4" } init { @@ -58,9 +60,9 @@ class OutboundPreference ) { if (!selectionReady || position < 0) return val newValue = entryValues?.getOrNull(position)?.toString() ?: return - val reselectedProfile = - dropdownOpened && newValue == value && newValue == VALUE_SELECT_PROFILE - if ((newValue != value || reselectedProfile) && callChangeListener(newValue)) { + val reselectedPicker = dropdownOpened && newValue == value && + newValue in setOf(VALUE_SELECT_PROFILE, VALUE_SELECT_ROUTER) + if ((newValue != value || reselectedPicker) && callChangeListener(newValue)) { value = newValue } dropdownOpened = false @@ -81,6 +83,13 @@ class OutboundPreference } } } + if (value == VALUE_SELECT_ROUTER) { + val routerId = DataStore.routeOutboundRouter + if (routerId > 0) { + return SagerDatabase.routerGroupDao.getById(routerId)?.name + ?: context.getString(R.string.router_reference_invalid) + } + } return super.getSummary() } diff --git a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt index f06e26d84b..be2a38815c 100644 --- a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt +++ b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt @@ -14,6 +14,8 @@ import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.utils.PackageCache +import io.nekohasekai.sagernet.route.routerNodeKey +import io.nekohasekai.sagernet.route.routerStableIdOrFallback import libcore.BoxPlatformInterface import libcore.Libcore import libcore.NB4AInterface @@ -82,26 +84,50 @@ class NativeInterface : BoxPlatformInterface, NB4AInterface { } override fun selector_OnProxySelected(selectorTag: String, tag: String) { + val service = DataStore.baseService + val proxy = service?.data?.proxy + val routerTag = proxy?.config?.routerSelectorTags?.entries + ?.firstOrNull { it.value == selectorTag }?.key + if (routerTag != null && service != null && proxy != null) { + val id = proxy.config.profileTagMap + .filterValues { it == tag }.keys.firstOrNull() ?: return + runOnDefaultDispatcher { + if (DataStore.baseService !== service || service.data.proxy !== proxy) return@runOnDefaultDispatcher + val selected = SagerDatabase.proxyDao.getById(id) ?: return@runOnDefaultDispatcher + SagerDatabase.routerGroupDao.getByStableTag(routerTag)?.let { router -> + SagerDatabase.routerGroupDao.update( + router.copy( + selectedProxyId = id, + selectedNodeKey = routerNodeKey( + selected.groupId, + routerStableIdOrFallback(selected.uuid, selected.id), + ), + ) + ) + } + } + return + } if (selectorTag != "proxy") { Logs.d("other selector: $selectorTag") return } Libcore.resetAllConnections(true) - DataStore.baseService?.apply { - runOnDefaultDispatcher { - val id = data.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) - } - // post binder - data.binder.broadcast { b -> - b.cbSelectorUpdate(id) - } + if (service == null || proxy == null) return + runOnDefaultDispatcher { + if (DataStore.baseService !== service || service.data.proxy !== proxy) return@runOnDefaultDispatcher + val id = proxy.config.profileTagMap + .filterValues { it == tag }.keys.firstOrNull() ?: -1 + val ent = SagerDatabase.proxyDao.getById(id) ?: return@runOnDefaultDispatcher + // traffic & title + proxy.apply { + looper?.selectMain(id) + displayProfileName = ServiceNotification.genTitle(ent) + service.data.notification?.postNotificationTitle(displayProfileName) + } + // post binder + service.data.binder.broadcast { b -> + b.cbSelectorUpdate(id) } } } diff --git a/app/src/main/res/layout/layout_group.xml b/app/src/main/res/layout/layout_group.xml index caf90435b8..87cf84c6b7 100644 --- a/app/src/main/res/layout/layout_group.xml +++ b/app/src/main/res/layout/layout_group.xml @@ -14,10 +14,32 @@ android:layout_width="match_parent" android:layout_height="wrap_content" /> + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 16b353acd5..2e834aa1c6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -610,4 +610,28 @@ QUIC Proxy 模式 仅 v6:旧版 v5 QUIC Proxy 兼容 仅适用于 v4-v6 + 代理组 + 手动选择 + 自动测速 + 新建代理组 + 从一个或多个订阅中筛选并组合节点 + 代理组设置 + 分组名称 + 启用 + 模式 + 订阅来源 + 包含正则(留空表示全部) + 排除正则 + 自动测速 + 测试地址 + 测试间隔(秒) + 容差(毫秒) + 匹配节点 + %1$d 个节点\n%2$s + 已禁用 + 已选择 + 有 %1$d 条路由规则正在使用此分组,请先重新指定这些规则。 + %1$s:%2$s + 代理组 + 代理组引用无效 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index b064e8563b..a6d456df37 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -317,6 +317,7 @@ @string/route_bypass @string/route_block @string/route_profile + @string/route_proxy_group @@ -324,6 +325,7 @@ 1 2 3 + 4 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8dc47181c..3a9cc75fa7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,6 +46,39 @@ Not updated yet %d Proxies %d Proxies | Updated on %s + Router groups + Manual + Automatic + %s | %d nodes + Current: %s + No current node + Select node + Members + Select Router members + No members configured + No subscription nodes are available. + Automatic Router groups select nodes by latency. + Create proxy group + Combine nodes from one or more subscriptions + Proxy group settings + Group name + Enabled + Mode + Subscription sources + Include regex (empty means all) + Exclude regex + URL test + Test URL + Test interval (seconds) + Tolerance (ms) + Matched nodes + %1$d nodes\n%2$s + Disabled + Selected + This group is used by %1$d route rules. Reassign those routes before deleting it. + %1$s: %2$s + Proxy group + Invalid proxy group reference %s: No difference %s: Updated %d proxies Diff diff --git a/app/src/test/java/io/nekohasekai/sagernet/database/RouterGroupRepositoryTest.kt b/app/src/test/java/io/nekohasekai/sagernet/database/RouterGroupRepositoryTest.kt new file mode 100644 index 0000000000..3425e13dfb --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/database/RouterGroupRepositoryTest.kt @@ -0,0 +1,82 @@ +package io.nekohasekai.sagernet.database + +import io.nekohasekai.sagernet.route.RouterFilterConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class RouterGroupRepositoryTest { + + @Test + fun enabledGroupRequiresASelectedSubscription() { + val error = assertThrows(RouterGroupValidationException::class.java) { + RouterGroupDraft( + name = "US1", + mode = RouterGroup.MODE_SELECTOR, + enabled = true, + sourceGroupIds = emptyList(), + filter = RouterFilterConfig(), + ).validate(emptyList(), setOf(10)) + } + assertEquals(RouterGroupValidationException.Field.SOURCES, error.field) + } + + @Test + fun disabledEmptyDraftIsAllowed() { + RouterGroupDraft( + name = "Draft", + mode = RouterGroup.MODE_SELECTOR, + enabled = false, + sourceGroupIds = emptyList(), + filter = RouterFilterConfig(), + ).validate(emptyList(), emptySet()) + } + + @Test + fun groupNameMustBeUniqueIgnoringCaseAndWhitespace() { + val error = assertThrows(RouterGroupValidationException::class.java) { + RouterGroupDraft( + id = 2, + name = " us1 ", + mode = RouterGroup.MODE_SELECTOR, + enabled = true, + sourceGroupIds = listOf(10), + filter = RouterFilterConfig(), + ).validate(listOf(RouterGroup(id = 1, name = "US1")), setOf(10)) + } + assertEquals(RouterGroupValidationException.Field.NAME, error.field) + } + + @Test + fun invalidModeSourceAndTimingAreRejected() { + val invalidMode = validDraft().copy(mode = 99) + assertEquals( + RouterGroupValidationException.Field.MODE, + assertThrows(RouterGroupValidationException::class.java) { + invalidMode.validate(emptyList(), setOf(10)) + }.field, + ) + val missingSource = validDraft().copy(sourceGroupIds = listOf(11)) + assertEquals( + RouterGroupValidationException.Field.SOURCES, + assertThrows(RouterGroupValidationException::class.java) { + missingSource.validate(emptyList(), setOf(10)) + }.field, + ) + val invalidTiming = validDraft().copy(filter = RouterFilterConfig(intervalSeconds = 9)) + assertEquals( + RouterGroupValidationException.Field.INTERVAL, + assertThrows(RouterGroupValidationException::class.java) { + invalidTiming.validate(emptyList(), setOf(10)) + }.field, + ) + } + + private fun validDraft() = RouterGroupDraft( + name = "US1", + mode = RouterGroup.MODE_URL_TEST, + enabled = true, + sourceGroupIds = listOf(10), + filter = RouterFilterConfig(), + ) +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt b/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt new file mode 100644 index 0000000000..c64a7e94e4 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt @@ -0,0 +1,82 @@ +package io.nekohasekai.sagernet.fmt + +import io.nekohasekai.sagernet.route.RouterRuntimeGroup +import io.nekohasekai.sagernet.route.RouterRuntimeMode +import io.nekohasekai.sagernet.route.RouterFilterConfig +import moe.matsuri.nb4a.SingBoxOptions.Outbound +import moe.matsuri.nb4a.SingBoxOptions.Outbound_SelectorOptions +import moe.matsuri.nb4a.SingBoxOptions.Outbound_URLTestOptions +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RouterOutboundConfigTest { + + @Test + fun translatesRuntimeSpecsIntoSelectorAndUrlTestOptions() { + val outbounds = buildRouterOutbounds( + groups = listOf( + RouterRuntimeGroup("router.us", RouterRuntimeMode.SELECTOR, listOf(1), 1, + id = 1, name = "US1", filter = RouterFilterConfig()), + RouterRuntimeGroup("router.sg", RouterRuntimeMode.URL_TEST, listOf(2), -1, + id = 2, name = "SG1", filter = RouterFilterConfig(testUrl = "https://example.com/204", intervalSeconds = 120, toleranceMs = 75)) + ), + proxyTags = mapOf(1L to "us-1", 2L to "sg-1") + ) + + val selector = outbounds[0] as Outbound_SelectorOptions + assertEquals("router.us", selector.tag) + assertEquals(listOf("us-1"), selector.outbounds) + assertEquals("us-1", selector.default_) + + val urlTest = outbounds[1] as Outbound_URLTestOptions + assertEquals("router.sg", urlTest.tag) + assertEquals(listOf("sg-1"), urlTest.outbounds) + assertEquals("https://example.com/204", urlTest.url) + assertEquals(120_000_000_000L, urlTest.interval) + assertEquals(75, urlTest.tolerance) + } + + @Test + fun excludesRouterOutboundsForPortableExport() { + val outbounds = buildRouterOutbounds( + groups = listOf( + RouterRuntimeGroup("router.us", RouterRuntimeMode.SELECTOR, listOf(1), 1, id = 1, name = "US1") + ), + proxyTags = mapOf(1L to "us-1"), + includeRouterGroups = false + ) + + assertTrue(outbounds.isEmpty()) + } + + @Test + fun skipsRouterTagReservedByAnExistingProfile() { + val outbounds = buildRouterOutbounds( + groups = listOf( + RouterRuntimeGroup("profile-us-1", RouterRuntimeMode.SELECTOR, listOf(1), 1, id = 1, name = "US1") + ), + proxyTags = mapOf(1L to "profile-us-1"), + reservedTags = setOf("profile-us-1") + ) + + assertTrue(outbounds.isEmpty()) + } + + @Test + fun skipsRouterTagReservedByAnAlreadyBuiltInternalOutboundMapTag() { + val internalOutbound = Outbound().apply { + _hack_config_map["tag"] = "g-123" + } + assertEquals("g-123", internalOutbound.asMap()["tag"]) + val outbounds = buildRouterOutbounds( + groups = listOf( + RouterRuntimeGroup("g-123", RouterRuntimeMode.SELECTOR, listOf(1), 1, id = 1, name = "US1") + ), + proxyTags = mapOf(1L to "g-123"), + reservedTags = routerReservedTags(listOf(internalOutbound)) + ) + + assertTrue(outbounds.isEmpty()) + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterRouteSemanticTest.kt b/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterRouteSemanticTest.kt new file mode 100644 index 0000000000..3aa3482e8a --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterRouteSemanticTest.kt @@ -0,0 +1,85 @@ +package io.nekohasekai.sagernet.fmt + +import io.nekohasekai.sagernet.database.RuleEntity +import io.nekohasekai.sagernet.database.RouterGroup +import io.nekohasekai.sagernet.route.RouterRuntimeException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class RouterRouteSemanticTest { + private val profileTags = mapOf(11L to "legacy-google", 12L to "legacy-social") + private val routerTags = mapOf(7L to "router.custom") + + @Test + fun routeNameNeverChangesItsLegacyOutbound() { + assertEquals( + TAG_BYPASS, + resolveRouteOutbound(RuleEntity(name = "Google and AI", outbound = -1), "main", profileTags, routerTags), + ) + assertEquals( + "legacy-social", + resolveRouteOutbound(RuleEntity(name = "Telegram", outbound = 12), "main", profileTags, routerTags), + ) + } + + @Test + fun explicitGroupReferenceUsesItsStableTag() { + val rule = RuleEntity(name = "Any name", outbound = 0, routerGroupId = 7) + assertEquals("router.custom", resolveRouteOutbound(rule, "main", profileTags, routerTags)) + } + + @Test + fun routeEditorTargetsAreMutuallyExclusive() { + assertEquals( + RouteOutboundChoice(outbound = 0L, routerGroupId = 7L), + serializeRouteOutboundChoice(4, legacyProfileId = 99L, routerGroupId = 7L, routerChoiceValue = 4), + ) + assertEquals( + RouteOutboundChoice(outbound = -1L, routerGroupId = 0L), + serializeRouteOutboundChoice(1, legacyProfileId = 99L, routerGroupId = 7L, routerChoiceValue = 4), + ) + assertEquals( + RouteOutboundChoice(outbound = 99L, routerGroupId = 0L), + serializeRouteOutboundChoice(3, legacyProfileId = 99L, routerGroupId = 7L, routerChoiceValue = 4), + ) + } + + @Test + fun missingGroupReferenceThrowsInsteadOfFallingBack() { + val error = assertThrows(RouterRuntimeException::class.java) { + resolveRouteOutbound(RuleEntity(outbound = 0, routerGroupId = 404), "main", profileTags, routerTags) + } + assertEquals(404L, error.groupId) + assertEquals(RouterRuntimeException.Reason.MISSING, error.reason) + } + + @Test + fun disabledAndEmptyReferencedGroupsHaveSpecificErrors() { + val groups = listOf( + RouterGroup(id = 1, stableTag = "router.disabled", name = "Disabled", enabled = false), + RouterGroup(id = 2, stableTag = "router.empty", name = "Empty", enabled = true), + ) + val disabled = assertThrows(RouterRuntimeException::class.java) { + validateRouterReferences(listOf(RuleEntity(routerGroupId = 1)), groups, emptySet()) + } + assertEquals("Disabled", disabled.groupName) + assertEquals(RouterRuntimeException.Reason.DISABLED, disabled.reason) + + val empty = assertThrows(RouterRuntimeException::class.java) { + validateRouterReferences(listOf(RuleEntity(routerGroupId = 2)), groups, emptySet()) + } + assertEquals("Empty", empty.groupName) + assertEquals(RouterRuntimeException.Reason.EMPTY, empty.reason) + } + + @Test + fun adBlockInvalidLoadAndMainSelectorTargetsStayUnchanged() { + assertEquals(TAG_BLOCK, resolveRouteOutbound(RuleEntity(name = "AdBlock", outbound = -2), "main", profileTags, routerTags)) + assertEquals("legacy-google", resolveRouteOutbound(RuleEntity(name = "加载节点", outbound = 11, domains = "full:load.invalid"), "main", profileTags, routerTags)) + assertEquals( + "main", + resolveRouteOutbound(RuleEntity(outbound = 99), "main", mapOf(99L to "chain"), routerTags, 99), + ) + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterFilterTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterFilterTest.kt new file mode 100644 index 0000000000..bdb0202f97 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterFilterTest.kt @@ -0,0 +1,18 @@ +package io.nekohasekai.sagernet.route + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RouterFilterTest { + @Test + fun jsonRoundTripPreservesOnlySupportedFields() { + val config = RouterFilterConfig("US", "Expired", "https://example.com/204", 120, 75) + assertEquals(config, RouterFilterConfig.fromJson(config.toJson())) + } + + @Test + fun oldOrEmptyJsonUsesSafeDefaults() { + assertEquals(RouterFilterConfig(), RouterFilterConfig.fromJson("{}")) + assertEquals(RouterFilterConfig(), RouterFilterConfig.fromJson("")) + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterMatcherTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterMatcherTest.kt new file mode 100644 index 0000000000..150f2824db --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterMatcherTest.kt @@ -0,0 +1,71 @@ +package io.nekohasekai.sagernet.route + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class RouterMatcherTest { + + @Test + fun sameNodeMayAppearInMultipleGroups() { + val node = RouterNodeSnapshot(7, "node-a", "US A", subscriptionId = 10) + val requests = listOf( + RouterMatchRequest(1, listOf(10), RouterFilterConfig(includeRegex = "US").validate()), + RouterMatchRequest(2, listOf(10), RouterFilterConfig(includeRegex = "A").validate()), + ) + assertEquals( + mapOf(1L to listOf(7L), 2L to listOf(7L)), + RouterMatcher.match(listOf(node), requests), + ) + } + + @Test + fun matchesOnlySelectedSourcesAndPreservesSourceNodeOrder() { + val nodes = listOf( + RouterNodeSnapshot(30, "a", "US 2", subscriptionId = 10), + RouterNodeSnapshot(10, "b", "US 1", subscriptionId = 10), + RouterNodeSnapshot(20, "c", "US other source", subscriptionId = 20), + ) + val request = RouterMatchRequest(1, listOf(10), RouterFilterConfig(includeRegex = "US").validate()) + assertEquals(listOf(30L, 10L), RouterMatcher.match(nodes, listOf(request))[1]) + } + + @Test + fun emptyIncludeMatchesAllAndExcludeWins() { + val nodes = listOf( + RouterNodeSnapshot(1, "a", "US Premium", subscriptionId = 10), + RouterNodeSnapshot(2, "b", "US Expired", subscriptionId = 10), + RouterNodeSnapshot(3, "c", "Singapore", subscriptionId = 10), + ) + val request = RouterMatchRequest(3, listOf(10), RouterFilterConfig(excludeRegex = "Expired").validate()) + assertEquals(listOf(1L, 3L), RouterMatcher.match(nodes, listOf(request))[3]) + } + + @Test + fun disabledUnavailableAndDuplicateNodesAreIgnoredWithinAGroup() { + val nodes = listOf( + RouterNodeSnapshot(1, "dead", "US", subscriptionId = 10, enabled = false), + RouterNodeSnapshot(2, "bad", "US", subscriptionId = 10, available = false), + RouterNodeSnapshot(3, "live", "US", subscriptionId = 10), + RouterNodeSnapshot(3, "copy", "US copy", subscriptionId = 10), + ) + val request = RouterMatchRequest(1, listOf(10), RouterFilterConfig().validate()) + assertEquals(listOf(3L), RouterMatcher.match(nodes, listOf(request))[1]) + } + + @Test + fun invalidRegexIdentifiesTheField() { + assertEquals( + RouterFilterException.Field.INCLUDE, + assertThrows(RouterFilterException::class.java) { + RouterFilterConfig(includeRegex = "[").validate() + }.field, + ) + assertEquals( + RouterFilterException.Field.EXCLUDE, + assertThrows(RouterFilterException::class.java) { + RouterFilterConfig(excludeRegex = "[").validate() + }.field, + ) + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterMembershipTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterMembershipTest.kt new file mode 100644 index 0000000000..ea090778c2 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterMembershipTest.kt @@ -0,0 +1,56 @@ +package io.nekohasekai.sagernet.route + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RouterMembershipTest { + + @Test + fun filtersUnavailableAndDuplicateMembersInCandidateOrder() { + val plan = RouterMembership.plan( + availableProxyIds = listOf(30L, 10L, 20L), + requestedProxyIds = listOf(20L, 99L, 20L, 30L), + currentSelectedProxyId = 20L, + ) + + assertEquals(listOf(30L, 20L), plan.memberProxyIds) + assertEquals(20L, plan.selectedProxyId) + } + + @Test + fun selectsFirstRemainingMemberWhenCurrentSelectionWasRemoved() { + val plan = RouterMembership.plan( + availableProxyIds = listOf(30L, 10L, 20L), + requestedProxyIds = listOf(20L, 30L), + currentSelectedProxyId = 10L, + ) + + assertEquals(listOf(30L, 20L), plan.memberProxyIds) + assertEquals(30L, plan.selectedProxyId) + } + + @Test + fun clearsSelectionWhenNoMembersRemain() { + val plan = RouterMembership.plan( + availableProxyIds = listOf(30L, 10L), + requestedProxyIds = emptyList(), + currentSelectedProxyId = 10L, + ) + + assertEquals(emptyList(), plan.memberProxyIds) + assertNull(plan.selectedProxyId) + } + + @Test + fun permitsMembersThatMayAlsoBelongToOtherGroups() { + val plan = RouterMembership.plan( + availableProxyIds = listOf(10L, 20L, 30L), + requestedProxyIds = listOf(10L, 20L, 30L), + currentSelectedProxyId = 20L, + ) + + assertEquals(listOf(10L, 20L, 30L), plan.memberProxyIds) + assertEquals(20L, plan.selectedProxyId) + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt new file mode 100644 index 0000000000..353f3c7045 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt @@ -0,0 +1,132 @@ +package io.nekohasekai.sagernet.route + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RouterReconcilerTest { + + @Test + fun remapsMemberAndSelectionBySourceScopedStableIdentity() { + val result = RouterReconciler.reconcile( + currentNodes = listOf(RouterNodeSnapshot(200, "node-a", "US renamed", subscriptionId = 10)), + groups = listOf(group(1, setOf(10), "US", selectedProxyId = 100)), + previousMembers = mapOf( + 1L to listOf(RouterMemberSnapshot(100, "node-a", sourceGroupId = 10, userOrder = 7)) + ), + ) + + assertEquals(listOf(200L), result.membersByRouterId.getValue(1).map { it.proxyId }) + assertEquals(listOf(7L), result.membersByRouterId.getValue(1).map { it.userOrder }) + assertEquals(200L, result.selectedProxyIdsByRouterId.getValue(1)) + assertFalse(result.preservedPreviousMembers) + } + + @Test + fun identicalStableIdentityFromAnotherSourceIsNotUsed() { + val result = RouterReconciler.reconcile( + currentNodes = listOf(RouterNodeSnapshot(200, "shared", "US B", subscriptionId = 20)), + groups = listOf(group(1, setOf(10), "US")), + previousMembers = mapOf( + 1L to listOf(RouterMemberSnapshot(100, "shared", sourceGroupId = 10, userOrder = 3)) + ), + ) + + assertTrue(result.membersByRouterId.getValue(1).isEmpty()) + } + + @Test + fun theSameCurrentNodeIsRetainedIndependentlyByTwoGroups() { + val current = listOf(RouterNodeSnapshot(200, "node-a", "US A", subscriptionId = 10)) + val previous = listOf(RouterMemberSnapshot(100, "node-a", sourceGroupId = 10)) + val result = RouterReconciler.reconcile( + current, + listOf(group(1, setOf(10), "US"), group(2, setOf(10), "A")), + mapOf(1L to previous, 2L to previous), + ) + + assertEquals(listOf(200L), result.membersByRouterId.getValue(1).map { it.proxyId }) + assertEquals(listOf(200L), result.membersByRouterId.getValue(2).map { it.proxyId }) + } + + @Test + fun excludeAndSourceChangesRemoveOldMembersOnSuccessfulRefresh() { + val result = RouterReconciler.reconcile( + currentNodes = listOf( + RouterNodeSnapshot(200, "a", "US Expired", subscriptionId = 10), + RouterNodeSnapshot(300, "b", "US Other", subscriptionId = 20), + ), + groups = listOf(group(1, setOf(10), "US", exclude = "Expired")), + previousMembers = mapOf(1L to listOf(RouterMemberSnapshot(100, "a", 10))), + ) + + assertTrue(result.membersByRouterId.getValue(1).isEmpty()) + assertFalse(result.preservedPreviousMembers) + } + + @Test + fun emptyOrInvalidRefreshPreservesTheLastValidSnapshot() { + val previous = mapOf(1L to listOf(RouterMemberSnapshot(100, "a", 10))) + val empty = RouterReconciler.reconcile(emptyList(), listOf(group(1, setOf(10), "")), previous) + val invalid = RouterReconciler.reconcile( + listOf(RouterNodeSnapshot(200, "b", "US", subscriptionId = 10, available = false)), + listOf(group(1, setOf(10), "")), + previous, + ) + + assertEquals(previous, empty.membersByRouterId) + assertEquals(previous, invalid.membersByRouterId) + assertTrue(empty.preservedPreviousMembers) + assertTrue(invalid.preservedPreviousMembers) + assertNotNull(empty.error) + assertNotNull(invalid.error) + } + + @Test + fun newMembersAppendInMatcherOrderAfterSurvivingUserOrder() { + val result = RouterReconciler.reconcile( + currentNodes = listOf( + RouterNodeSnapshot(300, "c", "US C", subscriptionId = 10), + RouterNodeSnapshot(200, "a", "US A", subscriptionId = 10), + RouterNodeSnapshot(201, "b", "US B", subscriptionId = 10), + ), + groups = listOf(group(1, setOf(10), "US")), + previousMembers = mapOf( + 1L to listOf( + RouterMemberSnapshot(101, "b", 10, 5), + RouterMemberSnapshot(100, "a", 10, 20), + ) + ), + ) + + assertEquals(listOf(201L, 200L, 300L), result.membersByRouterId.getValue(1).map { it.proxyId }) + assertEquals(listOf(5L, 20L, 21L), result.membersByRouterId.getValue(1).map { it.userOrder }) + } + + @Test + fun identifiesDanglingMembersAfterProxyDeletion() { + assertEquals( + setOf(100L), + danglingRouterMemberProxyIds( + listOf(RouterMemberSnapshot(100, "old"), RouterMemberSnapshot(200, "current")), + setOf(200), + ), + ) + } + + private fun group( + id: Long, + sources: Set, + include: String, + exclude: String = "", + selectedProxyId: Long? = null, + ) = RouterReconcileGroup( + routerId = id, + stableTag = "router.$id", + sourceGroupIds = sources.toList(), + filter = RouterFilterConfig(include, exclude).validate(), + selectedProxyId = selectedProxyId, + ) +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt new file mode 100644 index 0000000000..35b19e0ffd --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt @@ -0,0 +1,72 @@ +package io.nekohasekai.sagernet.route + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RouterRuntimeTest { + + @Test + fun buildsArbitraryUserNamedGroupsWithStableTags() { + val outbounds = RouterRuntime.build( + groups = listOf( + RouterRuntimeGroup("router.27a", RouterRuntimeMode.SELECTOR, listOf(1), 1, name = "US 1"), + RouterRuntimeGroup("router.91b", RouterRuntimeMode.URL_TEST, listOf(2), -1, name = "Work") + ), + proxyTags = mapOf(1L to "us-1", 2L to "work-1") + ) + + assertEquals( + listOf("router.27a", "router.91b"), + outbounds.map { it.tag } + ) + assertEquals( + listOf(RouterRuntimeMode.SELECTOR, RouterRuntimeMode.URL_TEST), + outbounds.map { it.mode } + ) + } + + @Test + fun resolvesOnlyCurrentOutboundTagsAndSkipsEmptyGroups() { + val outbounds = RouterRuntime.build( + groups = listOf( + RouterRuntimeGroup("router.us", RouterRuntimeMode.SELECTOR, listOf(1, 99, 1), 99), + RouterRuntimeGroup("router.empty", RouterRuntimeMode.URL_TEST, listOf(404), -1) + ), + proxyTags = mapOf(1L to "us-1", 2L to "unrelated") + ) + + assertEquals(listOf("us-1"), outbounds[0].outbounds) + assertEquals("us-1", outbounds[0].defaultTag) + assertEquals(listOf("router.us"), outbounds.map { it.tag }) + } + + @Test + fun skipsGroupsWhoseStableTagsCollideWithProfilesOrSystemOutbounds() { + val outbounds = RouterRuntime.build( + groups = listOf( + RouterRuntimeGroup("profile-us-1", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("direct", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("bypass", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("block", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("proxy", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("fragment", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("mixed-in", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("dns-hosts", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("router.us", RouterRuntimeMode.SELECTOR, listOf(1), 1) + ), + proxyTags = mapOf(1L to "profile-us-1"), + reservedTags = setOf( + "profile-us-1", + "direct", + "bypass", + "block", + "proxy", + "fragment", + "mixed-in", + "dns-hosts" + ) + ) + + assertEquals(listOf("router.us"), outbounds.map { it.tag }) + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterSelectionTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterSelectionTest.kt new file mode 100644 index 0000000000..b635bed953 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterSelectionTest.kt @@ -0,0 +1,130 @@ +package io.nekohasekai.sagernet.route + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RouterSelectionTest { + + @Test + fun selectsOnlyTheRequestedRouterUsingItsSelectorMapping() { + val result = RouterSelection.plan( + request = RouterSelectionRequest( + routerTag = "router.sg", + proxyId = 20L, + mode = RouterRuntimeMode.SELECTOR, + ), + routerSelectorTags = mapOf( + "router.us" to "selector-us", + "router.sg" to "selector-sg", + ), + routerMemberIds = mapOf( + "router.us" to setOf(10L), + "router.sg" to setOf(20L), + ), + profileTags = mapOf(10L to "node-us", 20L to "node-sg"), + selectorGroupId = -1L, + ) + + assertEquals( + RouterSelectionPlan.HotSwitch( + routerTag = "router.sg", + selectorTag = "selector-sg", + targetTag = "node-sg", + ), + result, + ) + } + + @Test + fun keepsLegacySelectorGroupIdSelectionAvailable() { + val result = RouterSelection.plan( + request = RouterSelectionRequest( + routerTag = null, + proxyId = 10L, + mode = RouterRuntimeMode.SELECTOR, + ), + routerSelectorTags = emptyMap(), + routerMemberIds = emptyMap(), + profileTags = mapOf(10L to "node-us"), + selectorGroupId = 42L, + ) + + assertEquals( + RouterSelectionPlan.HotSwitch( + routerTag = null, + selectorTag = "proxy", + targetTag = "node-us", + ), + result, + ) + } + + @Test + fun missingRouterTagDoesNotProduceASelectorCall() { + val result = RouterSelection.plan( + request = RouterSelectionRequest( + routerTag = "router.missing", + proxyId = 20L, + mode = RouterRuntimeMode.SELECTOR, + ), + routerSelectorTags = mapOf("router.us" to "selector-us"), + routerMemberIds = mapOf("router.us" to setOf(10L)), + profileTags = mapOf(20L to "node-sg"), + selectorGroupId = -1L, + ) + + assertEquals(RouterSelectionPlan.IgnoreMissingRouter, result) + } + + @Test + fun disabledRouterDoesNotProduceASelectorCall() { + val result = RouterSelection.plan( + request = RouterSelectionRequest( + routerTag = "router.us", + proxyId = 10L, + mode = RouterRuntimeMode.SELECTOR, + routerEnabled = false, + ), + routerSelectorTags = mapOf("router.us" to "selector-us"), + routerMemberIds = mapOf("router.us" to setOf(10L)), + profileTags = mapOf(10L to "node-us"), + selectorGroupId = -1L, + ) + + assertEquals(RouterSelectionPlan.IgnoreMissingRouter, result) + } + + @Test + fun blankRouterTagDoesNotProduceASelectorCall() { + val result = RouterSelection.plan( + request = RouterSelectionRequest( + routerTag = "", + proxyId = 10L, + mode = RouterRuntimeMode.SELECTOR, + ), + routerSelectorTags = mapOf("" to "selector-empty"), + routerMemberIds = mapOf("" to setOf(10L)), + profileTags = mapOf(10L to "node-us"), + selectorGroupId = -1L, + ) + + assertEquals(RouterSelectionPlan.IgnoreMissingRouter, result) + } + + @Test + fun automaticRouterSelectionUsesFullReloadInsteadOfHotSwitch() { + val result = RouterSelection.plan( + request = RouterSelectionRequest( + routerTag = "router.us-low", + proxyId = 30L, + mode = RouterRuntimeMode.URL_TEST, + ), + routerSelectorTags = mapOf("router.us-low" to "selector-us-low"), + routerMemberIds = mapOf("router.us-low" to setOf(30L)), + profileTags = mapOf(30L to "node-us-low"), + selectorGroupId = -1L, + ) + + assertEquals(RouterSelectionPlan.Reload, result) + } +} diff --git a/buildScript/compile-hevtun.sh b/buildScript/compile-hevtun.sh index 9aeb37ef23..a4dc667c7c 100755 --- a/buildScript/compile-hevtun.sh +++ b/buildScript/compile-hevtun.sh @@ -47,17 +47,48 @@ fi # Persistent obj/libs dirs so ndk-build stays incremental between runs. mkdir -p "$BUILD_DIR" + +# Git for Windows can check out repository symlinks as small text files when +# Developer Mode is unavailable. Build from a disposable mirror and replace +# those link placeholders with the contents of their targets, leaving the +# submodule checkout untouched. +BUILD_SRC="$BUILD_DIR/source" +rm -rf "$BUILD_SRC" +mkdir -p "$BUILD_SRC" +cp -a "$HEV_SRC/." "$BUILD_SRC/" + +materialize_git_links() { + local source_repo="$1" + local build_repo="$2" + git -c safe.directory="$source_repo" -C "$source_repo" ls-files -s | + awk '$1 == "120000" { print $4 }' | + while IFS= read -r relative_path; do + local link_file="$build_repo/$relative_path" + local target + target="$(tr -d '\r\n' < "$source_repo/$relative_path")" + cp -f "$(dirname "$link_file")/$target" "$link_file" + done +} + +materialize_git_links "$HEV_SRC" "$BUILD_SRC" +materialize_git_links "$HEV_SRC/src/core" "$BUILD_SRC/src/core" +materialize_git_links "$HEV_SRC/third-part/hev-task-system" \ + "$BUILD_SRC/third-part/hev-task-system" +materialize_git_links "$HEV_SRC/third-part/yaml" "$BUILD_SRC/third-part/yaml" +HEV_REV="$(git -c safe.directory="$HEV_SRC" -C "$HEV_SRC" \ + rev-parse --short HEAD 2>/dev/null || printf unknown)" + pushd "$BUILD_DIR" > /dev/null -if [ ! -e jni/hev-socks5-tunnel ]; then - mkdir -p jni - ln -sfn "$HEV_SRC" jni/hev-socks5-tunnel - echo 'include $(call all-subdir-makefiles)' > jni/Android.mk +NDK_BUILD="$ANDROID_NDK_HOME/ndk-build" +if [ ! -f "$NDK_BUILD" ] && [ -f "$NDK_BUILD.cmd" ]; then + NDK_BUILD="$NDK_BUILD.cmd" fi -"$ANDROID_NDK_HOME/ndk-build" \ +"$NDK_BUILD" \ NDK_PROJECT_PATH=. \ - APP_BUILD_SCRIPT=jni/Android.mk \ + APP_BUILD_SCRIPT="$BUILD_SRC/Android.mk" \ + "REV_ID=$HEV_REV" \ "APP_ABI=$ABIS" \ APP_PLATFORM=android-21 \ "APP_CFLAGS=-O3 -DPKGNAME=moe/matsuri/nb4a/hevtun -DCLSNAME=HevTunNative" \ diff --git a/buildScript/init/env_ndk.sh b/buildScript/init/env_ndk.sh index c0c7b8761b..52b300aac8 100755 --- a/buildScript/init/env_ndk.sh +++ b/buildScript/init/env_ndk.sh @@ -20,5 +20,9 @@ if [ ! -f "$_NDK/source.properties" ]; then exit 1 fi +case "$_NDK" in + [A-Za-z]:\\*) _NDK="$(cygpath -u "$_NDK")" ;; +esac + export ANDROID_NDK_HOME=$_NDK export NDK=$_NDK diff --git a/libcore/box.go b/libcore/box.go index 5df6d4ba0f..22c97c331b 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -213,6 +213,18 @@ func (b *BoxInstance) SelectOutbound(tag string) bool { return false } +func (b *BoxInstance) SelectOutboundFor(selectorTag, tag string) bool { + proxy, ok := b.Outbound().Outbound(selectorTag) + if !ok { + return false + } + selector, ok := proxy.(*group.Selector) + if !ok { + return false + } + return selector.SelectOutbound(tag) +} + func UrlTest(i *BoxInstance, link string, timeout int32) (latency int32, err error) { defer device.DeferPanicToError("box.UrlTest", func(err_ error) { err = err_ }) var connectionTracker adapter.ConnectionTracker From 5c8760e9dca1a85cc48f4a2e5ef8b07450091931 Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 16:28:00 +0800 Subject: [PATCH 04/29] docs: record custom proxy group verification --- ...6-09-04-antigravity-custom-proxy-groups.md | 140 ++++++++++++++++++ .../2026-09-04-custom-proxy-groups.md | 130 ++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 docs/superpowers/handoffs/2026-09-04-antigravity-custom-proxy-groups.md create mode 100644 docs/superpowers/verification/2026-09-04-custom-proxy-groups.md diff --git a/docs/superpowers/handoffs/2026-09-04-antigravity-custom-proxy-groups.md b/docs/superpowers/handoffs/2026-09-04-antigravity-custom-proxy-groups.md new file mode 100644 index 0000000000..1ea3e82945 --- /dev/null +++ b/docs/superpowers/handoffs/2026-09-04-antigravity-custom-proxy-groups.md @@ -0,0 +1,140 @@ +# Antigravity Handoff: NekoBox Custom Proxy Groups + +## Mission + +Continue and finish the user-approved custom proxy-group feature in this exact worktree: + +`C:\Users\renos\Documents\Proxy\NekoBoxForAndroid-router-groups` + +Do not restart the implementation and do not replace it with fixed US/SG/JP groups. Review and finish the existing uncommitted code. + +## User-approved behavior + +- Users can create any number of custom proxy groups with arbitrary display names. +- Each group selects one or more existing subscription groups as sources. +- Include regex filters node names; empty include means all source nodes. +- Exclude regex runs after include and wins. +- The same node may belong to multiple custom groups. +- Modes in this phase are only `selector` and `url-test`. +- Route rules explicitly select a custom group. Never infer a group from a rule name, domain, or app. +- A stable internal `router.` tag survives display-name changes and subscription refreshes. +- Missing, disabled, or empty referenced groups must produce a visible group-specific error. Never silently fall back. +- Failed/empty subscription refresh preserves the last valid members and records an error. +- Deleting a group is blocked while route rules reference it. +- Preserve all existing subscriptions, routes, App routing, AdBlock, `.invalid` load rules, DNS/TUN, profiles, and default behavior. +- Do not implement fallback, load balancing, nested groups, Clash YAML import, or predefined groups. + +## Hard safety boundaries + +- Never delete anything outside this project directory. +- Do not touch `C:\Users\renos\Documents\Proxy\A7.yaml`, `nekobox_isA8.json`, or similar isA8 files. +- Preserve all current working-tree changes. Do not reset, clean checkout, or discard files. +- Gradle `clean` inside this project is allowed and required for final ABI verification. +- Do not commit or push until verification is complete. Do not rewrite existing commits. +- Use minimal changes only; no unrelated cleanup or refactoring. + +## Read first + +1. `AGENTS.md` in the parent Proxy workspace and any repository-local instructions. +2. `docs/superpowers/specs/2026-09-04-custom-proxy-groups-design.md` +3. `docs/superpowers/plans/2026-09-04-custom-proxy-groups.md` +4. This handoff, then `git status --short` and the complete diff from `f8e6418`. + +## Git state + +- Branch: `router-groups` +- Existing commits: + - `f8e6418 docs: define custom proxy groups design` + - `bdc6392 docs: plan custom proxy groups implementation` +- The implementation is intentionally still uncommitted and includes earlier Antigravity work plus follow-up corrections. Preserve it. + +## Toolchain + +- Project-local Android SDK is configured by `local.properties`: + `C:\Users\renos\Documents\Proxy\.router-groups-toolchain\android-sdk` +- Portable JDK 17: + `C:\Users\renos\AppData\Local\CodexTools\jdk-17.0.20\jdk-17.0.20.1+1` +- Before Gradle commands in PowerShell: + `$env:JAVA_HOME='C:\Users\renos\AppData\Local\CodexTools\jdk-17.0.20\jdk-17.0.20.1+1'` +- Do not delete or relocate either external toolchain directory. +- ADB path: + `C:\Users\renos\Documents\Proxy\.router-groups-toolchain\android-sdk\platform-tools\adb.exe` + +## Work already completed + +- Independent include/exclude matching with cross-group overlap. +- Room v10 models for custom groups, materialized members, ordered subscription sources, stable selection, errors, and explicit `RuleEntity.routerGroupId`. +- No predefined groups or semantic route assignment. +- Repository validation, CRUD, preview, deletion guard, refresh reconciliation, stable node identity, and member-order preservation. +- ConfigBuilder emits selector/url-test outbounds and explicit route references; missing/disabled/empty references throw. +- Dedicated proxy-group list/editor and route-group picker. +- Selector hot switching through the existing native selector API. +- Backup version 3 stores groups, members, sources, and separate rule references (`routerRuleRefs`) while preserving the old RuleEntity parcel layout. +- Subscription failure records an error on affected groups. +- Old fixed inline group-card/manual-membership UI was removed. +- AAR ABI gate requires `libcore.HTTPClient`, rejects legacy `libcore.HttpClient`, checks `newHttpClient`, all four JNI ABIs, and compiled app caller bytecode. + +## Checks already passed + +- `:app:compileOssDebugKotlin` +- All `:app:testOssDebugUnitTest` +- `:app:compileOssDebugAndroidTestKotlin` +- `:app:verifyLibcore` +- `:app:verifyOssDebugLibcoreCallers` +- `git diff --check` (only Windows line-ending warnings) + +The latest combined JVM/Android-test compilation completed successfully. There was no connected Android device. `lintOssDebug` was started but intentionally interrupted when the user requested this handoff; do not treat lint as passed or failed. + +## Required remaining work + +1. Perform a focused code review against the approved spec and current plan. Fix only confirmed defects. +2. Pay special attention to: + - backup v3 import transaction and old-backup behavior; + - explicit route target serialization and invalid references; + - editor preview/change listeners and validation messages; + - selector persistence of both `selectedProxyId` and `selectedNodeKey`; + - refresh failure/empty-success preservation semantics; + - no remaining exclusivity or fixed-name assumptions outside test fixture strings; + - Room schema 10 matching current entities; + - URL-test `url`, nanosecond `interval`, and `tolerance` fields supported by the pinned binding. +3. Rerun full JVM tests and Android-test compilation. +4. Run lint to completion and classify findings. Do not broadly clean pre-existing warnings. +5. Run a clean debug build so stale native callers cannot survive: + `./gradlew.bat :app:clean :app:assembleOssDebug --console=plain --no-build-cache` +6. Inspect the produced APK: + - all expected `libgojni.so` ABI entries exist; + - no stale `Llibcore/HttpClient;` descriptor exists; + - current `Llibcore/HTTPClient;` descriptor exists. +7. Record APK path, size, SHA-256, exact command results, schema version, ABI results, lint result, and unrun device tests in: + `docs/superpowers/verification/2026-09-04-custom-proxy-groups.md` +8. Audit scope from `f8e6418`; verify no changes to protected config files and no unintended DNS/TUN/subscription/AdBlock/`.invalid` behavior. +9. If exactly one authorized Android device is connected, install the clean APK without clearing app data and run the device checklist below. Otherwise stop with the APK ready and clearly mark device acceptance unverified. +10. Only after all available checks pass, commit coherent implementation and verification changes. Do not push unless the user explicitly asks. + +## Device acceptance checklist + +1. Upgrade install without clearing data; confirm old database opens. +2. Refresh subscription 1 and subscription 2; confirm the prior `HttpClient/HTTPClient` crashes do not recur. +3. Create `US1` from both subscriptions with a US include regex. +4. Create a second group containing at least one of the same nodes; confirm overlap works. +5. In selector mode, switch one group and confirm the other group is unchanged. +6. In URL-test mode, confirm only that group's members are tested/selected. +7. Edit one route rule and explicitly select `US1`; start the service and verify the route resolves. +8. Refresh both subscriptions; confirm group definitions, route references, and valid members survive. +9. Confirm AdBlock, `.invalid` load rules, existing App routing, DNS/TUN, subscriptions, and ordinary profiles still function. +10. Capture exact logs for any failure; do not claim success from APK build alone. + +## Current device state + +The project SDK's `adb devices -l` returned no attached devices at handoff time. Do not invent device results. + +## Completion report format + +Lead with whether the feature is statically complete and whether real-device acceptance was actually performed. List: + +- what changed; +- exact passing/failing commands; +- APK absolute path and SHA-256; +- remaining risks or unverified device steps; +- commit hashes created; +- confirmation that protected files and unrelated behavior were not changed. diff --git a/docs/superpowers/verification/2026-09-04-custom-proxy-groups.md b/docs/superpowers/verification/2026-09-04-custom-proxy-groups.md new file mode 100644 index 0000000000..935cb7ad01 --- /dev/null +++ b/docs/superpowers/verification/2026-09-04-custom-proxy-groups.md @@ -0,0 +1,130 @@ +# NekoBox Custom Proxy Groups Verification Report + +Date: 2026-09-04 +Worktree: `C:\Users\renos\Documents\Proxy\NekoBoxForAndroid-router-groups` +Branch: `router-groups` +Base commit: `f8e6418` +Plan commit: `bdc6392` + +--- + +## 1. Executive Summary + +- **Static Feature Status**: Complete. All spec requirements, persistence models, matcher logic, repository methods, config generation, backup v3 serialization, UI components, and clean-build ABI gates are implemented and verified. +- **Real-Device Acceptance**: **Unverified**. `adb devices -l` returned no attached devices at verification time. Clean APKs are assembled and ready for installation. +- **Safety Boundaries**: Protected files (`A7.yaml`, `nekobox_isA8.json`) and files outside the project directory were completely untouched. No unrelated DNS, TUN, AdBlock, `.invalid` loading rules, App routing, or subscription behaviors were altered. + +--- + +## 2. Produced APK Artifacts + +Generated by clean build without build cache: +`.\gradlew.bat :app:clean :app:assembleOssDebug --console=plain --no-build-cache` + +| Artifact | Size (Bytes) | SHA-256 | +|---|---|---| +| `NekoBoxF-1.4.2-rev-24-arm64-v8a-debug.apk` | 23,487,101 | `D0B753E1D2066704C9B8F0AF6CD1F7D79D58A5D1D206513457E33D8D534CC658` | +| `NekoBoxF-1.4.2-rev-24-armeabi-v7a-debug.apk` | 23,628,566 | `03E0FB2F4FE3742A0AA3B0BE9C149262FD9907F707ED0B852E78F9E99CE3AE46` | +| `NekoBoxF-1.4.2-rev-24-x86-debug.apk` | 24,346,074 | `36AF2214A6B391FA1519FD12024FDD7A0FABD4410D0544BC217A90143E9719BF` | +| `NekoBoxF-1.4.2-rev-24-x86_64-debug.apk` | 24,222,449 | `1BD530C0CC27BB206FEA4FB29106D37612B896036FE6C5C955C06588115D136E` | + +### APK ABI & Bytecode Verification + +Inspected every produced APK using ordinal ASCII matching across all packed DEX files: + +- **`libgojni.so` Native Library**: + - `arm64-v8a`: `lib/arm64-v8a/libgojni.so` present. + - `armeabi-v7a`: `lib/armeabi-v7a/libgojni.so` present. + - `x86`: `lib/x86/libgojni.so` present. + - `x86_64`: `lib/x86_64/libgojni.so` present. +- **`Llibcore/HttpClient;` (Stale / Obsolete)**: **Absent** across all 4 APKs (`HasLegacyHttpClient = False`). +- **`Llibcore/HTTPClient;` (Current ABI)**: **Present** across all 4 APKs (`HasCurrentHTTPClient = True`). + +--- + +## 3. Database Schema & Migration + +- **Schema Version**: `10` (`app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json`) +- **AutoMigration**: `AutoMigration(from = 9, to = 10)` registered in `SagerDatabase.kt`. +- **Entities & Columns**: + - `router_groups`: `id`, `stableTag` (unique index), `name`, `mode`, `enabled`, `matchConfig`, `selectedProxyId`, `userOrder`, `selectedNodeKey`, `lastError`. + - `router_members`: primary key `(routerId, proxyId)`, `userOrder`, `lastMatchedAt`, index on `proxyId`. + - `router_group_sources`: primary key `(routerId, sourceGroupId)`, `userOrder`, index on `sourceGroupId`. + - `rules`: added `routerGroupId` (INTEGER NOT NULL DEFAULT 0), `@IgnoredOnParcel` to preserve legacy parcel wire layout. +- **Migration Verification**: `RouterMigrationTest.kt` passes compilation and tests v8->v9, v9->v10, sharing of sources/members across routers, and ordering/replacement/deletion semantics. + +--- + +## 4. Automated Test Results + +Executed with: +`$env:JAVA_HOME='C:\Users\renos\Documents\Proxy\.router-groups-toolchain\jdk'` + +### 4.1 JVM Unit Tests (`:app:testOssDebugUnitTest`) +- **Result**: `BUILD SUCCESSFUL` (0 failures, 0 errors). +- **Suites Passed**: + - `RouterMatcherTest`: multi-group node overlap, source order preservation, include/exclude precedence, local deduplication, regex validation errors. + - `RouterMembershipTest`: membership planning, stable selection retention, order stability. + - `RouterFilterTest`: JSON codec round-trip, timing bounds, field validation. + - `RouterReconcilerTest`: source-scoped stable identity remap, cross-group overlap, preservation of previous members on failed/empty refresh, appending in matcher order. + - `RouterSelectionTest`: targeted hot-switching for selector groups, reload for URL-test groups, isolated group switching, legacy selector fallback. + - `RouterGroupRepositoryTest`: draft validation, unique case-insensitive name check, minimum 10s interval, 0..65535 tolerance, HTTP/HTTPS URL requirement. + - `RouterRouteSemanticTest`: legacy routes never modified by rule name, explicit router group resolution, mutually exclusive picker choices, missing group runtime error, AdBlock and `.invalid` load rules untouched. + - `RouterOutboundConfigTest`: emission of sing-box `selector` and `urltest` options, nanosecond conversion (`120_000_000_000L`), reserved tag avoidance. + +### 4.2 Instrumentation Compilation (`:app:compileOssDebugAndroidTestKotlin`) +- **Result**: `BUILD SUCCESSFUL`. +- Includes `RouterMigrationTest.kt` and `BackupSerializationTest.kt`. + +### 4.3 Clean-Build ABI & Caller Gates +- `:app:verifyLibcore`: Passed (verified `classes.jar`, all 4 `libgojni.so` architectures, presence of `HTTPClient`, absence of `HttpClient`, and `newHttpClient` return type). +- `:app:verifyOssDebugLibcoreCallers`: Passed (verified compiled app caller bytecode references `libcore/HTTPClient` and contains zero `libcore/HttpClient` references). + +--- + +## 5. Lint Findings Classification (`:app:lintOssDebug`) + +Total findings reported: 79 errors (build abort threshold). + +All 79 findings were analyzed against the project changes: +1. **Dependency Versions** (~68 errors): Pre-existing Gradle dependency warnings flagging newer available library versions (e.g. `kotlinx-coroutines-android:1.7.3`, `androidx.room:2.8.4`, `androidx.core:1.19.0`). +2. **Pre-existing UI / Format Issues**: + - `AssetsActivity.kt:387`: `MissingSuperCall` on `onBackPressed()`. + - `KotlinUtil.kt:53-55`: `DefaultLocale` usage in string formatting. + - `StandardV2RayBean.java:101`: `DefaultLocale` in `toLowerCase()`. + - `JuicityFmt.kt:87-88`: `NewApi` on `java.util.Base64.getUrlEncoder` (minSdk 21). + - `themes.xml:117`: `NewApi` on `android:dialogCornerRadius`. + - `AssetsActivity.kt:367`: `SimpleDateFormat` without explicit locale. + - `BaseService.kt:413`: `UnspecifiedRegisterReceiverFlag` on legacy service broadcast receiver registration. +3. **Task Changes**: **0 lint errors** were introduced by the custom proxy group implementation or modified task files. + +--- + +## 6. Real-Device Acceptance Checklist (Unverified) + +Status: **Unverified** (no attached Android hardware). + +When a device is connected, run: +```powershell +.\gradlew.bat :app:installOssDebug +``` +And verify: +1. [ ] Upgrade install without clearing app data; verify database migration v10 opens cleanly. +2. [ ] Refresh subscription 1 and subscription 2; confirm no `HttpClient/HTTPClient` runtime crashes occur. +3. [ ] Create `US1` group selecting both subscriptions with a US include regex; observe live preview. +4. [ ] Create a second group (`US2`) containing at least one identical node; verify overlap. +5. [ ] In selector mode, change selected node in `US1`; verify `US2` and main proxy selector are untouched. +6. [ ] In URL-test mode, trigger latency test and verify reaching member is chosen. +7. [ ] Edit a route rule to explicitly target `US1`; start VPN and verify route resolution. +8. [ ] Refresh both subscriptions; confirm group configurations, route targets, and valid members persist. +9. [ ] Verify AdBlock, `.invalid` load rules, App routing, DNS/TUN, and ordinary profiles continue to operate normally. +10. [ ] Capture logs for any runtime errors. + +--- + +## 7. Scope and Protected Boundary Confirmation + +- `C:\Users\renos\Documents\Proxy\A7.yaml`: **Untouched** (Last modified 2026-09-02 19:37:23). +- `C:\Users\renos\Documents\Proxy\nekobox_isA8.json`: **Untouched** (Last modified 2026-09-02 21:52:42). +- Project External Files: **Zero modifications or deletions**. +- Feature Boundary: User-defined proxy groups only (`selector` & `url-test`); no predefined US/SG/JP groups, no automatic route inference, no YAML clash import, no fallback or load-balancing additions. From 37e95bbfefad464bdec618884fbc7d4f45ef6385 Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 17:21:55 +0800 Subject: [PATCH 05/29] fix: format urltest interval as duration string for sing-box --- app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt | 2 +- app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java | 2 +- .../io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 f38275474a..7e884b674d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -142,7 +142,7 @@ internal fun buildRouterOutbounds( tag = router.tag outbounds = router.outbounds url = router.filter.testUrl - interval = router.filter.intervalSeconds * 1_000_000_000L + interval = "${router.filter.intervalSeconds}s" tolerance = router.filter.toleranceMs } } diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java index 0cfb24a225..dc853beffe 100644 --- a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java +++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java @@ -4452,7 +4452,7 @@ public static class Outbound_URLTestOptions extends Outbound { public String url; - public Long interval; + public String interval; public Integer tolerance; diff --git a/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt b/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt index c64a7e94e4..b6e0e95344 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/fmt/RouterOutboundConfigTest.kt @@ -33,7 +33,7 @@ class RouterOutboundConfigTest { assertEquals("router.sg", urlTest.tag) assertEquals(listOf("sg-1"), urlTest.outbounds) assertEquals("https://example.com/204", urlTest.url) - assertEquals(120_000_000_000L, urlTest.interval) + assertEquals("120s", urlTest.interval) assertEquals(75, urlTest.tolerance) } From 9166e0da11f46a8b960c91634747ebe9bbefe384 Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 17:22:26 +0800 Subject: [PATCH 06/29] docs: update verification report with latest APK hashes after interval fix --- .../verification/2026-09-04-custom-proxy-groups.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/verification/2026-09-04-custom-proxy-groups.md b/docs/superpowers/verification/2026-09-04-custom-proxy-groups.md index 935cb7ad01..54511bac55 100644 --- a/docs/superpowers/verification/2026-09-04-custom-proxy-groups.md +++ b/docs/superpowers/verification/2026-09-04-custom-proxy-groups.md @@ -23,10 +23,10 @@ Generated by clean build without build cache: | Artifact | Size (Bytes) | SHA-256 | |---|---|---| -| `NekoBoxF-1.4.2-rev-24-arm64-v8a-debug.apk` | 23,487,101 | `D0B753E1D2066704C9B8F0AF6CD1F7D79D58A5D1D206513457E33D8D534CC658` | -| `NekoBoxF-1.4.2-rev-24-armeabi-v7a-debug.apk` | 23,628,566 | `03E0FB2F4FE3742A0AA3B0BE9C149262FD9907F707ED0B852E78F9E99CE3AE46` | -| `NekoBoxF-1.4.2-rev-24-x86-debug.apk` | 24,346,074 | `36AF2214A6B391FA1519FD12024FDD7A0FABD4410D0544BC217A90143E9719BF` | -| `NekoBoxF-1.4.2-rev-24-x86_64-debug.apk` | 24,222,449 | `1BD530C0CC27BB206FEA4FB29106D37612B896036FE6C5C955C06588115D136E` | +| `NekoBoxF-1.4.2-rev-24-arm64-v8a-debug.apk` | 23,554,911 | `0499048F4B75C996465F09987442BE020AFAF4EF86C8B55B9CDB13109E30E47D` | +| `NekoBoxF-1.4.2-rev-24-armeabi-v7a-debug.apk` | 23,696,376 | `81F15A67DA021480ACB773E333CB65B30C1DD9D9987432E64BAB1289BEFD4B8C` | +| `NekoBoxF-1.4.2-rev-24-x86-debug.apk` | 24,413,884 | `E8BACE2D913E1D48CAB729F68CC3BC9499CF922DC607E955108A00FC8A7AE42E` | +| `NekoBoxF-1.4.2-rev-24-x86_64-debug.apk` | 24,290,259 | `C2A264ADEBC02A95BF69208F6EE3C435CF8B55367A16FC110A97C92A0B9E9B0E` | ### APK ABI & Bytecode Verification From 9c688e6e154e41b98943b8d24c52aff2a9d3832b Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 19:15:13 +0800 Subject: [PATCH 07/29] feat: polish custom proxy group UI and runtime handling --- .gitignore | 5 ++ .../sagernet/database/GroupManager.kt | 16 ++++- .../database/RouterGroupRepository.kt | 21 ++++-- .../nekohasekai/sagernet/ui/GroupFragment.kt | 23 +++++- .../nekohasekai/sagernet/ui/MainActivity.kt | 5 ++ .../sagernet/ui/RouterGroupListFragment.kt | 13 ++++ .../ui/RouterGroupSettingsActivity.kt | 53 +++++++++++--- .../sagernet/utils/CrashHandler.kt | 2 +- .../res/drawable/ic_action_navigate_next.xml | 10 +++ app/src/main/res/layout/layout_group.xml | 71 +++++++++++++++---- app/src/main/res/menu/main_drawer_menu.xml | 5 ++ app/src/main/res/values-fa/strings.xml | 4 +- app/src/main/res/values-ru/strings.xml | 4 +- app/src/main/res/values-uk/strings.xml | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 5 ++ app/src/main/res/values/strings.xml | 9 ++- buildSrc/src/main/kotlin/Helpers.kt | 4 +- 17 files changed, 211 insertions(+), 43 deletions(-) create mode 100644 app/src/main/res/drawable/ic_action_navigate_next.xml diff --git a/.gitignore b/.gitignore index c9cec16fe6..fd79680ec5 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ jniLibs/ # submodules /external .hev-build/ + +# Local logs and test dumps +*.log +/nekobox_*.json +.superpowers/ 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 3c10ee439f..0d09b0aa77 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt @@ -172,12 +172,15 @@ object GroupManager { RouterNodeSnapshot( id = proxy.id, stableId = proxy.routerStableId(), - name = proxy.displayName(), + name = proxy.displayNameOrFallback(), subscriptionId = sourceGroups[proxy.groupId] ?.takeIf { it.type == GroupType.SUBSCRIPTION } ?.id, - available = proxy.error == null + enabled = true, + available = true, ) + }.onFailure { error -> + Logs.e("Failed to snapshot proxy ${proxy.id}", error) }.getOrNull() } @@ -286,7 +289,7 @@ object GroupManager { } -private fun ProxyEntity.routerStableId(): String { +internal fun ProxyEntity.routerStableId(): String { return routerStableIdOrFallback( uuid.takeIf { it.isNotBlank() } ?: runCatching { requireBean().routerStableIdentity() }.getOrNull(), @@ -294,6 +297,13 @@ private fun ProxyEntity.routerStableId(): String { ) } +internal fun ProxyEntity.displayNameOrFallback(): String = + runCatching { displayName() }.getOrNull() + ?.takeIf { it.isNotBlank() } + ?: runCatching { displayAddress() }.getOrNull()?.takeIf { it.isNotBlank() } + ?: uuid.takeIf { it.isNotBlank() } + ?: "Proxy $id" + internal fun AbstractBean.routerStableIdentity(): String { return clone().apply { name = "" diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt index 685bfb8685..b81c1edd7b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt @@ -8,6 +8,7 @@ import io.nekohasekai.sagernet.route.RouterMatcher import io.nekohasekai.sagernet.route.RouterNodeSnapshot import io.nekohasekai.sagernet.route.routerNodeKey import io.nekohasekai.sagernet.route.routerStableIdOrFallback +import io.nekohasekai.sagernet.ktx.Logs import java.net.URI import java.util.UUID @@ -101,19 +102,29 @@ object RouterGroupRepository { runCatching { RouterNodeSnapshot( id = proxy.id, - stableId = proxy.uuid.takeIf(String::isNotBlank), - name = proxy.displayName(), + stableId = proxy.routerStableId(), + name = proxy.displayNameOrFallback(), subscriptionId = sourceId, - available = proxy.error == null, + enabled = true, + available = true, ) + }.onFailure { error -> + Logs.e("Failed to snapshot proxy ${proxy.id}", error) }.getOrNull() } } + val subNames = runCatching { SagerDatabase.groupDao.allGroups().associate { it.id to it.displayName() } }.getOrDefault(emptyMap()) val ids = RouterMatcher.match( nodes, listOf(RouterMatchRequest(draft.id, sourceIds, draft.filter.validate())), )[draft.id].orEmpty() - val names = nodes.associateBy { it.id }.let { byId -> ids.mapNotNull { byId[it]?.name } } + val byId = nodes.associateBy { it.id } + val names = ids.mapNotNull { id -> + byId[id]?.let { node -> + val subName = node.subscriptionId?.let { subNames[it] } + if (!subName.isNullOrBlank()) "[${subName}] ${node.name.trim()}" else node.name.trim() + } + } return RouterGroupPreview(ids, names) } @@ -168,7 +179,7 @@ object RouterGroupRepository { } val proxy = SagerDatabase.proxyDao.getById(proxyId) ?: throw IllegalArgumentException("Selected node does not exist") - val stableId = routerStableIdOrFallback(proxy.uuid, proxy.id) + val stableId = proxy.routerStableId() val updated = group.copy( selectedProxyId = proxyId, selectedNodeKey = routerNodeKey(proxy.groupId, stableId), 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 a33bbd9d6e..718fa9d681 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt @@ -7,6 +7,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.Toolbar @@ -37,7 +38,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), lateinit var activity: MainActivity lateinit var groupListView: RecyclerView - lateinit var routerSection: LinearLayout + lateinit var routerSection: View lateinit var layoutManager: LinearLayoutManager lateinit var groupAdapter: GroupAdapter lateinit var undoManager: UndoSnackbarManager @@ -57,6 +58,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), routerSection.setOnClickListener { startActivity(Intent(requireContext(), RouterGroupListActivity::class.java)) } + updateRouterSection() layoutManager = FixedLinearLayoutManager(groupListView) groupListView.layoutManager = layoutManager groupAdapter = GroupAdapter() @@ -550,4 +552,23 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), } } + override fun onResume() { + super.onResume() + updateRouterSection() + } + + private fun updateRouterSection() { + val routerSubtitle = view?.findViewById(R.id.router_subtitle) ?: return + runOnDefaultDispatcher { + val count = runCatching { SagerDatabase.routerGroupDao.all().size }.getOrDefault(0) + onMainDispatcher { + if (count > 0) { + routerSubtitle.text = getString(R.string.router_groups_card_summary_with_count, count) + } else { + routerSubtitle.text = getString(R.string.router_groups_card_summary) + } + } + } + } + } 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 5b11495849..0035c40d00 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -361,6 +361,11 @@ class MainActivity : ThemedActivity(), } override fun onNavigationItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.nav_router_group) { + binding.drawerLayout.closeDrawers() + startActivity(Intent(this, RouterGroupListActivity::class.java)) + return false + } if (item.isChecked) binding.drawerLayout.closeDrawers() else { return displayFragmentWithId(item.itemId) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt index ff552d916a..1bac815b58 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt @@ -2,13 +2,18 @@ package io.nekohasekai.sagernet.ui import android.content.Intent import android.os.Bundle +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.RouterGroup import io.nekohasekai.sagernet.database.RouterGroupRepository import io.nekohasekai.sagernet.database.SagerDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class RouterGroupListFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) = rebuild() @@ -16,6 +21,14 @@ class RouterGroupListFragment : PreferenceFragmentCompat() { override fun onResume() { super.onResume() rebuild() + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + runCatching { + GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers()) + } + withContext(Dispatchers.Main) { + if (isAdded) rebuild() + } + } } private fun rebuild() { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt index 056a44af95..755eab89a0 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt @@ -22,6 +22,7 @@ import io.nekohasekai.sagernet.database.RouterGroupDraft import io.nekohasekai.sagernet.database.RouterGroupRepository import io.nekohasekai.sagernet.database.RouterGroupValidationException import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.database.displayNameOrFallback import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.route.RouterFilterConfig @@ -77,38 +78,59 @@ class RouterGroupSettingsActivity : ThemedActivity(R.layout.layout_settings_acti sourceOrder = subscriptions.map { it.id } val screen = preferenceManager.createPreferenceScreen(requireContext()) name = EditTextPreference(requireContext()).nonPersistent().apply { + key = "router_group_name" title = getString(R.string.router_group_name) text = group?.name.orEmpty() summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() } enabled = SwitchPreferenceCompat(requireContext()).nonPersistent().apply { + key = "router_group_enabled" title = getString(R.string.router_group_enabled) isChecked = group?.enabled ?: true } mode = ListPreference(requireContext()).nonPersistent().apply { + key = "router_group_mode" title = getString(R.string.router_group_mode) entries = arrayOf(getString(R.string.router_mode_manual), getString(R.string.router_mode_automatic)) entryValues = arrayOf(RouterGroup.MODE_SELECTOR.toString(), RouterGroup.MODE_URL_TEST.toString()) value = (group?.mode ?: RouterGroup.MODE_SELECTOR).toString() summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() } + val subMap = subscriptions.associate { it.id.toString() to it.displayName() } sources = MultiSelectListPreference(requireContext()).nonPersistent().apply { + key = "router_group_sources" title = getString(R.string.router_group_sources) entries = subscriptions.map { it.displayName() }.toTypedArray() entryValues = subscriptions.map { it.id.toString() }.toTypedArray() values = RouterGroupRepository.sourceIds(routerId).map(Long::toString).toSet() + summaryProvider = Preference.SummaryProvider { pref -> + val selectedNames = pref.values.mapNotNull { subMap[it] } + if (selectedNames.isEmpty()) { + getString(R.string.router_no_sources_selected) + } else { + selectedNames.joinToString(", ") + } + } + } + include = textPreference("router_group_include", R.string.router_group_include, filter.includeRegex) + exclude = textPreference("router_group_exclude", R.string.router_group_exclude, filter.excludeRegex) + urlCategory = PreferenceCategory(requireContext()).apply { + key = "router_category_url_test" + title = getString(R.string.router_url_test_settings) } - include = textPreference(R.string.router_group_include, filter.includeRegex) - exclude = textPreference(R.string.router_group_exclude, filter.excludeRegex) - urlCategory = PreferenceCategory(requireContext()).apply { title = getString(R.string.router_url_test_settings) } - testUrl = textPreference(R.string.router_test_url, filter.testUrl) - interval = textPreference(R.string.router_test_interval, filter.intervalSeconds.toString()) - tolerance = textPreference(R.string.router_test_tolerance, filter.toleranceMs.toString()) + testUrl = textPreference("router_test_url", R.string.router_test_url, filter.testUrl) + interval = textPreference("router_test_interval", R.string.router_test_interval, filter.intervalSeconds.toString()) + tolerance = textPreference("router_test_tolerance", R.string.router_test_tolerance, filter.toleranceMs.toString()) selected = ListPreference(requireContext()).nonPersistent().apply { + key = "router_select_node" title = getString(R.string.router_select_node) val members = SagerDatabase.routerMemberDao.getByRouter(routerId) .mapNotNull { SagerDatabase.proxyDao.getById(it.proxyId) } - entries = members.map { it.displayName() }.toTypedArray() + entries = members.map { proxy -> + val subName = subMap[proxy.groupId.toString()] + if (!subName.isNullOrBlank()) "[${subName}] ${proxy.displayNameOrFallback().trim()}" + else proxy.displayNameOrFallback().trim() + }.toTypedArray() entryValues = members.map { it.id.toString() }.toTypedArray() value = group?.selectedProxyId?.takeIf { it > 0 }?.toString() summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() @@ -123,6 +145,7 @@ class RouterGroupSettingsActivity : ThemedActivity(R.layout.layout_settings_acti } } preview = Preference(requireContext()).apply { + key = "router_group_preview" title = getString(R.string.router_group_preview) isSelectable = false } @@ -134,7 +157,11 @@ class RouterGroupSettingsActivity : ThemedActivity(R.layout.layout_settings_acti preferenceScreen = screen listOf(name, enabled, mode, sources, include, exclude, testUrl, interval, tolerance).forEach { preference -> - preference.setOnPreferenceChangeListener { _, _ -> + preference.setOnPreferenceChangeListener { _, newValue -> + if (preference == sources) { + @Suppress("UNCHECKED_CAST") + sources.values = newValue as? Set ?: emptySet() + } view?.post { updateDynamicState() } true } @@ -149,7 +176,12 @@ class RouterGroupSettingsActivity : ThemedActivity(R.layout.layout_settings_acti runCatching { RouterGroupRepository.preview(draft()) } .onSuccess { result -> preview.summary = if (result.names.isEmpty()) getString(R.string.router_no_members) - else getString(R.string.router_group_preview_count, result.names.size, result.names.take(8).joinToString("\n")) + else { + val maxDisplay = 20 + val previewText = result.names.take(maxDisplay).joinToString("\n") + val suffix = if (result.names.size > maxDisplay) "\n..." else "" + getString(R.string.router_group_preview_count, result.names.size, previewText + suffix) + } } .onFailure { preview.summary = it.message } } @@ -195,8 +227,9 @@ class RouterGroupSettingsActivity : ThemedActivity(R.layout.layout_settings_acti ), ) - private fun textPreference(titleRes: Int, initial: String) = + private fun textPreference(prefKey: String, titleRes: Int, initial: String) = EditTextPreference(requireContext()).nonPersistent().apply { + key = prefKey title = getString(titleRes) text = initial summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt index b660e5c696..2aa93fac7d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt @@ -62,7 +62,7 @@ object CrashHandler : Thread.UncaughtExceptionHandler { fun buildReportHeader(): String { var report = "" - report += "NekoBoxF for Android ${SagerNet.appVersionNameForDisplay} (${BuildConfig.VERSION_CODE})\n" + report += "NekoBox for Android ${SagerNet.appVersionNameForDisplay} (${BuildConfig.VERSION_CODE})\n" report += "Date: ${getCurrentMilliSecondUTCTimeStamp()}\n\n" report += "OS_VERSION: ${getSystemPropertyWithAndroidAPI("os.version")}\n" report += "SDK_INT: ${Build.VERSION.SDK_INT}\n" diff --git a/app/src/main/res/drawable/ic_action_navigate_next.xml b/app/src/main/res/drawable/ic_action_navigate_next.xml new file mode 100644 index 0000000000..02dc82bced --- /dev/null +++ b/app/src/main/res/drawable/ic_action_navigate_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/layout_group.xml b/app/src/main/res/layout/layout_group.xml index 87cf84c6b7..bc34bae8d6 100644 --- a/app/src/main/res/layout/layout_group.xml +++ b/app/src/main/res/layout/layout_group.xml @@ -14,26 +14,71 @@ android:layout_width="match_parent" android:layout_height="wrap_content" /> - - - - + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingStart="16dp" + android:paddingTop="12dp" + android:paddingEnd="16dp" + android:paddingBottom="12dp"> + + + + + + + + + + + + + + + + + - NekoBoxF - NekoBoxF for Android + NekoBox + NekoBox for Android مجموعه ابزار جامع پراکسی برای اندروید، نوشته‌شده با کاتلین. پروژه کد منبع diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4fc87af95e..b2f09e9c76 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -35,8 +35,8 @@ Всегда показывать адрес Всегда отображать адрес сервера на карте конфигурации Универсальный набор инструментов прокси для Android, написанный на Kotlin. -NekoBoxF -NekoBoxF для Android +NekoBox +NekoBox для Android Минимальная версия TLS протокола Версия Добавить HTTP-прокси к VPN diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 528e309782..400d693747 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,7 +1,7 @@ - NekoBoxF - NekoBoxF for Android + NekoBox + NekoBox for Android Універсальний набір інструментів проксі для Android, написаний на Kotlin. Проєкт Вихідний код diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2e834aa1c6..c9d3290bb0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -634,4 +634,9 @@ %1$s:%2$s 代理组 代理组引用无效 + 路由代理组 / 策略组 + 管理 URL-Test 自动优选与手动选择节点组 + 已配置 %d 个代理组 • 自动测速与手动切换 + 代理组 (策略组) + 未选择任何订阅 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a9cc75fa7..7a634b4c8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - NekoBoxF - NekoBoxF for Android + NekoBox + NekoBox for Android The universal proxy toolchain for Android, written in Kotlin. Project Source code @@ -79,6 +79,11 @@ %1$s: %2$s Proxy group Invalid proxy group reference + Proxy groups (Policy groups) + Manage URL-Test automatic latency & manual node groups + %d groups configured • URL-Test & Manual + Proxy groups + No subscriptions selected %s: No difference %s: Updated %d proxies Diff diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt index 64b2a96d0a..3561cdd1e1 100644 --- a/buildSrc/src/main/kotlin/Helpers.kt +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -197,10 +197,10 @@ fun Project.setupApp() { outputFileName = if (isPreview) { outputFileName.replace( project.name, - "NekoBoxF-" + requireMetadata().getProperty("PRE_VERSION_NAME") + "NekoBox-" + requireMetadata().getProperty("PRE_VERSION_NAME") ).replace("-preview", "") } else { - outputFileName.replace(project.name, "NekoBoxF-$versionName") + outputFileName.replace(project.name, "NekoBox-$versionName") .replace("-release", "") .replace("-oss", "") } From 13ccb44f3590460ba9aef2b3128a4e81fde556a2 Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 20:45:09 +0800 Subject: [PATCH 08/29] release: prepare NekoBox 1.4.4 --- .../java/io/nekohasekai/sagernet/Constants.kt | 2 + .../sagernet/bg/ServiceNotification.kt | 16 +- .../io/nekohasekai/sagernet/bg/TileService.kt | 6 +- .../sagernet/database/DataStore.kt | 2 + .../sagernet/ui/ConfigurationFragment.kt | 416 +++++++++++++----- .../nekohasekai/sagernet/ui/MainActivity.kt | 19 +- .../sagernet/ui/RouterGroupListFragment.kt | 97 +++- .../sagernet/ui/SettingsPreferenceFragment.kt | 1 + app/src/main/res/menu/add_profile_menu.xml | 6 + app/src/main/res/menu/main_drawer_menu.xml | 8 +- app/src/main/res/values-zh-rCN/strings.xml | 14 + app/src/main/res/values/strings.xml | 13 +- app/src/main/res/xml/global_preferences.xml | 5 + .../bg/NotificationTitlePolicyTest.kt | 75 ++++ nb4a.properties | 6 +- 15 files changed, 547 insertions(+), 139 deletions(-) create mode 100644 app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index f67f72d103..85b873e966 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -47,6 +47,8 @@ object Key { const val ALLOW_ACCESS = "allowAccess" const val SPEED_INTERVAL = "speedInterval" const val SHOW_DIRECT_SPEED = "showDirectSpeed" + const val SHOW_PROFILE_IN_NOTIFICATION = "showProfileInNotification" + const val VIEW_MODE_ROUTER_GROUPS = "viewModeRouterGroups" const val APPEND_HTTP_PROXY = "appendHttpProxy" const val HTTP_PROXY_BYPASS = "httpProxyBypass" diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt index 0ac84ac6a3..1d14afac77 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt @@ -46,10 +46,18 @@ class ServiceNotification( val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 - fun genTitle(ent: ProxyEntity): String { - val gn = if (DataStore.showGroupInNotification) - SagerDatabase.groupDao.getById(ent.groupId)?.displayName() else null - return if (gn == null) ent.displayName() else "[$gn] ${ent.displayName()}" + fun genTitle( + ent: ProxyEntity?, + showProfileInNotification: Boolean = DataStore.showProfileInNotification, + showGroupInNotification: Boolean = DataStore.showGroupInNotification, + groupNameProvider: (Long) -> String? = { SagerDatabase.groupDao.getById(it)?.displayName() }, + fallbackAppName: String = SagerNet.application.getString(R.string.app_name), + ): String { + if (ent == null || !showProfileInNotification) { + return fallbackAppName + } + val gn = if (showGroupInNotification) groupNameProvider(ent.groupId) else null + return if (gn.isNullOrBlank()) ent.displayName() else "[$gn] ${ent.displayName()}" } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt index dd46ee5cdf..6a9f100f76 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt @@ -6,6 +6,7 @@ import androidx.annotation.RequiresApi import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.ISagerNetService +import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.SagerDatabase import android.service.quicksettings.TileService as BaseTileService @@ -32,7 +33,8 @@ class TileService : BaseTileService(), SagerConnection.Callback { override fun cbSelectorUpdate(id: Long) { val profile = SagerDatabase.proxyDao.getById(id) ?: return - updateTile(BaseService.State.Connected, profile.displayName()) + val title = if (DataStore.showProfileInNotification) profile.displayName() else getString(R.string.app_name) + updateTile(BaseService.State.Connected, title) } override fun onStartListening() { @@ -61,7 +63,7 @@ class TileService : BaseTileService(), SagerConnection.Callback { BaseService.State.Connected -> { icon = iconConnected - label = profileName + label = if (DataStore.showProfileInNotification) profileName else getString(R.string.app_name) state = Tile.STATE_ACTIVE } 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 5d659486f1..2f1d82e54b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -118,6 +118,8 @@ object DataStore : OnPreferenceDataStoreChangeListener { var allowAccess by configurationStore.boolean(Key.ALLOW_ACCESS) var speedInterval by configurationStore.stringToInt(Key.SPEED_INTERVAL) + var showProfileInNotification by configurationStore.boolean(Key.SHOW_PROFILE_IN_NOTIFICATION) { true } + var viewModeRouterGroups by configurationStore.boolean(Key.VIEW_MODE_ROUTER_GROUPS) { true } var showGroupInNotification by configurationStore.boolean("showGroupInNotification") var globalCustomConfig by configurationStore.string(Key.GLOBAL_CUSTOM_CONFIG) { "" } 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 a6734cd9fa..2948990283 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -139,6 +139,8 @@ import java.util.zip.ZipInputStream import kotlin.collections.set import androidx.appcompat.app.AlertDialog import io.nekohasekai.sagernet.database.SubscriptionBean +import io.nekohasekai.sagernet.database.RouterGroup +import io.nekohasekai.sagernet.database.RouterGroupRepository import kotlin.math.abs class ConfigurationFragment @JvmOverloads constructor( @@ -260,6 +262,38 @@ class ConfigurationFragment @JvmOverloads constructor( private fun isSelectedProfile(profileId: Long) = selectedProxySnapshot == profileId + /** + * In router-group view mode each tab has its own selected proxy. We cache routerId->selectedProxyId + * so that [ConfigurationHolder.bindProfileState] can determine selection without a DB call. + */ + @Volatile + private var routerGroupSelectionSnapshot: Map = emptyMap() + + /** Returns true when [profileId] is the selected node inside [routerGroupId]. */ + fun isSelectedProfileInRouterGroup(routerGroupId: Long, profileId: Long): Boolean { + return routerGroupSelectionSnapshot[routerGroupId] == profileId + } + + /** Called from [selectProfileInRouterGroup] to refresh the snapshot after a selection change. */ + private fun updateRouterGroupSelectionSnapshot(routerGroupId: Long, proxyId: Long) { + routerGroupSelectionSnapshot = routerGroupSelectionSnapshot + (routerGroupId to proxyId) + } + + /** Refreshes the router-group selection snapshot from the DB on the background. */ + fun refreshRouterGroupSelections() { + runOnDefaultDispatcher { + val groups = RouterGroupRepository.all() + val snapshot = groups.associate { it.id to it.selectedProxyId } + onMainDispatcher { + routerGroupSelectionSnapshot = snapshot + // Trigger a UI refresh for all visible router-group pages + adapter.groupFragments.values.forEach { frag -> + frag.adapter?.notifyDataSetChanged() + } + } + } + } + private fun isCurrentProfile(profileId: Long) = currentProfileSnapshot == profileId private fun isCurrentGroupPagerAdapter(candidate: GroupPagerAdapter): Boolean { @@ -330,6 +364,13 @@ class ConfigurationFragment @JvmOverloads constructor( if (!select) { toolbar.inflateMenu(R.menu.add_profile_menu) toolbar.menu.findItem(R.id.action_global_mode)?.isChecked = DataStore.globalMode + // Set initial title to '代理' (Proxy) + toolbar.setTitle(R.string.route_proxy) + // Set initial label for the view-switch menu item + toolbar.menu.findItem(R.id.action_switch_group_view)?.setTitle( + if (DataStore.viewModeRouterGroups) R.string.switch_to_subscription_view + else R.string.switch_to_router_group_view + ) toolbar.setOnMenuItemClickListener(this) } else { toolbar.setTitle(titleRes) @@ -361,8 +402,15 @@ class ConfigurationFragment @JvmOverloads constructor( groupPager.offscreenPageLimit = 2 TabLayoutMediator(tabLayout, groupPager) { tab, position -> - if (adapter.groupList.size > position) { - tab.text = adapter.groupList[position].displayName() + val rgMode = adapter.inRouterGroupMode + if (rgMode) { + if (adapter.routerGroupList.size > position) { + tab.text = adapter.routerGroupList[position].name + } + } else { + if (adapter.groupList.size > position) { + tab.text = adapter.groupList[position].displayName() + } } tab.view.setOnLongClickListener { // clear toast true @@ -863,6 +911,32 @@ class ConfigurationFragment @JvmOverloads constructor( urlTest() } + R.id.action_switch_group_view -> { + // Toggle between router-groups view and subscription groups view + val hasRouterGroups = runCatching { + io.nekohasekai.sagernet.database.RouterGroupRepository.all().isNotEmpty() + }.getOrDefault(false) + if (!hasRouterGroups) { + snackbar(getString(R.string.router_empty_title)).show() + } else { + DataStore.viewModeRouterGroups = !DataStore.viewModeRouterGroups + // Update the menu item title to reflect the new state + val newTitle = if (DataStore.viewModeRouterGroups) { + R.string.switch_to_subscription_view + } else { + R.string.switch_to_router_group_view + } + toolbar.menu.findItem(R.id.action_switch_group_view)?.setTitle(newTitle) + adapter.reload(now = true) + } + return true + } + + R.id.action_manage_router_groups -> { + startActivity(android.content.Intent(requireContext(), RouterGroupListActivity::class.java)) + return true + } + R.id.action_global_mode -> { item.isChecked = !item.isChecked DataStore.globalMode = item.isChecked @@ -1211,6 +1285,13 @@ class ConfigurationFragment @JvmOverloads constructor( var selectedGroupIndex = 0 var groupList: ArrayList = ArrayList() + + /** Router groups shown when [inRouterGroupMode] is true. */ + var routerGroupList: ArrayList = ArrayList() + + /** True when the user has switched to the router-group view. */ + var inRouterGroupMode: Boolean = false + var groupFragments: HashMap = HashMap() private val reloadGeneration = AtomicLong() @@ -1222,58 +1303,107 @@ class ConfigurationFragment @JvmOverloads constructor( } runOnDefaultDispatcher { - var newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) - if (newGroupList.isEmpty()) { - SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) - newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) - } - newGroupList.find { it.ungrouped }?.let { - if (SagerDatabase.proxyDao.countByGroup(it.id) == 0L) { - newGroupList.remove(it) - } - } - - if (generation != reloadGeneration.get()) return@runOnDefaultDispatcher - - var selectedGroup = selectedItem?.groupId ?: DataStore.currentGroupId() - var newSelectedGroupIndex: Int? = null - if (selectedGroup > 0L) { - newSelectedGroupIndex = newGroupList.indexOfFirst { it.id == selectedGroup } - } else if (groupList.size == 1) { - selectedGroup = groupList[0].id - if (DataStore.selectedGroup != selectedGroup) { - DataStore.selectedGroup = selectedGroup - } - } - - val runFunc = if (now) activity?.let { it::runOnUiThread } else groupPager::post - if (runFunc != null) { - val reloadAdapter = this@GroupPagerAdapter - runFunc { - val viewOwner = viewLifecycleOwnerLiveData.value - if (generation == reloadGeneration.get() && viewOwner != null && - isCurrentGroupPagerAdapter(reloadAdapter) - ) { - viewOwner.lifecycleScope.launch(Dispatchers.Main.immediate) { - profileStateInitialized.await() - if (generation != reloadGeneration.get() || - viewLifecycleOwnerLiveData.value !== viewOwner || - !isCurrentGroupPagerAdapter(reloadAdapter) - ) { - return@launch - } - refreshProfileState() - newSelectedGroupIndex?.let { selectedGroupIndex = it } - groupList = newGroupList - notifyDataSetChanged() - if (newSelectedGroupIndex != null) { - groupPager.setCurrentItem(selectedGroupIndex, false) + // Determine view mode + val wantRouterMode = !select && DataStore.viewModeRouterGroups && + RouterGroupRepository.all().isNotEmpty() + + if (wantRouterMode) { + // ----- Router-group mode ----- + val newRouterList = ArrayList(RouterGroupRepository.all()) + + if (generation != reloadGeneration.get()) return@runOnDefaultDispatcher + + // Refresh selection snapshot for all router groups + val selectionSnapshot = newRouterList.associate { it.id to it.selectedProxyId } + + val runFunc = if (now) activity?.let { it::runOnUiThread } else groupPager::post + if (runFunc != null) { + val reloadAdapter = this@GroupPagerAdapter + runFunc { + val viewOwner = viewLifecycleOwnerLiveData.value + if (generation == reloadGeneration.get() && viewOwner != null && + isCurrentGroupPagerAdapter(reloadAdapter) + ) { + viewOwner.lifecycleScope.launch(Dispatchers.Main.immediate) { + profileStateInitialized.await() + if (generation != reloadGeneration.get() || + viewLifecycleOwnerLiveData.value !== viewOwner || + !isCurrentGroupPagerAdapter(reloadAdapter) + ) { + return@launch + } + routerGroupSelectionSnapshot = selectionSnapshot + inRouterGroupMode = true + routerGroupList = newRouterList + groupFragments.clear() + notifyDataSetChanged() + groupPager.setCurrentItem(0, false) + val hideTab = routerGroupList.size < 2 + tabLayout.isGone = hideTab + toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat() + // Refresh tab labels + tabLayout.invalidate() } - val hideTab = groupList.size < 2 - tabLayout.isGone = hideTab - toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat() - if (!select) { - groupPager.registerOnPageChangeCallback(updateSelectedCallback) + } + } + } + } else { + // ----- Normal proxy-group mode ----- + var newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) + if (newGroupList.isEmpty()) { + SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) + newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) + } + newGroupList.find { it.ungrouped }?.let { + if (SagerDatabase.proxyDao.countByGroup(it.id) == 0L) { + newGroupList.remove(it) + } + } + + if (generation != reloadGeneration.get()) return@runOnDefaultDispatcher + + var selectedGroup = selectedItem?.groupId ?: DataStore.currentGroupId() + var newSelectedGroupIndex: Int? = null + if (selectedGroup > 0L) { + newSelectedGroupIndex = newGroupList.indexOfFirst { it.id == selectedGroup } + } else if (groupList.size == 1) { + selectedGroup = groupList[0].id + if (DataStore.selectedGroup != selectedGroup) { + DataStore.selectedGroup = selectedGroup + } + } + + val runFunc = if (now) activity?.let { it::runOnUiThread } else groupPager::post + if (runFunc != null) { + val reloadAdapter = this@GroupPagerAdapter + runFunc { + val viewOwner = viewLifecycleOwnerLiveData.value + if (generation == reloadGeneration.get() && viewOwner != null && + isCurrentGroupPagerAdapter(reloadAdapter) + ) { + viewOwner.lifecycleScope.launch(Dispatchers.Main.immediate) { + profileStateInitialized.await() + if (generation != reloadGeneration.get() || + viewLifecycleOwnerLiveData.value !== viewOwner || + !isCurrentGroupPagerAdapter(reloadAdapter) + ) { + return@launch + } + refreshProfileState() + inRouterGroupMode = false + newSelectedGroupIndex?.let { selectedGroupIndex = it } + groupList = newGroupList + groupFragments.clear() + notifyDataSetChanged() + if (newSelectedGroupIndex != null) { + groupPager.setCurrentItem(selectedGroupIndex, false) + } + val hideTab = groupList.size < 2 + tabLayout.isGone = hideTab + toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat() + if (!select) { + groupPager.registerOnPageChangeCallback(updateSelectedCallback) + } } } } @@ -1287,28 +1417,48 @@ class ConfigurationFragment @JvmOverloads constructor( } override fun getItemCount(): Int { - return groupList.size + return if (inRouterGroupMode) routerGroupList.size else groupList.size } override fun createFragment(position: Int): Fragment { - return GroupFragment().apply { - proxyGroup = groupList[position] - groupFragments[proxyGroup.id] = this - if (position == selectedGroupIndex) { - selected = true + return if (inRouterGroupMode) { + GroupFragment().apply { + proxyGroup = ProxyGroup(ungrouped = true) // placeholder – not actually used in router mode + routerGroup = routerGroupList[position] + val key = -(routerGroupList[position].id) // negative to avoid collision with ProxyGroup ids + groupFragments[key] = this + selected = position == selectedGroupIndex + } + } else { + GroupFragment().apply { + proxyGroup = groupList[position] + groupFragments[proxyGroup.id] = this + if (position == selectedGroupIndex) { + selected = true + } } } } override fun getItemId(position: Int): Long { - return groupList[position].id + return if (inRouterGroupMode) { + // Use negative IDs for router groups to avoid collision with proxy group IDs + -(routerGroupList[position].id) + } else { + groupList[position].id + } } override fun containsItem(itemId: Long): Boolean { - return groupList.any { it.id == itemId } + return if (inRouterGroupMode) { + routerGroupList.any { -(it.id) == itemId } + } else { + groupList.any { it.id == itemId } + } } override suspend fun groupAdd(group: ProxyGroup) { + if (inRouterGroupMode) return tabLayout.post { groupList.add(group) @@ -1322,6 +1472,7 @@ class ConfigurationFragment @JvmOverloads constructor( } override suspend fun groupRemoved(groupId: Long) { + if (inRouterGroupMode) return val index = groupList.indexOfFirst { it.id == groupId } if (index == -1) return @@ -1332,6 +1483,7 @@ class ConfigurationFragment @JvmOverloads constructor( } override suspend fun groupUpdated(group: ProxyGroup) { + if (inRouterGroupMode) return val index = groupList.indexOfFirst { it.id == group.id } if (index == -1) return @@ -1343,6 +1495,7 @@ class ConfigurationFragment @JvmOverloads constructor( override suspend fun groupUpdated(groupId: Long) = Unit override suspend fun onAdd(profile: ProxyEntity) { + if (inRouterGroupMode) return if (groupList.find { it.id == profile.groupId } == null) { DataStore.selectedGroup = profile.groupId reload() @@ -1354,6 +1507,7 @@ class ConfigurationFragment @JvmOverloads constructor( override suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) = Unit override suspend fun onRemoved(groupId: Long, profileId: Long) { + if (inRouterGroupMode) return val group = groupList.find { it.id == groupId } ?: return if (group.ungrouped && SagerDatabase.proxyDao.countByGroup(groupId) == 0L) { reload() @@ -1364,6 +1518,12 @@ class ConfigurationFragment @JvmOverloads constructor( class GroupFragment : Fragment() { lateinit var proxyGroup: ProxyGroup + + /** Non-null when this fragment represents a tab in router-group view mode. */ + var routerGroup: RouterGroup? = null + + val inRouterGroupMode get() = routerGroup != null + var selected = false override fun onCreateView( @@ -1509,6 +1669,8 @@ class ConfigurationFragment @JvmOverloads constructor( fun checkOrderMenu() { if (select) return + // Sort/order menu doesn't apply in router-group mode + if (inRouterGroupMode) return val pf = requireParentFragment() as? ToolbarFragment ?: return val menu = pf.toolbar.menu @@ -2107,17 +2269,28 @@ class ConfigurationFragment @JvmOverloads constructor( } fun reloadProfiles() { - var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) - when (proxyGroup.order) { - GroupOrder.BY_NAME -> { - newProfiles = newProfiles.sortedBy { it.displayName() } - + val rg = routerGroup + val newProfiles: List + if (rg != null) { + // Router-group mode: load members from the router group + val memberIds = SagerDatabase.routerMemberDao.getByRouter(rg.id) + .sortedBy { it.userOrder } + .map { it.proxyId } + newProfiles = memberIds.mapNotNull { id -> + SagerDatabase.proxyDao.getById(id) } - - GroupOrder.BY_DELAY -> { - newProfiles = - newProfiles.sortedBy { if (it.status == 1) it.ping else 114514 } + } else { + // Normal proxy-group mode + var list = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) + when (proxyGroup.order) { + GroupOrder.BY_NAME -> { + list = list.sortedBy { it.displayName() } + } + GroupOrder.BY_DELAY -> { + list = list.sortedBy { if (it.status == 1) it.ping else 114514 } + } } + newProfiles = list } val newProfileMap = newProfiles.associateBy { it.id } @@ -2125,7 +2298,10 @@ class ConfigurationFragment @JvmOverloads constructor( var selectedProfileIndex = -1 - if (selected) { + if (rg != null) { + // In router-group mode, scroll to the currently selected node + selectedProfileIndex = newProfileIds.indexOf(rg.selectedProxyId) + } else if (selected) { val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy selectedProfileIndex = newProfileIds.indexOf(selectedProxy) } @@ -2240,36 +2416,65 @@ class ConfigurationFragment @JvmOverloads constructor( private fun selectProfile(proxyEntity: ProxyEntity) { val pf = parentFragment as? ConfigurationFragment ?: return - runOnDefaultDispatcher { - var update: Boolean - var lastSelected: Long - profileAccess.withLock { - update = DataStore.selectedProxy != proxyEntity.id - lastSelected = DataStore.selectedProxy - DataStore.selectedProxy = proxyEntity.id - onMainDispatcher { - pf.updateSelectedProxySnapshot(proxyEntity.id) + val rg = routerGroup + if (rg != null) { + // --- Router-group mode: select the node inside this router group --- + if (rg.mode != RouterGroup.MODE_SELECTOR) { + // URL_TEST groups auto-select; don't allow manual selection + return + } + runOnDefaultDispatcher { + try { + val updated = RouterGroupRepository.select(rg.id, proxyEntity.id) + // Update in-memory routerGroup reference + routerGroup = updated + onMainDispatcher { + pf.updateRouterGroupSelectionSnapshot(rg.id, proxyEntity.id) + adapter?.notifyDataSetChanged() + } + if (DataStore.serviceState.canStop) { + SagerNet.reloadService(routerTag = updated.stableTag, routerProxyId = proxyEntity.id) + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + snackbar(e.readableMessage).show() + } } } - - if (update) { - ProfileManager.postUpdate(lastSelected, noTraffic = true) - if (DataStore.serviceState.canStop && reloadAccess.tryLock()) { - SagerNet.reloadService() - reloadAccess.unlock() + } else { + // --- Normal proxy-group mode --- + runOnDefaultDispatcher { + var update: Boolean + var lastSelected: Long + profileAccess.withLock { + update = DataStore.selectedProxy != proxyEntity.id + lastSelected = DataStore.selectedProxy + DataStore.selectedProxy = proxyEntity.id + onMainDispatcher { + pf.updateSelectedProxySnapshot(proxyEntity.id) + } } - } else if (SagerNet.isTv) { - if (DataStore.serviceState.started) { - SagerNet.stopService() - } else { - SagerNet.startService() + + if (update) { + ProfileManager.postUpdate(lastSelected, noTraffic = true) + if (DataStore.serviceState.canStop && reloadAccess.tryLock()) { + SagerNet.reloadService() + reloadAccess.unlock() + } + } else if (SagerNet.isTv) { + if (DataStore.serviceState.started) { + SagerNet.stopService() + } else { + SagerNet.startService() + } } } } } private fun removeProfile(proxyEntity: ProxyEntity) { - if (select) return + if (select || inRouterGroupMode) return val currentAdapter = adapter ?: return val index = currentAdapter.configurationIdList.indexOf(proxyEntity.id) if (index < 0) return @@ -2422,16 +2627,17 @@ class ConfigurationFragment @JvmOverloads constructor( val selectOrChain = select || proxyEntity.type == ProxyEntity.TYPE_CHAIN val isDoubleColumn = layoutManager is FixedGridLayoutManager - + val isRgMode = inRouterGroupMode + if (isDoubleColumn) { editButton.isGone = true shareLayout.isGone = true removeButton.isGone = true - doubleColumnMenuButton.isVisible = true + doubleColumnMenuButton.isVisible = !isRgMode } else { - shareLayout.isGone = selectOrChain - editButton.isGone = select - removeButton.isGone = select + shareLayout.isGone = selectOrChain || isRgMode + editButton.isGone = select || isRgMode + removeButton.isGone = select || isRgMode doubleColumnMenuButton.isGone = true } @@ -2441,14 +2647,19 @@ class ConfigurationFragment @JvmOverloads constructor( } } - val selected = pf.isSelectedProfile(proxyEntity.id) + val rg = routerGroup + val selected = if (rg != null) { + pf.isSelectedProfileInRouterGroup(rg.id, proxyEntity.id) + } else { + pf.isSelectedProfile(proxyEntity.id) + } val started = selected && DataStore.serviceState.started && pf.isCurrentProfile(proxyEntity.id) editButton.isEnabled = !started removeButton.isEnabled = !started applySelected(selected) - if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { + if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN || isRgMode)) { shareLayer.setBackgroundColor(Color.TRANSPARENT) shareButton.setImageResource(R.drawable.ic_social_share) shareButton.setColorFilter(Color.GRAY) @@ -2466,7 +2677,12 @@ class ConfigurationFragment @JvmOverloads constructor( return } val pf = parentFragment as? ConfigurationFragment ?: return - val selected = pf.isSelectedProfile(proxyEntity.id) + val rg = routerGroup + val selected = if (rg != null) { + pf.isSelectedProfileInRouterGroup(rg.id, proxyEntity.id) + } else { + pf.isSelectedProfile(proxyEntity.id) + } val started = selected && DataStore.serviceState.started && pf.isCurrentProfile(proxyEntity.id) editButton.isEnabled = !started 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 0035c40d00..f1723045d7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -361,11 +361,6 @@ class MainActivity : ThemedActivity(), } override fun onNavigationItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.nav_router_group) { - binding.drawerLayout.closeDrawers() - startActivity(Intent(this, RouterGroupListActivity::class.java)) - return false - } if (item.isChecked) binding.drawerLayout.closeDrawers() else { return displayFragmentWithId(item.itemId) } @@ -424,6 +419,13 @@ class MainActivity : ThemedActivity(), fun displayFragmentWithId(@IdRes id: Int): Boolean { when (id) { + R.id.nav_router_group -> { + // Open the router group manager as an Activity + binding.drawerLayout.closeDrawers() + startActivity(Intent(this, RouterGroupListActivity::class.java)) + return true + } + R.id.nav_configuration -> { displayFragment(ConfigurationFragment()) } @@ -443,7 +445,7 @@ class MainActivity : ThemedActivity(), else -> return false } - navigation.menu.findItem(id).isChecked = true + navigation.menu.findItem(id)?.isChecked = true return true } @@ -525,6 +527,11 @@ class MainActivity : ThemedActivity(), showWhenConnected = DataStore.showBottomBar, animate = true, ) + Key.SHOW_PROFILE_IN_NOTIFICATION -> { + if (DataStore.serviceState.canStop) { + SagerNet.reloadService() + } + } Key.PROXY_APPS, Key.BYPASS_MODE, Key.INDIVIDUAL -> { if (DataStore.serviceState.canStop) { snackbar(getString(R.string.need_reload)).setAction(R.string.apply) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt index 1bac815b58..f5f1bd1452 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt @@ -6,10 +6,14 @@ import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat +import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.RouterGroup import io.nekohasekai.sagernet.database.RouterGroupRepository +import io.nekohasekai.sagernet.database.RouterMember import io.nekohasekai.sagernet.database.SagerDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -31,43 +35,98 @@ class RouterGroupListFragment : PreferenceFragmentCompat() { } } - private fun rebuild() { + fun rebuild() { val screen = preferenceManager.createPreferenceScreen(requireContext()) - screen.addPreference(Preference(requireContext()).apply { - title = getString(R.string.router_group_add) - summary = getString(R.string.router_group_add_summary) - setIcon(R.drawable.ic_action_note_add) - setOnPreferenceClickListener { - startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java)) - true + val groups = RouterGroupRepository.all() + + if (groups.isEmpty()) { + screen.addPreference(Preference(requireContext()).apply { + title = getString(R.string.router_empty_title) + summary = getString(R.string.router_empty_summary) + setIcon(R.drawable.ic_hardware_router) + setOnPreferenceClickListener { + startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java)) + true + } + }) + } else { + val category = PreferenceCategory(requireContext()).apply { + title = getString(R.string.router_groups_title) } - }) - val category = PreferenceCategory(requireContext()).apply { - title = getString(R.string.router_groups_title) + screen.addPreference(category) + groups.forEach { group -> category.addPreference(group.toPreference()) } } - screen.addPreference(category) - RouterGroupRepository.all().forEach { group -> category.addPreference(group.toPreference()) } + preferenceScreen = screen } - private fun RouterGroup.toPreference() = Preference(requireContext()).apply { + private fun RouterGroup.toPreference(): Preference = Preference(requireContext()).apply { title = name.ifBlank { stableTag } + setIcon(R.drawable.ic_hardware_router) val members = SagerDatabase.routerMemberDao.getByRouter(id) val modeName = getString( if (mode == RouterGroup.MODE_URL_TEST) R.string.router_mode_automatic else R.string.router_mode_manual ) - val state = when { + val selectedProxy = if (selectedProxyId > 0) SagerDatabase.proxyDao.getById(selectedProxyId) else null + val selectedName = selectedProxy?.displayName() ?: getString(R.string.router_no_selection) + + summary = when { !enabled -> getString(R.string.router_group_disabled) lastError.isNotBlank() -> lastError - else -> getString(R.string.router_status, modeName, members.size) + mode == RouterGroup.MODE_URL_TEST -> getString(R.string.router_status, modeName, members.size) + else -> "${getString(R.string.router_status, modeName, members.size)} • ${getString(R.string.router_current_node, selectedName)}" } - summary = state + setOnPreferenceClickListener { + if (mode == RouterGroup.MODE_SELECTOR && members.isNotEmpty()) { + showNodeSelectionDialog(this@toPreference, members) + } else { + startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java).apply { + putExtra(RouterGroupSettingsActivity.EXTRA_ROUTER_ID, id) + }) + } + true + } + } + + private fun showNodeSelectionDialog(group: RouterGroup, members: List) { + val proxies = SagerDatabase.proxyDao.getEntities(members.map { it.proxyId }) + val proxyMap = proxies.associateBy { it.id } + val orderedProxies = members.mapNotNull { proxyMap[it.proxyId] } + if (orderedProxies.isEmpty()) { startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java).apply { - putExtra(RouterGroupSettingsActivity.EXTRA_ROUTER_ID, id) + putExtra(RouterGroupSettingsActivity.EXTRA_ROUTER_ID, group.id) }) - true + return } + val items = orderedProxies.map { it.displayName() }.toTypedArray() + val currentIndex = orderedProxies.indexOfFirst { it.id == group.selectedProxyId } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(group.name.ifBlank { group.stableTag }) + .setSingleChoiceItems(items, currentIndex) { dialog, which -> + val chosen = orderedProxies[which] + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + runCatching { + RouterGroupRepository.select(group.id, chosen.id) + }.onSuccess { updated -> + if (DataStore.serviceState.started) { + SagerNet.reloadService(updated.stableTag, chosen.id) + } + } + withContext(Dispatchers.Main) { + rebuild() + } + } + dialog.dismiss() + } + .setNeutralButton(R.string.router_edit_group) { _, _ -> + startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java).apply { + putExtra(RouterGroupSettingsActivity.EXTRA_ROUTER_ID, group.id) + }) + } + .setNegativeButton(android.R.string.cancel, null) + .show() } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt index 659a54e3c0..40e61338c8 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt @@ -238,6 +238,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { } enableTLSFragment.onPreferenceChangeListener = reloadListener + findPreference(Key.SHOW_PROFILE_IN_NOTIFICATION)?.onPreferenceChangeListener = reloadListener // 恢复默认设置功能 val resetSettings = findPreference("resetSettings")!! diff --git a/app/src/main/res/menu/add_profile_menu.xml b/app/src/main/res/menu/add_profile_menu.xml index 78310fd63d..d4b594900d 100644 --- a/app/src/main/res/menu/add_profile_menu.xml +++ b/app/src/main/res/menu/add_profile_menu.xml @@ -95,6 +95,12 @@ android:title="" app:showAsAction="always"> + + diff --git a/app/src/main/res/menu/main_drawer_menu.xml b/app/src/main/res/menu/main_drawer_menu.xml index 227bd4a794..b452af9021 100644 --- a/app/src/main/res/menu/main_drawer_menu.xml +++ b/app/src/main/res/menu/main_drawer_menu.xml @@ -4,8 +4,8 @@ + android:icon="@drawable/ic_hardware_router" + android:title="@string/route_proxy" /> + android:icon="@drawable/ic_action_description" + android:title="@string/router_groups_title" /> 落地代理 协议版本 分享订阅 + 在通知中显示节点名称 + 关闭后通知仅显示应用名称,隐藏具体的节点信息 在通知中显示组名 重置连接 删除重复的服务器 @@ -613,6 +615,10 @@ 代理组 手动选择 自动测速 + %1$s | %2$d 个节点 + 当前:%s + 未选择节点 + 刷新 新建代理组 从一个或多个订阅中筛选并组合节点 代理组设置 @@ -639,4 +645,12 @@ 已配置 %d 个代理组 • 自动测速与手动切换 代理组 (策略组) 未选择任何订阅 + 切换节点 + 编辑分组 + 暂无代理组 + 点击右上角 + 创建出站代理组,支持自动测速与按规则分流 + 切换到订阅分组视图 + 切换到代理组视图 + 自动测速组将根据延迟自动选择最优节点 + 管理代理组 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7a634b4c8c..8555f4a1e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,7 +49,8 @@ Router groups Manual Automatic - %s | %d nodes + %1$s | %2$d nodes + Refresh Current: %s No current node Select node @@ -84,6 +85,14 @@ %d groups configured • URL-Test & Manual Proxy groups No subscriptions selected + Switch node + Edit group + No proxy groups + Tap + above to create a proxy group, enabling automatic latency testing and policy-based routing. + Switch to subscription view + Switch to proxy groups view + Automatic URL-test group selects the best node automatically + Manage proxy groups %s: No difference %s: Updated %d proxies Diff @@ -628,6 +637,8 @@ ShadowTLS Protocol Version Share Subscription + Show profile name in notification + When disabled, the notification only displays the app name, hiding specific node details Show group name in notification Reset Connections Remove duplicate servers diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index a4135321c5..d16c3be1e1 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -77,6 +77,11 @@ app:summary="@string/show_direct_speed_sum" app:title="@string/show_direct_speed" app:useSimpleSummaryProvider="true" /> + diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt new file mode 100644 index 0000000000..efd194fda1 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt @@ -0,0 +1,75 @@ +package io.nekohasekai.sagernet.bg + +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.fmt.http.HttpBean +import org.junit.Assert.assertEquals +import org.junit.Test + +class NotificationTitlePolicyTest { + + @Test + fun returnsAppNameWhenShowProfileIsDisabled() { + val proxy = ProxyEntity(id = 1L, groupId = 10L, userOrder = 0).apply { + putBean(HttpBean().apply { + serverAddress = "example.com" + name = "Sensitive Airport Node" + }) + } + val title = ServiceNotification.genTitle( + ent = proxy, + showProfileInNotification = false, + showGroupInNotification = true, + groupNameProvider = { "VIP Group" }, + fallbackAppName = "NekoBox", + ) + assertEquals("NekoBox", title) + } + + @Test + fun returnsNodeNameWhenShowGroupIsDisabled() { + val proxy = ProxyEntity(id = 1L, groupId = 10L, userOrder = 0).apply { + putBean(HttpBean().apply { + serverAddress = "example.com" + name = "Node A" + }) + } + val title = ServiceNotification.genTitle( + ent = proxy, + showProfileInNotification = true, + showGroupInNotification = false, + groupNameProvider = { "VIP Group" }, + fallbackAppName = "NekoBox", + ) + assertEquals("Node A", title) + } + + @Test + fun returnsGroupAndNodeNameWhenBothAreEnabled() { + val proxy = ProxyEntity(id = 1L, groupId = 10L, userOrder = 0).apply { + putBean(HttpBean().apply { + serverAddress = "example.com" + name = "Node A" + }) + } + val title = ServiceNotification.genTitle( + ent = proxy, + showProfileInNotification = true, + showGroupInNotification = true, + groupNameProvider = { "VIP Group" }, + fallbackAppName = "NekoBox", + ) + assertEquals("[VIP Group] Node A", title) + } + + @Test + fun returnsAppNameWhenProxyIsNull() { + val title = ServiceNotification.genTitle( + ent = null, + showProfileInNotification = true, + showGroupInNotification = true, + groupNameProvider = { "VIP Group" }, + fallbackAppName = "NekoBox", + ) + assertEquals("NekoBox", title) + } +} diff --git a/nb4a.properties b/nb4a.properties index 41db1139ce..4ba9a56b61 100644 --- a/nb4a.properties +++ b/nb4a.properties @@ -1,4 +1,4 @@ PACKAGE_NAME=com.nb4a -VERSION_NAME=1.4.2-rev-24 -PRE_VERSION_NAME=pre-1.4.2-20260214-1 -VERSION_CODE=46 +VERSION_NAME=1.4.4 +PRE_VERSION_NAME=pre-1.4.4-20260904-1 +VERSION_CODE=47 From 5e24366d2152cb15258a5eb19e54ae9f131400cf Mon Sep 17 00:00:00 2001 From: Gitefy Date: Fri, 4 Sep 2026 21:17:33 +0800 Subject: [PATCH 09/29] fix: route home proxy through urltest group --- .../java/io/nekohasekai/sagernet/SagerNet.kt | 10 +++-- .../sagernet/bg/ServiceNotification.kt | 14 +++++++ .../io/nekohasekai/sagernet/bg/VpnService.kt | 2 +- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 39 +++++++++++-------- .../sagernet/route/RouterRuntime.kt | 9 +++++ .../bg/NotificationTitlePolicyTest.kt | 11 ++++++ .../sagernet/route/RouterRuntimeTest.kt | 13 +++++++ 7 files changed, 78 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt index 0c1e751dbf..c0c96c85d9 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt @@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import go.Seq import io.nekohasekai.sagernet.bg.SagerConnection +import io.nekohasekai.sagernet.bg.ServiceNotification import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.isOss @@ -165,14 +166,17 @@ class SagerNet : Application(), fun updateNotificationChannels() { if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) { + val vpnNotificationPolicy = ServiceNotification.vpnNotificationChannelPolicy() notification.createNotificationChannels( listOf( NotificationChannel( - "service-vpn", + ServiceNotification.vpnNotificationChannel, application.getText(R.string.service_vpn), - if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN + if (Build.VERSION.SDK_INT >= 28) vpnNotificationPolicy.importance else NotificationManager.IMPORTANCE_LOW - ), // #1355 + ).apply { + setLockscreenVisibility(vpnNotificationPolicy.lockscreenVisibility) + }, // #1355 NotificationChannel( "service-proxy", application.getText(R.string.service_proxy), diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt index 1d14afac77..d30a9c92e6 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt @@ -1,5 +1,7 @@ package io.nekohasekai.sagernet.bg +import android.app.Notification +import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver @@ -43,9 +45,20 @@ class ServiceNotification( ) : BroadcastReceiver() { companion object { const val notificationId = 1 + const val vpnNotificationChannel = "service-vpn-hidden" val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + data class NotificationChannelPolicy( + val importance: Int, + val lockscreenVisibility: Int, + ) + + fun vpnNotificationChannelPolicy() = NotificationChannelPolicy( + importance = NotificationManager.IMPORTANCE_MIN, + lockscreenVisibility = Notification.VISIBILITY_SECRET, + ) + fun genTitle( ent: ProxyEntity?, showProfileInNotification: Boolean = DataStore.showProfileInNotification, @@ -128,6 +141,7 @@ class ServiceNotification( .setContentIntent(SagerNet.configureIntent(service)) .setSmallIcon(R.drawable.ic_service_active) .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(if (visible) NotificationCompat.VISIBILITY_PRIVATE else NotificationCompat.VISIBILITY_SECRET) .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN) private val buildLock = Mutex() diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt index a18da8ffcd..731b57a832 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt @@ -73,7 +73,7 @@ class VpnService : BaseVpnService(), override val data = BaseService.Data(this) override val tag = "SagerNetVpnService" override fun createNotification(profileName: String) = - ServiceNotification(this, profileName, "service-vpn") + ServiceNotification(this, profileName, ServiceNotification.vpnNotificationChannel) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (DataStore.serviceMode == Key.MODE_VPN) { 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 7e884b674d..4b3290b42a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -722,22 +722,23 @@ fun buildConfig( extraProxies.forEach { (key, p) -> tagMap[key] = buildChain(key, p) } + val runtimeRouterGroups = routerGroups.map { router -> + RouterRuntimeGroup( + stableTag = router.stableTag, + mode = if (router.mode == RouterGroup.MODE_URL_TEST) { + RouterRuntimeMode.URL_TEST + } else { + RouterRuntimeMode.SELECTOR + }, + memberProxyIds = routerMembers[router.id].orEmpty().map { it.proxyId }, + selectedProxyId = router.selectedProxyId, + id = router.id, + name = router.name, + filter = RouterFilterConfig.fromJson(router.matchConfig), + ) + } val routerOutbounds = buildRouterOutbounds( - routerGroups.map { router -> - RouterRuntimeGroup( - stableTag = router.stableTag, - mode = if (router.mode == RouterGroup.MODE_URL_TEST) { - RouterRuntimeMode.URL_TEST - } else { - RouterRuntimeMode.SELECTOR - }, - memberProxyIds = routerMembers[router.id].orEmpty().map { it.proxyId }, - selectedProxyId = router.selectedProxyId, - id = router.id, - name = router.name, - filter = RouterFilterConfig.fromJson(router.matchConfig), - ) - }, + runtimeRouterGroups, tagMap, reservedTags = routerReservedTags(outbounds), includeRouterGroups = includeRouterGroups @@ -760,7 +761,13 @@ fun buildConfig( router.stableTag to routerMembers[router.id].orEmpty().map { it.proxyId }.toSet() }.filterKeys(routerSelectorTags::containsKey) - val mainProxyTag = (if (buildSelector) TAG_PROXY else tagMap[proxy.id]) ?: TAG_PROXY + val mainProxyTag = if (buildSelector) { + RouterRuntime.findUrlTestGroupForProxy(runtimeRouterGroups, proxy.id) + ?.takeIf { it in builtRouterTags } + ?: TAG_PROXY + } else { + tagMap[proxy.id] ?: TAG_PROXY + } // 在应用用户规则之前检查全局模式 if (!forTest && DataStore.globalMode) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt index 24a0c34473..40697cd4e8 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt @@ -45,6 +45,15 @@ class RouterRuntimeException( * invalidate references to a Router group. */ object RouterRuntime { + fun findUrlTestGroupForProxy( + groups: Iterable, + selectedProxyId: Long, + ): String? = groups.firstOrNull { + it.mode == RouterRuntimeMode.URL_TEST && + it.stableTag.isNotBlank() && + selectedProxyId in it.memberProxyIds + }?.stableTag + fun build( groups: Iterable, proxyTags: Map, diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt index efd194fda1..1453346549 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt @@ -2,11 +2,22 @@ package io.nekohasekai.sagernet.bg import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.http.HttpBean +import android.app.Notification +import android.app.NotificationManager import org.junit.Assert.assertEquals import org.junit.Test class NotificationTitlePolicyTest { + @Test + fun vpnNotificationIsHiddenFromLockScreen() { + val policy = ServiceNotification.vpnNotificationChannelPolicy() + + assertEquals("service-vpn-hidden", ServiceNotification.vpnNotificationChannel) + assertEquals(NotificationManager.IMPORTANCE_MIN, policy.importance) + assertEquals(Notification.VISIBILITY_SECRET, policy.lockscreenVisibility) + } + @Test fun returnsAppNameWhenShowProfileIsDisabled() { val proxy = ProxyEntity(id = 1L, groupId = 10L, userOrder = 0).apply { diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt index 35b19e0ffd..5b656f3e05 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeTest.kt @@ -69,4 +69,17 @@ class RouterRuntimeTest { assertEquals(listOf("router.us"), outbounds.map { it.tag }) } + + @Test + fun findsUrlTestGroupForTheSelectedHomeProxy() { + val tag = RouterRuntime.findUrlTestGroupForProxy( + groups = listOf( + RouterRuntimeGroup("router.manual", RouterRuntimeMode.SELECTOR, listOf(1), 1), + RouterRuntimeGroup("router.web3", RouterRuntimeMode.URL_TEST, listOf(2, 3), -1), + ), + selectedProxyId = 3L, + ) + + assertEquals("router.web3", tag) + } } From d3f1d07d6c8f7a758371c7d428fce2240ce450f8 Mon Sep 17 00:00:00 2001 From: Gitefy Date: Sat, 5 Sep 2026 10:12:44 +0800 Subject: [PATCH 10/29] release: prepare NekoBox 1.4.5 --- .../sagernet/aidl/ISagerNetService.aidl | 1 + .../sagernet/aidl/SpeedDisplayData.kt | 3 ++ .../io/nekohasekai/sagernet/bg/BaseService.kt | 10 ++++ .../sagernet/bg/ServiceNotification.kt | 17 +++++-- .../sagernet/bg/proto/BoxInstance.kt | 10 ++++ .../sagernet/bg/proto/TrafficLoopPolicy.kt | 20 ++++++++ .../sagernet/bg/proto/TrafficLooper.kt | 44 ++++++++++++++--- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 20 +++++--- .../sagernet/route/RouterRuntime.kt | 28 +++++++++++ .../sagernet/ui/ConfigurationFragment.kt | 43 ++++++++++++++-- .../nekohasekai/sagernet/ui/MainActivity.kt | 32 ++++++++++-- .../bg/NotificationTitlePolicyTest.kt | 8 +++ .../bg/proto/TrafficLoopPolicyTest.kt | 49 +++++++++++++++++++ .../route/RouterRuntimeSelectionTest.kt | 38 ++++++++++++++ libcore/box.go | 28 +++++++++++ nb4a.properties | 6 +-- 16 files changed, 329 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicyTest.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeSelectionTest.kt diff --git a/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetService.aidl b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetService.aidl index ae0afcb6b6..086d9a6b7a 100644 --- a/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetService.aidl +++ b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetService.aidl @@ -5,6 +5,7 @@ import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback; interface ISagerNetService { int getState(); String getProfileName(); + long[] getCurrentUrlTestSelections(); void registerCallback(in ISagerNetServiceCallback cb, int id); oneway void unregisterCallback(in ISagerNetServiceCallback cb); diff --git a/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt b/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt index 5cafbf33e7..e5baf504fa 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt @@ -15,4 +15,7 @@ data class SpeedDisplayData( // Outbound "bypass" usage is not counted var txTotal: Long = 0L, var rxTotal: Long = 0L, + + // Runtime-only Router URL_TEST selections as routerId/profileId pairs. + var urlTestSelections: LongArray = longArrayOf(), ) : Parcelable 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 2af106e676..00cb5e5904 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -115,6 +115,8 @@ class BaseService { override fun getState(): Int = (data?.state ?: State.Idle).ordinal override fun getProfileName(): String = data?.proxy?.displayProfileName ?: "Idle" + override fun getCurrentUrlTestSelections(): LongArray = + data?.proxy?.currentUrlTestSelections() ?: longArrayOf() override fun registerCallback(cb: ISagerNetServiceCallback, id: Int) { if (id == SagerConnection.CONNECTION_ID_RESTART_BG) { @@ -352,6 +354,14 @@ class BaseService { if (DataStore.networkChangeResetConnections) { Libcore.resetAllConnections(true) } + val runningProxy = data.proxy + if (runningProxy?.isInitialized() == true) { + data.binder.launch(Dispatchers.IO) { + runningProxy.config.routerUrlTestTags.values.distinct().forEach { + runningProxy.box.refreshURLTestFor(it) + } + } + } } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt index d30a9c92e6..fbed78752d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED import android.os.Build +import android.os.PowerManager import android.text.format.Formatter import android.widget.Toast import androidx.core.app.NotificationCompat @@ -41,7 +42,7 @@ import kotlinx.coroutines.sync.withLock */ class ServiceNotification( private val service: BaseService.Interface, title: String, - channel: String, visible: Boolean = false, + channel: String, private val visible: Boolean = false, ) : BroadcastReceiver() { companion object { const val notificationId = 1 @@ -59,6 +60,9 @@ class ServiceNotification( lockscreenVisibility = Notification.VISIBILITY_SECRET, ) + fun shouldPostSpeed(visible: Boolean, interactive: Boolean): Boolean = + visible && interactive + fun genTitle( ent: ProxyEntity?, showProfileInNotification: Boolean = DataStore.showProfileInNotification, @@ -74,7 +78,11 @@ class ServiceNotification( } } - var listenPostSpeed = true + @Volatile + var listenPostSpeed = shouldPostSpeed( + visible, + ((service as Context).getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive, + ) suspend fun postNotificationSpeedUpdate(stats: SpeedDisplayData) { useBuilder { @@ -201,7 +209,10 @@ class ServiceNotification( override fun onReceive(context: Context, intent: Intent) { if (service.data.state == BaseService.State.Connected) { - listenPostSpeed = intent.action == Intent.ACTION_SCREEN_ON + listenPostSpeed = shouldPostSpeed( + visible, + intent.action == Intent.ACTION_SCREEN_ON, + ) } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index dc60d5efdf..071fd9d879 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -18,6 +18,7 @@ import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager +import io.nekohasekai.sagernet.route.RouterRuntimeSelection import kotlinx.coroutines.* import libcore.BoxInstance import libcore.Libcore @@ -40,6 +41,15 @@ abstract class BoxInstance( return ::config.isInitialized && ::box.isInitialized } + fun currentUrlTestSelections(): LongArray { + if (!isInitialized()) return longArrayOf() + return RouterRuntimeSelection.resolve( + routerTags = config.routerUrlTestTags, + profileTags = config.profileTagMap, + currentOutbound = box::currentOutboundFor, + ) + } + protected fun initPlugin(name: String): PluginManager.InitResult { return pluginPath.getOrPut(name) { PluginManager.init(name)!! } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt new file mode 100644 index 0000000000..9881ae7f37 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt @@ -0,0 +1,20 @@ +package io.nekohasekai.sagernet.bg.proto + +object TrafficLoopPolicy { + private const val MIN_BACKGROUND_NOTIFICATION_MILLIS = 5_000L + private const val MIN_BACKGROUND_HIDDEN_MILLIS = 30_000L + private const val MIN_INITIALIZATION_RETRY_MILLIS = 250L + + fun delayMillis( + configuredMillis: Long, + mainActivityForeground: Boolean, + notificationSpeedVisible: Boolean, + ): Long = when { + mainActivityForeground -> configuredMillis + notificationSpeedVisible -> maxOf(configuredMillis, MIN_BACKGROUND_NOTIFICATION_MILLIS) + else -> maxOf(configuredMillis, MIN_BACKGROUND_HIDDEN_MILLIS) + } + + fun initializationRetryMillis(configuredMillis: Long): Long = + maxOf(configuredMillis, MIN_INITIALIZATION_RETRY_MILLIS) +} 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 48f8744265..635d315719 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 @@ -85,7 +85,7 @@ class TrafficLooper selectMainLocked(id) } - private suspend fun selectMainLocked(id: Long) { + private suspend fun selectMainLocked(id: Long, statsTag: String = TAG_PROXY) { Logs.d("select traffic count $TAG_PROXY to $id, old id is $selectorNowId") val oldData = idMap[selectorNowId] val newData = idMap[id] ?: return @@ -104,11 +104,22 @@ class TrafficLooper selectorNowFakeTag = newData.tag selectorNowId = id newData.apply { - tag = TAG_PROXY + tag = statsTag ignore = false } } + private suspend fun syncUrlTestWinnerLocked(proxy: ProxyInstance): LongArray { + val selections = proxy.currentUrlTestSelections() + val mainTag = proxy.config.mainUrlTestTag ?: return selections + val mainRouterId = proxy.config.routerUrlTestTags.entries + .firstOrNull { it.value == mainTag }?.key ?: return selections + val selectionMap = io.nekohasekai.sagernet.route.RouterRuntimeSelection.toMap(selections) + val winnerId = selectionMap[mainRouterId] ?: return selections + if (winnerId != selectorNowId) selectMainLocked(winnerId, mainTag) + return selections + } + suspend fun resetTraffic(profileIds: LongArray) { val targetIds = profileIds.asSequence().filter { it > 0L }.toHashSet() if (targetIds.isEmpty()) return @@ -171,7 +182,10 @@ class TrafficLooper delay(delayMs) continue } - if (!proxy.isInitialized()) continue + if (!proxy.isInitialized()) { + delay(TrafficLoopPolicy.initializationRetryMillis(delayMs)) + continue + } val snapshot = withStateLock { if (trafficUpdater == null) { @@ -179,6 +193,8 @@ class TrafficLooper idMap[-1] = itemBypass // val tags = hashSetOf(TAG_PROXY, TAG_BYPASS) + val dynamicMain = proxy.config.selectorGroupId >= 0L || + proxy.config.mainUrlTestTag != null proxy.config.trafficMap.forEach { (tag, ents) -> tags.add(tag) for (ent in ents) { @@ -188,23 +204,27 @@ class TrafficLooper tx = ent.tx, rxBase = ent.rx, txBase = ent.tx, - ignore = proxy.config.selectorGroupId >= 0L, + ignore = dynamicMain, ) idMap[ent.id] = item tagMap[tag] = item Logs.d("traffic count $tag to ${ent.id}") } } - if (proxy.config.selectorGroupId >= 0L) { + if (proxy.config.mainUrlTestTag != null) { + syncUrlTestWinnerLocked(proxy) + } else if (proxy.config.selectorGroupId >= 0L) { selectMainLocked(proxy.config.mainEntId) } // trafficUpdater = TrafficUpdater( box = proxy.box, items = idMap.values.toList() ) + proxy.config.mainUrlTestTag?.let(tags::add) proxy.box.setV2rayStats(tags.joinToString("\n")) } + val urlTestSelections = syncUrlTestWinnerLocked(proxy) trafficUpdater!!.updateAll() currentCoroutineContext().ensureActive() @@ -237,7 +257,8 @@ class TrafficLooper if (showDirectSpeed) itemBypass.txRate else 0L, if (showDirectSpeed) itemBypass.rxRate else 0L, mainTx, - mainRx + mainRx, + urlTestSelections, ), trafficUpdates = trafficUpdates, ) @@ -268,7 +289,16 @@ class TrafficLooper if (listenPostSpeed) postNotificationSpeedUpdate(snapshot.speed) } - delay(delayMs) + val mainActivityForeground = data.binder.callbackIdMap.containsValue( + SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND + ) + delay( + TrafficLoopPolicy.delayMillis( + configuredMillis = delayMs, + mainActivityForeground = mainActivityForeground, + notificationSpeedVisible = data.notification?.listenPostSpeed == true, + ) + ) } } } 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 4b3290b42a..e4f5354b28 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -158,6 +158,8 @@ class ConfigBuildResult( val selectorGroupId: Long, val routerSelectorTags: Map = emptyMap(), val routerMemberIds: Map> = emptyMap(), + val routerUrlTestTags: Map = emptyMap(), + val mainUrlTestTag: String? = null, ) { data class IndexEntity(var chain: LinkedHashMap) } @@ -317,6 +319,8 @@ fun buildConfig( var routerSelectorTags: Map = emptyMap() var routerMemberIds: Map> = emptyMap() + var routerUrlTestTags: Map = emptyMap() + var mainUrlTestTag: String? = null return MyOptions().apply { if (!forTest) { @@ -761,13 +765,13 @@ fun buildConfig( router.stableTag to routerMembers[router.id].orEmpty().map { it.proxyId }.toSet() }.filterKeys(routerSelectorTags::containsKey) - val mainProxyTag = if (buildSelector) { - RouterRuntime.findUrlTestGroupForProxy(runtimeRouterGroups, proxy.id) - ?.takeIf { it in builtRouterTags } - ?: TAG_PROXY - } else { - tagMap[proxy.id] ?: TAG_PROXY - } + routerUrlTestTags = runtimeRouterGroups.filter { + it.mode == RouterRuntimeMode.URL_TEST && it.stableTag in builtRouterTags + }.associate { it.id to it.stableTag } + + mainUrlTestTag = RouterRuntime.findUrlTestGroupForProxy(runtimeRouterGroups, proxy.id) + ?.takeIf { it in builtRouterTags } + val mainProxyTag = mainUrlTestTag ?: if (buildSelector) TAG_PROXY else tagMap[proxy.id] ?: TAG_PROXY // 在应用用户规则之前检查全局模式 if (!forTest && DataStore.globalMode) { @@ -1214,6 +1218,8 @@ fun buildConfig( if (buildSelector) group.id else -1L, routerSelectorTags, routerMemberIds, + routerUrlTestTags, + mainUrlTestTag, ) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt index 40697cd4e8..d77b050cf2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt @@ -77,3 +77,31 @@ object RouterRuntime { } } } + +object RouterRuntimeSelection { + fun resolve( + routerTags: Map, + profileTags: Map, + currentOutbound: (String) -> String, + ): LongArray { + if (routerTags.isEmpty() || profileTags.isEmpty()) return longArrayOf() + val profileIdsByTag = profileTags.entries.associate { (id, tag) -> tag to id } + val result = ArrayList(routerTags.size * 2) + routerTags.forEach { (routerId, routerTag) -> + val profileId = profileIdsByTag[currentOutbound(routerTag)] ?: return@forEach + result += routerId + result += profileId + } + return result.toLongArray() + } + + fun toMap(pairs: LongArray): Map { + val result = linkedMapOf() + var index = 0 + while (index + 1 < pairs.size) { + result[pairs[index]] = pairs[index + 1] + index += 2 + } + return result + } +} 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 2948990283..9838205ec4 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -141,6 +141,7 @@ import androidx.appcompat.app.AlertDialog import io.nekohasekai.sagernet.database.SubscriptionBean import io.nekohasekai.sagernet.database.RouterGroup import io.nekohasekai.sagernet.database.RouterGroupRepository +import io.nekohasekai.sagernet.route.RouterRuntimeSelection import kotlin.math.abs class ConfigurationFragment @JvmOverloads constructor( @@ -269,9 +270,38 @@ class ConfigurationFragment @JvmOverloads constructor( @Volatile private var routerGroupSelectionSnapshot: Map = emptyMap() + @Volatile + private var routerGroupRuntimeSelectionSnapshot: Map = emptyMap() + /** Returns true when [profileId] is the selected node inside [routerGroupId]. */ - fun isSelectedProfileInRouterGroup(routerGroupId: Long, profileId: Long): Boolean { - return routerGroupSelectionSnapshot[routerGroupId] == profileId + fun isSelectedProfileInRouterGroup(routerGroup: RouterGroup, profileId: Long): Boolean { + val selectedId = if (routerGroup.mode == RouterGroup.MODE_URL_TEST) { + routerGroupRuntimeSelectionSnapshot[routerGroup.id] + } else { + routerGroupSelectionSnapshot[routerGroup.id] + } + return selectedId == profileId + } + + private fun selectedProfileInRouterGroup(routerGroup: RouterGroup): Long? { + return if (routerGroup.mode == RouterGroup.MODE_URL_TEST) { + routerGroupRuntimeSelectionSnapshot[routerGroup.id] + } else { + routerGroupSelectionSnapshot[routerGroup.id] + } + } + + fun updateRuntimeUrlTestSelections(pairs: LongArray) { + val next = RouterRuntimeSelection.toMap(pairs) + if (next == routerGroupRuntimeSelectionSnapshot) return + val changedIds = (routerGroupRuntimeSelectionSnapshot.values + next.values) + .filter { it > 0L } + .toSet() + routerGroupRuntimeSelectionSnapshot = next + if (!::adapter.isInitialized) return + adapter.groupFragments.values.forEach { fragment -> + fragment.adapter?.refreshProfileState(changedIds) + } } /** Called from [selectProfileInRouterGroup] to refresh the snapshot after a selection change. */ @@ -2300,7 +2330,10 @@ class ConfigurationFragment @JvmOverloads constructor( if (rg != null) { // In router-group mode, scroll to the currently selected node - selectedProfileIndex = newProfileIds.indexOf(rg.selectedProxyId) + selectedProfileIndex = newProfileIds.indexOf( + (parentFragment as? ConfigurationFragment) + ?.selectedProfileInRouterGroup(rg) + ) } else if (selected) { val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy selectedProfileIndex = newProfileIds.indexOf(selectedProxy) @@ -2649,7 +2682,7 @@ class ConfigurationFragment @JvmOverloads constructor( val rg = routerGroup val selected = if (rg != null) { - pf.isSelectedProfileInRouterGroup(rg.id, proxyEntity.id) + pf.isSelectedProfileInRouterGroup(rg, proxyEntity.id) } else { pf.isSelectedProfile(proxyEntity.id) } @@ -2679,7 +2712,7 @@ class ConfigurationFragment @JvmOverloads constructor( val pf = parentFragment as? ConfigurationFragment ?: return val rg = routerGroup val selected = if (rg != null) { - pf.isSelectedProfileInRouterGroup(rg.id, proxyEntity.id) + pf.isSelectedProfileInRouterGroup(rg, proxyEntity.id) } else { pf.isSelectedProfile(proxyEntity.id) } 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 f1723045d7..2bf664e482 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -371,6 +371,9 @@ class MainActivity : ThemedActivity(), @SuppressLint("CommitTransaction") fun displayFragment(fragment: ToolbarFragment) { currentMainFragment = fragment + (fragment as? ConfigurationFragment)?.updateRuntimeUrlTestSelections( + runtimeUrlTestSelections + ) supportFragmentManager.beginTransaction() .replace(R.id.fragment_holder, fragment) .commitAllowingStateLoss() @@ -456,6 +459,9 @@ class MainActivity : ThemedActivity(), animateControls: Boolean = animate, ) { DataStore.serviceState = state + if (state != BaseService.State.Connected) { + updateRuntimeUrlTestSelections(longArrayOf()) + } refreshConfigurationProfileState() binding.fab.changeState(state, DataStore.serviceState, animate) @@ -481,13 +487,23 @@ class MainActivity : ThemedActivity(), } val connection = SagerConnection(SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND, true) - override fun onServiceConnected(service: ISagerNetService) = changeState( - try { + override fun onServiceConnected(service: ISagerNetService) { + val state = try { BaseService.State.values()[service.state] } catch (_: RemoteException) { BaseService.State.Idle } - ) + changeState(state) + updateRuntimeUrlTestSelections( + if (state == BaseService.State.Connected) { + runCatching { service.currentUrlTestSelections }.getOrDefault(longArrayOf()) + } else { + longArrayOf() + } + ) + } + + private var runtimeUrlTestSelections = longArrayOf() override fun onServiceDisconnected() = changeState(BaseService.State.Idle) override fun onBinderDied() { @@ -503,6 +519,16 @@ class MainActivity : ThemedActivity(), // ONLY do UI update here, write DB in bg process override fun cbSpeedUpdate(stats: SpeedDisplayData) { binding.stats.updateSpeed(stats.txRateProxy, stats.rxRateProxy) + updateRuntimeUrlTestSelections(stats.urlTestSelections) + } + + private fun updateRuntimeUrlTestSelections(selections: LongArray) { + runtimeUrlTestSelections = selections.copyOf() + val fragment = currentMainFragment + ?: supportFragmentManager.findFragmentById(R.id.fragment_holder) + (fragment as? ConfigurationFragment)?.updateRuntimeUrlTestSelections( + runtimeUrlTestSelections + ) } override suspend fun cbTrafficUpdate(data: TrafficDataBatch) { diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt index 1453346549..8a690ec65d 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt @@ -18,6 +18,14 @@ class NotificationTitlePolicyTest { assertEquals(Notification.VISIBILITY_SECRET, policy.lockscreenVisibility) } + @Test + fun hiddenVpnNotificationNeverRequestsSpeedUpdates() { + assertEquals(false, ServiceNotification.shouldPostSpeed(false, true)) + assertEquals(false, ServiceNotification.shouldPostSpeed(false, false)) + assertEquals(true, ServiceNotification.shouldPostSpeed(true, true)) + assertEquals(false, ServiceNotification.shouldPostSpeed(true, false)) + } + @Test fun returnsAppNameWhenShowProfileIsDisabled() { val proxy = ProxyEntity(id = 1L, groupId = 10L, userOrder = 0).apply { diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicyTest.kt new file mode 100644 index 0000000000..e6ffa7c7e6 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicyTest.kt @@ -0,0 +1,49 @@ +package io.nekohasekai.sagernet.bg.proto + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TrafficLoopPolicyTest { + + @Test + fun keepsConfiguredRefreshRateWhileHomePageIsVisible() { + assertEquals( + 1_000L, + TrafficLoopPolicy.delayMillis( + configuredMillis = 1_000L, + mainActivityForeground = true, + notificationSpeedVisible = false, + ), + ) + } + + @Test + fun limitsVisibleBackgroundNotificationRefreshToFiveSeconds() { + assertEquals( + 5_000L, + TrafficLoopPolicy.delayMillis( + configuredMillis = 1_000L, + mainActivityForeground = false, + notificationSpeedVisible = true, + ), + ) + } + + @Test + fun throttlesHiddenBackgroundStatisticsToThirtySeconds() { + assertEquals( + 30_000L, + TrafficLoopPolicy.delayMillis( + configuredMillis = 1_000L, + mainActivityForeground = false, + notificationSpeedVisible = false, + ), + ) + } + + @Test + fun initializationRetryNeverBusySpins() { + assertEquals(250L, TrafficLoopPolicy.initializationRetryMillis(0L)) + assertEquals(1_000L, TrafficLoopPolicy.initializationRetryMillis(1_000L)) + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeSelectionTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeSelectionTest.kt new file mode 100644 index 0000000000..a1268dda96 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterRuntimeSelectionTest.kt @@ -0,0 +1,38 @@ +package io.nekohasekai.sagernet.route + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class RouterRuntimeSelectionTest { + + @Test + fun mapsCoreWinnerToItsActualProfileInsteadOfTheFirstMember() { + val selections = RouterRuntimeSelection.resolve( + routerTags = linkedMapOf(10L to "router.web3"), + profileTags = linkedMapOf(1L to "node-a", 2L to "node-b"), + currentOutbound = { "node-b" }, + ) + + assertArrayEquals(longArrayOf(10L, 2L), selections) + } + + @Test + fun leavesGroupUnselectedUntilCoreHasAResult() { + val selections = RouterRuntimeSelection.resolve( + routerTags = linkedMapOf(10L to "router.web3"), + profileTags = linkedMapOf(1L to "node-a", 2L to "node-b"), + currentOutbound = { "" }, + ) + + assertArrayEquals(longArrayOf(), selections) + } + + @Test + fun parsesSelectionPairsWithoutAcceptingDanglingValues() { + assertEquals( + linkedMapOf(10L to 2L, 20L to 4L), + RouterRuntimeSelection.toMap(longArrayOf(10L, 2L, 20L, 4L, 99L)), + ) + } +} diff --git a/libcore/box.go b/libcore/box.go index 22c97c331b..4410aa8006 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -225,6 +225,34 @@ func (b *BoxInstance) SelectOutboundFor(selectorTag, tag string) bool { return selector.SelectOutbound(tag) } +func (b *BoxInstance) CurrentOutboundFor(groupTag string) string { + proxy, ok := b.Outbound().Outbound(groupTag) + if !ok { + return "" + } + switch outbound := proxy.(type) { + case *group.Selector: + return outbound.Now() + case *group.URLTest: + return outbound.Now() + default: + return "" + } +} + +func (b *BoxInstance) RefreshURLTestFor(groupTag string) bool { + proxy, ok := b.Outbound().Outbound(groupTag) + if !ok { + return false + } + urlTest, ok := proxy.(*group.URLTest) + if !ok { + return false + } + urlTest.CheckOutbounds() + return true +} + func UrlTest(i *BoxInstance, link string, timeout int32) (latency int32, err error) { defer device.DeferPanicToError("box.UrlTest", func(err_ error) { err = err_ }) var connectionTracker adapter.ConnectionTracker diff --git a/nb4a.properties b/nb4a.properties index 4ba9a56b61..82fd56bc20 100644 --- a/nb4a.properties +++ b/nb4a.properties @@ -1,4 +1,4 @@ PACKAGE_NAME=com.nb4a -VERSION_NAME=1.4.4 -PRE_VERSION_NAME=pre-1.4.4-20260904-1 -VERSION_CODE=47 +VERSION_NAME=1.4.5 +PRE_VERSION_NAME=pre-1.4.5-20260904-1 +VERSION_CODE=48 From 523056225d1d2e27be5e2c0694e6a9256e1dfe53 Mon Sep 17 00:00:00 2001 From: Gitefy Date: Sat, 5 Sep 2026 11:06:41 +0800 Subject: [PATCH 11/29] feat: prepare 1.4.6, optimize router group drag reordering, and repair lifecycle defects - Decouple router group drag reordering from source subscription userOrder via RouterMember.userOrder - Fix GroupUpdater concurrency lock release on duplicate update or confirmation abort - Eliminate OkHttp Response and Socket leaks in WebDAV settings test - Fix ConfigurationFragment ViewPager2 listener leaks, state persistence, and N+1 query - Add access mutex locking and core state checks in libcore box.go SelectOutbound/SelectOutboundFor - Add confirmation dialog before router group deletion - Ensure dangling router members and invalid selections are cleaned after backup restore - Update traffic looper, background service notifications, and bump version to 1.4.6 (code 49) --- .../sagernet/database/RouterMigrationTest.kt | 37 ++++++++ app/src/main/AndroidManifest.xml | 5 +- .../io/nekohasekai/sagernet/bg/BaseService.kt | 61 +++++++------ .../nekohasekai/sagernet/bg/ProxyService.kt | 7 +- .../sagernet/bg/ServiceNotification.kt | 19 ++-- .../sagernet/bg/proto/ProxyInstance.kt | 35 +++----- .../sagernet/bg/proto/TrafficLoopPolicy.kt | 2 +- .../sagernet/bg/proto/TrafficLooper.kt | 37 +++++++- .../sagernet/bg/proto/TrafficUpdater.kt | 17 +++- .../sagernet/database/GroupManager.kt | 25 ++++-- .../sagernet/database/ProfileManager.kt | 2 + .../sagernet/database/RouterGroup.kt | 10 +++ .../sagernet/database/RouterMember.kt | 23 +++++ .../sagernet/fmt/juicity/JuicityFmt.kt | 8 +- .../sagernet/fmt/v2ray/StandardV2RayBean.java | 3 +- .../sagernet/group/GroupUpdater.kt | 5 +- .../sagernet/route/RouterReconciler.kt | 11 ++- .../nekohasekai/sagernet/ui/AssetsActivity.kt | 10 +-- .../nekohasekai/sagernet/ui/BackupFragment.kt | 4 + .../sagernet/ui/ConfigurationFragment.kt | 87 +++++++++++++++---- .../ui/RouterGroupSettingsActivity.kt | 12 ++- .../sagernet/ui/WebDAVSettingsActivity.kt | 27 +++--- .../java/moe/matsuri/nb4a/utils/KotlinUtil.kt | 7 +- app/src/main/res/layout/layout_stun.xml | 3 +- app/src/main/res/menu/add_group_menu.xml | 6 +- app/src/main/res/values-fa/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-v28/themes.xml | 5 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- app/src/main/res/values/themes.xml | 6 +- .../bg/NotificationTitlePolicyTest.kt | 9 ++ .../bg/proto/TrafficLoopPolicyTest.kt | 7 ++ .../sagernet/bg/proto/TrafficUpdaterTest.kt | 41 +++++++++ .../sagernet/route/RouterReconcilerTest.kt | 34 ++++++++ buildSrc/src/main/kotlin/Helpers.kt | 6 +- .../plans/2026-09-05-neko-1.4.6.md | 29 +++++++ libcore/box.go | 25 +++++- nb4a.properties | 6 +- 42 files changed, 506 insertions(+), 139 deletions(-) create mode 100644 app/src/main/res/values-v28/themes.xml create mode 100644 app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdaterTest.kt create mode 100644 docs/superpowers/plans/2026-09-05-neko-1.4.6.md diff --git a/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt b/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt index c1dc1a2a72..6e76082966 100644 --- a/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt +++ b/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt @@ -146,6 +146,43 @@ class RouterMigrationTest { cursor.getLong(0) } + @Test + fun clearsDanglingSelectionsIncludingDisabledRoutersWithoutChangingValidSelections() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, SagerDatabase::class.java) + .allowMainThreadQueries() + .build() + try { + database.openHelper.writableDatabase.execSQL( + "INSERT INTO proxy_entities " + + "(id, groupId, type, userOrder, tx, rx, status, ping, uuid) " + + "VALUES (20, 10, 0, 0, 0, 0, 0, 0, 'node')" + ) + val routers = database.routerGroupDao() + val valid = routers.create(RouterGroup( + stableTag = "router.valid", selectedProxyId = 20, selectedNodeKey = "10:node", + )) + val deleted = routers.create(RouterGroup( + stableTag = "router.deleted", enabled = false, + selectedProxyId = 30, selectedNodeKey = "10:deleted", + )) + val nonMember = routers.create(RouterGroup( + stableTag = "router.nonmember", selectedProxyId = 20, selectedNodeKey = "10:node", + )) + database.routerMemberDao().insert(listOf(RouterMember(valid, 20), RouterMember(deleted, 30))) + + assertEquals(2, routers.clearInvalidSelections()) + assertEquals(20L, routers.getById(valid)!!.selectedProxyId) + assertEquals("10:node", routers.getById(valid)!!.selectedNodeKey) + for (id in listOf(deleted, nonMember)) { + assertEquals(RouterGroup.NO_SELECTION, routers.getById(id)!!.selectedProxyId) + assertEquals("", routers.getById(id)!!.selectedNodeKey) + } + } finally { + database.close() + } + } + private companion object { const val TEST_DB = "router-migration-test" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0677d341b3..e35fc16813 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ @@ -22,7 +23,7 @@ + tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" /> @@ -50,7 +51,6 @@ @@ -106,17 +108,19 @@ class BaseService { private val callbacks = object : RemoteCallbackList() { override fun onCallbackDied(callback: ISagerNetServiceCallback?, cookie: Any?) { super.onCallbackDied(callback, cookie) + callback?.let(callbackIdMap::remove) } } - val callbackIdMap = mutableMapOf() + val callbackIdMap = ConcurrentHashMap() override val coroutineContext = Dispatchers.Main.immediate + Job() override fun getState(): Int = (data?.state ?: State.Idle).ordinal override fun getProfileName(): String = data?.proxy?.displayProfileName ?: "Idle" override fun getCurrentUrlTestSelections(): LongArray = - data?.proxy?.currentUrlTestSelections() ?: longArrayOf() + data?.takeIf { it.state == State.Connected }?.proxy?.currentUrlTestSelections() + ?: longArrayOf() override fun registerCallback(cb: ISagerNetServiceCallback, id: Int) { if (id == SagerConnection.CONNECTION_ID_RESTART_BG) { @@ -127,6 +131,9 @@ class BaseService { callbacks.register(cb) } callbackIdMap[cb] = id + if (id == SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND) { + data?.proxy?.looper?.requestUpdate() + } } private val broadcastMutex = Mutex() @@ -184,6 +191,7 @@ class BaseService { override fun close() { callbacks.kill() + callbackIdMap.clear() cancel() data = null } @@ -204,6 +212,7 @@ class BaseService { ) { if (DataStore.selectedProxy == 0L) { stopRunner(false, (this as Context).getString(R.string.profile_empty)) + return } val routerReloadRequested = routerTag != null && routerProxyId != null if (routerReloadRequested && trySelectRouter(routerTag!!, routerProxyId!!)) return @@ -340,23 +349,34 @@ class BaseService { var upstreamInterfaceName: String? suspend fun preInit() { - DefaultNetworkListener.start(this) { - SagerNet.connectivity.getLinkProperties(it)?.also { link -> - SagerNet.underlyingNetwork = it + var previousNetwork: android.net.Network? = null + var hadNetwork = false + DefaultNetworkListener.start(this) { network -> + if (network == null) { + previousNetwork = null + SagerNet.underlyingNetwork = null + return@start + } + SagerNet.connectivity.getLinkProperties(network)?.also { link -> + val networkChanged = hadNetwork && previousNetwork != network + previousNetwork = network + hadNetwork = true + SagerNet.underlyingNetwork = network DataStore.vpnService?.updateUnderlyingNetwork() // val oldName = upstreamInterfaceName if (oldName != link.interfaceName) { upstreamInterfaceName = link.interfaceName } - if (oldName != null && upstreamInterfaceName != null && oldName != upstreamInterfaceName) { + if (networkChanged || (oldName != null && upstreamInterfaceName != null && oldName != upstreamInterfaceName)) { Logs.d("Network changed: $oldName -> $upstreamInterfaceName") if (DataStore.networkChangeResetConnections) { Libcore.resetAllConnections(true) } val runningProxy = data.proxy - if (runningProxy?.isInitialized() == true) { + if (data.state == State.Connected && runningProxy?.isInitialized() == true) { data.binder.launch(Dispatchers.IO) { + if (data.state != State.Connected || data.proxy !== runningProxy) return@launch runningProxy.config.routerUrlTestTags.values.distinct().forEach { runningProxy.box.refreshURLTestFor(it) } @@ -411,27 +431,15 @@ class BaseService { } addAction(Action.RESET_UPSTREAM_CONNECTIONS) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver( - data.receiver, - filter, - "$packageName.SERVICE", - null, - Context.RECEIVER_EXPORTED - ) - } else { - registerReceiver( - data.receiver, - filter, - "$packageName.SERVICE", - null - ) - } + ContextCompat.registerReceiver( + this, data.receiver, filter, "$packageName.SERVICE", null, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) data.closeReceiverRegistered = true } data.changeState(State.Connecting) - runOnMainDispatcher { + data.connectingJob = data.binder.launch(start = CoroutineStart.LAZY) { try { data.notification = createNotification(ServiceNotification.genTitle(profile)) @@ -471,6 +479,7 @@ class BaseService { data.connectingJob = null } } + data.connectingJob?.start() return Service.START_NOT_STICKY } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt index 448ac2699a..0cf11c2301 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt @@ -21,7 +21,12 @@ class ProxyService : Service(), BaseService.Interface { .apply { acquire() } } - override fun onBind(intent: Intent) = super.onBind(intent) + override fun onBind(intent: Intent) = super.onBind(intent) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = super.onStartCommand(intent, flags, startId) + + override fun onDestroy() { + data.binder.close() + super.onDestroy() + } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt index fbed78752d..6f5248e6b8 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt @@ -1,7 +1,6 @@ package io.nekohasekai.sagernet.bg import android.app.Notification -import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver @@ -56,13 +55,19 @@ class ServiceNotification( ) fun vpnNotificationChannelPolicy() = NotificationChannelPolicy( - importance = NotificationManager.IMPORTANCE_MIN, + importance = NotificationManagerCompat.IMPORTANCE_MIN, lockscreenVisibility = Notification.VISIBILITY_SECRET, ) fun shouldPostSpeed(visible: Boolean, interactive: Boolean): Boolean = visible && interactive + fun notificationPriority(visible: Boolean, wakeLockAcquired: Boolean): Int = when { + !visible -> NotificationCompat.PRIORITY_MIN + wakeLockAcquired -> NotificationCompat.PRIORITY_HIGH + else -> NotificationCompat.PRIORITY_LOW + } + fun genTitle( ent: ProxyEntity?, showProfileInNotification: Boolean = DataStore.showProfileInNotification, @@ -133,8 +138,7 @@ class ServiceNotification( suspend fun postNotificationWakeLockStatus(acquired: Boolean) { updateActions() useBuilder { - it.priority = - if (acquired) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW + it.priority = notificationPriority(visible, acquired) } update() } @@ -153,6 +157,7 @@ class ServiceNotification( .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN) private val buildLock = Mutex() + @Volatile private var destroyed = false private suspend fun useBuilder(f: (NotificationCompat.Builder) -> Unit) { buildLock.withLock { @@ -200,7 +205,7 @@ class ServiceNotification( val resetUpstreamAction = NotificationCompat.Action.Builder( 0, service.getString(R.string.reset_connections), PendingIntent.getBroadcast( - service, 0, Intent(Action.RESET_UPSTREAM_CONNECTIONS), flags + service, 0, Intent(Action.RESET_UPSTREAM_CONNECTIONS).setPackage(service.packageName), flags ) ).setShowsUserInterface(false).build() it.addAction(resetUpstreamAction) @@ -219,6 +224,7 @@ class ServiceNotification( private suspend fun show() = useBuilder { + if (destroyed) return@useBuilder try { if (Build.VERSION.SDK_INT >= 34) { (service as Service).startForeground( @@ -239,10 +245,13 @@ class ServiceNotification( } private suspend fun update() = useBuilder { + if (destroyed) return@useBuilder NotificationManagerCompat.from(service as Service).notify(notificationId, it.build()) } fun destroy() { + if (destroyed) return + destroyed = true listenPostSpeed = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { (service as Service).stopForeground(Service.STOP_FOREGROUND_REMOVE) 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 9758a5c40d..fcb45d7e6c 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 @@ -1,13 +1,14 @@ package io.nekohasekai.sagernet.bg.proto -import io.nekohasekai.sagernet.BuildConfig 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking -import moe.matsuri.nb4a.utils.JavaUtil class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? = null) : BoxInstance(profile) { @@ -18,14 +19,14 @@ class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? = var displayProfileName = ServiceNotification.genTitle(profile) // for TrafficLooper - var looper: TrafficLooper? = null + @Volatile var looper: TrafficLooper? = null + private val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun buildConfig() { super.buildConfig() lastSelectorGroupId = super.config.selectorGroupId // - if (notTmp) Logs.d(config.config) - if (notTmp && BuildConfig.DEBUG) Logs.d(JavaUtil.gson.toJson(config.trafficMap)) + if (notTmp) Logs.d("Built proxy configuration: ${config.profileTagMap.size} profiles") } // only use this in temporary instance @@ -34,14 +35,6 @@ class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? = buildConfig() } - override suspend fun init() { - super.init() - pluginConfigs.forEach { (_, plugin) -> - val (_, content) = plugin - Logs.d(content) - } - } - override suspend fun loadConfig() { super.loadConfig() } @@ -49,17 +42,17 @@ 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() - } + looper = service?.let { TrafficLooper(it.data, runtimeScope) } + looper?.start() } override fun close() { - super.close() - runBlocking { - looper?.stop() + try { + runBlocking { looper?.stop() } + } finally { looper = null + runtimeScope.cancel() + super.close() } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt index 9881ae7f37..d47a4ffe0e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt @@ -10,7 +10,7 @@ object TrafficLoopPolicy { mainActivityForeground: Boolean, notificationSpeedVisible: Boolean, ): Long = when { - mainActivityForeground -> configuredMillis + mainActivityForeground -> if (configuredMillis > 0L) configuredMillis else 1_000L notificationSpeedVisible -> maxOf(configuredMillis, MIN_BACKGROUND_NOTIFICATION_MILLIS) else -> maxOf(configuredMillis, MIN_BACKGROUND_HIDDEN_MILLIS) } 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 635d315719..f6418d21bf 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 @@ -11,6 +11,7 @@ import io.nekohasekai.sagernet.fmt.TAG_BYPASS import io.nekohasekai.sagernet.fmt.TAG_PROXY import io.nekohasekai.sagernet.ktx.Logs import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.Mutex class TrafficLooper @@ -23,6 +24,15 @@ class TrafficLooper } private var job: Job? = null + private val updateRequests = Channel(Channel.CONFLATED) + + fun requestUpdate() { + updateRequests.trySend(Unit) + } + + private suspend fun awaitUpdate(delayMillis: Long) { + withTimeoutOrNull(delayMillis) { updateRequests.receive() } + } private val idMap = mutableMapOf() // id to 1 data private val tagMap = mutableMapOf() // tag to 1 data private val stateMutex = Mutex() @@ -48,6 +58,7 @@ class TrafficLooper // finally traffic post if (!DataStore.profileTrafficStatistics) return withStateLock { + trafficUpdater?.updateAll() val traffic = mutableMapOf() data.proxy?.config?.trafficMap?.forEach { (_, ents) -> for (ent in ents) { @@ -171,7 +182,6 @@ class TrafficLooper val delayMs = DataStore.speedInterval.toLong() val showDirectSpeed = DataStore.showDirectSpeed val profileTrafficStatistics = DataStore.profileTrafficStatistics - if (delayMs == 0L) return // for display val itemBypass = TrafficUpdater.TrafficLooperData(tag = TAG_BYPASS) @@ -179,11 +189,30 @@ class TrafficLooper while (currentCoroutineContext().isActive) { val proxy = data.proxy if (proxy == null) { - delay(delayMs) + awaitUpdate(TrafficLoopPolicy.initializationRetryMillis(delayMs)) continue } if (!proxy.isInitialized()) { - delay(TrafficLoopPolicy.initializationRetryMillis(delayMs)) + awaitUpdate(TrafficLoopPolicy.initializationRetryMillis(delayMs)) + continue + } + + if (delayMs <= 0L) { + if (data.state == BaseService.State.Connected) { + val selections = proxy.currentUrlTestSelections() + data.binder.broadcast { callback -> + if (data.binder.callbackIdMap[callback] == + SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND + ) { + callback.cbSpeedUpdate(SpeedDisplayData(urlTestSelections = selections)) + } + } + } + awaitUpdate(TrafficLoopPolicy.delayMillis( + delayMs, + data.binder.callbackIdMap.containsValue(SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND), + false, + )) continue } @@ -292,7 +321,7 @@ class TrafficLooper val mainActivityForeground = data.binder.callbackIdMap.containsValue( SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND ) - delay( + awaitUpdate( TrafficLoopPolicy.delayMillis( configuredMillis = delayMs, mainActivityForeground = mainActivityForeground, diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt index 35ab1bd367..03590834d5 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt @@ -1,9 +1,17 @@ package io.nekohasekai.sagernet.bg.proto class TrafficUpdater( - private val box: libcore.BoxInstance, + private val queryStats: (String, String) -> Long, val items: List, // contain "bypass" + private val monotonicMillis: () -> Long = { System.nanoTime() / 1_000_000L }, ) { + constructor(box: libcore.BoxInstance, items: List) : + this(box::queryStats, items) + + init { + val now = monotonicMillis() + items.forEach { it.lastUpdate = now } + } class TrafficLooperData( // Don't associate proxyEntity @@ -21,7 +29,7 @@ class TrafficUpdater( private fun updateOne(item: TrafficLooperData): TrafficLooperData { // last update - val now = System.currentTimeMillis() + val now = monotonicMillis() val interval = now - item.lastUpdate item.lastUpdate = now if (interval <= 0) { @@ -31,8 +39,8 @@ class TrafficUpdater( } // query - val tx = box.queryStats(item.tag, "uplink") - val rx = box.queryStats(item.tag, "downlink") + val tx = queryStats(item.tag, "uplink") + val rx = queryStats(item.tag, "downlink") // add diff item.rx += rx @@ -67,6 +75,7 @@ class TrafficUpdater( item.rxRate = diff.rxRate item.txRate = diff.txRate item.hasTrafficDelta = diff.rx != 0L || diff.tx != 0L + item.lastUpdate = monotonicMillis() } } // Logs.d(JavaUtil.gson.toJson(items)) 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 0d09b0aa77..594d0ebb22 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt @@ -145,10 +145,14 @@ object GroupManager { } suspend fun reconcileRouterMembers(previous: RouterRefreshSnapshot) { - cleanupDanglingRouterMembers() + // Keep the old selected ID until reconciliation can resolve it through the snapshot. + cleanupDanglingRouterMembers(clearInvalidSelections = false) val routers = SagerDatabase.routerGroupDao.all() - .filter { it.enabled && it.stableTag.isNotBlank() } - if (routers.isEmpty()) return + .filter { it.stableTag.isNotBlank() } + if (routers.isEmpty()) { + cleanupDanglingRouterMembers() + return + } val groups = routers.mapNotNull { router -> runCatching { @@ -162,9 +166,15 @@ object GroupManager { ) }.onFailure { error -> Logs.e("Router ${router.stableTag} match configuration is invalid", error) + SagerDatabase.routerGroupDao.update( + router.copy(lastError = "Invalid Router match configuration"), + ) }.getOrNull() } - if (groups.size != routers.size) return + if (groups.isEmpty()) { + cleanupDanglingRouterMembers() + return + } val sourceGroups = SagerDatabase.groupDao.allGroups().associateBy { it.id } val nodes = SagerDatabase.proxyDao.getAll().mapNotNull { proxy -> @@ -187,9 +197,10 @@ object GroupManager { val result = RouterReconciler.reconcile(nodes, groups, previous.membersByRouterId) if (result.error != null) { Logs.e("Router reconciliation preserved existing members: ${result.error}") - routers.forEach { router -> + routers.filter { router -> groups.any { it.routerId == router.id } }.forEach { router -> SagerDatabase.routerGroupDao.update(router.copy(lastError = result.error)) } + cleanupDanglingRouterMembers() return } @@ -222,6 +233,7 @@ object GroupManager { ) } } + cleanupDanglingRouterMembers() } fun markRouterRefreshFailed(sourceGroupId: Long, message: String) { @@ -233,7 +245,7 @@ object GroupManager { } } - fun cleanupDanglingRouterMembers() { + fun cleanupDanglingRouterMembers(clearInvalidSelections: Boolean = true) { runCatching { val currentProxyIds = SagerDatabase.proxyDao.getAll().map { it.id }.toSet() val members = SagerDatabase.routerGroupDao.all().flatMap { router -> @@ -244,6 +256,7 @@ object GroupManager { }, currentProxyIds).forEach { proxyId -> SagerDatabase.routerMemberDao.deleteByProxy(proxyId) } + if (clearInvalidSelections) SagerDatabase.routerGroupDao.clearInvalidSelections() }.onFailure { error -> Logs.e("Unable to clean dangling router members", error) } 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 a3a6d190c8..384c56016e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -110,6 +110,7 @@ object ProfileManager { suspend fun deleteProfile2(groupId: Long, profileId: Long) { SagerDatabase.routerMemberDao.deleteByProxy(profileId) if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return + GroupManager.cleanupDanglingRouterMembers() if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L } @@ -118,6 +119,7 @@ object ProfileManager { suspend fun deleteProfile(groupId: Long, profileId: Long) { SagerDatabase.routerMemberDao.deleteByProxy(profileId) if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return + GroupManager.cleanupDanglingRouterMembers() if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L } diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt index 9fcf730e12..ec6ea955f0 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt @@ -87,6 +87,16 @@ data class RouterGroup( @Update fun update(router: RouterGroup): Int + @Query(""" + UPDATE router_groups SET selectedProxyId = -1, selectedNodeKey = '' + WHERE selectedProxyId != -1 AND ( + NOT EXISTS (SELECT 1 FROM proxy_entities WHERE id = router_groups.selectedProxyId) + OR NOT EXISTS (SELECT 1 FROM router_members + WHERE routerId = router_groups.id AND proxyId = router_groups.selectedProxyId) + ) + """) + fun clearInvalidSelections(): Int + @Delete fun delete(router: RouterGroup): Int diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt index e07b9d4cb3..0c658e6f56 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt @@ -57,6 +57,29 @@ data class RouterMember( @Query("DELETE FROM router_members WHERE proxyId = :proxyId") fun deleteByProxy(proxyId: Long): Int + @Query("UPDATE router_members SET userOrder = :userOrder WHERE routerId = :routerId AND proxyId = :proxyId") + fun updateUserOrder(routerId: Long, proxyId: Long, userOrder: Long): Int + + @Transaction + fun updateOrders(routerId: Long, orderedProxyIds: List) { + val existing = getByRouter(routerId) + if (existing.isEmpty()) return + val orderedSet = orderedProxyIds.toSet() + if (orderedSet.size == existing.size) { + for ((index, proxyId) in orderedProxyIds.withIndex()) { + updateUserOrder(routerId, proxyId, (index + 1).toLong()) + } + } else { + val remaining = existing.filter { it.proxyId !in orderedSet }.sortedBy { it.userOrder } + val merged = ArrayList(existing.size) + merged.addAll(orderedProxyIds) + merged.addAll(remaining.map { it.proxyId }) + for ((index, proxyId) in merged.withIndex()) { + updateUserOrder(routerId, proxyId, (index + 1).toLong()) + } + } + } + @Insert fun insert(members: List) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/juicity/JuicityFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/juicity/JuicityFmt.kt index bebb6a43f7..d3fd3b0909 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/juicity/JuicityFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/juicity/JuicityFmt.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sagernet.fmt.juicity +import android.util.Base64 import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.linkBuilder import io.nekohasekai.sagernet.ktx.toLink @@ -8,7 +9,6 @@ import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.SingBoxOptions.Outbound_JuicityOptions import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import java.util.Base64 fun parseJuicity(url: String): JuicityBean { val link = url.replace("juicity://", "https://").toHttpUrlOrNull() ?: error( @@ -84,8 +84,10 @@ fun buildSingBoxOutboundJuicityBean(bean: JuicityBean): Outbound_JuicityOptions private fun normalizePinnedCertChainHash(rawHash: String?): String? { val certChainHash = rawHash?.replace(":", "")?.takeIf { it.isNotEmpty() } ?: return null return when { - certChainHash.length == 64 -> Base64.getUrlEncoder() - .encodeToString(certChainHash.chunked(2).map { chunk -> chunk.toInt(16).toByte() }.toByteArray()) + certChainHash.length == 64 -> Base64.encodeToString( + certChainHash.chunked(2).map { chunk -> chunk.toInt(16).toByte() }.toByteArray(), + Base64.URL_SAFE or Base64.NO_WRAP + ) else -> certChainHash.replace('/', '_').replace('+', '-') } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java index 875e163668..17a883782e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java @@ -2,6 +2,7 @@ import com.esotericsoftware.kryo.io.ByteBufferInput; import com.esotericsoftware.kryo.io.ByteBufferOutput; +import java.util.Locale; import io.nekohasekai.sagernet.fmt.AbstractBean; import io.nekohasekai.sagernet.fmt.trojan.TrojanBean; @@ -98,7 +99,7 @@ public void initializeDefaultValues() { if (JavaUtil.isNullOrBlank(type)) type = "tcp"; else if ("h2".equals(type)) type = "http"; - type = type.toLowerCase(); + type = type.toLowerCase(Locale.ROOT); if (JavaUtil.isNullOrBlank(host)) host = ""; if (JavaUtil.isNullOrBlank(path)) path = ""; 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 c1c60deeb3..af0b1906c6 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt @@ -132,7 +132,7 @@ abstract class GroupUpdater { suspend fun executeUpdate(proxyGroup: ProxyGroup, byUser: Boolean): Boolean { return coroutineScope { - if (!updating.add(proxyGroup.id)) cancel() + if (!updating.add(proxyGroup.id)) return@coroutineScope false GroupManager.postReload(proxyGroup.id) val subscription = proxyGroup.subscription!! @@ -142,8 +142,7 @@ abstract class GroupUpdater { if (byUser && (subscription.link?.startsWith("http://") == true || subscription.updateWhenConnectedOnly) && !connected) { if (userInterface == null || !userInterface.confirm(app.getString(R.string.update_subscription_warning))) { finishUpdate(proxyGroup) - cancel() - return@coroutineScope true + return@coroutineScope false } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt index 3427da58e8..0221b5ee23 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt @@ -69,7 +69,10 @@ object RouterReconciler { .mapNotNull { indexed -> val previous = indexed.value val current = currentById[previous.proxyId] - ?.takeIf { it.subscriptionId == previous.sourceGroupId && it.id in matchedIdSet } + ?.takeIf { + it.subscriptionId == previous.sourceGroupId && it.id in matchedIdSet && + routerStableIdOrFallback(it.stableId, it.id) == previous.stableId + } ?: currentByStableKey[StableNodeKey(previous.sourceGroupId, previous.stableId)] ?.takeIf { it.id in matchedIdSet } current?.let { node -> @@ -103,12 +106,14 @@ object RouterReconciler { val members = membersByRouterId[group.routerId].orEmpty() val previousSelected = previousMembers[group.routerId].orEmpty() .firstOrNull { it.proxyId == group.selectedProxyId } - val selected = members.firstOrNull { it.proxyId == group.selectedProxyId } - ?: previousSelected?.let { old -> + val selected = previousSelected?.let { old -> members.firstOrNull { it.stableId == old.stableId && it.sourceGroupId == old.sourceGroupId } } + ?: members.firstOrNull { + previousSelected == null && it.proxyId == group.selectedProxyId + } ?: members.firstOrNull() group.routerId to selected?.proxyId } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt index 22d7789ca9..12e93c643b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt @@ -6,6 +6,7 @@ import android.text.format.DateFormat import android.view.Menu import android.view.MenuItem import android.view.ViewGroup +import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isInvisible import androidx.recyclerview.widget.ItemTouchHelper @@ -33,6 +34,9 @@ class AssetsActivity : ThemedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + onBackPressedDispatcher.addCallback(this) { + finish() + } val binding = LayoutAssetsBinding.inflate(layoutInflater) layout = binding @@ -364,7 +368,7 @@ class AssetsActivity : ThemedActivity() { response.writeTo(cacheFile.canonicalPath) cacheFile.renameTo(file) - val currentDate = java.text.SimpleDateFormat("yyyyMMdd").format(java.util.Date()) + val currentDate = java.text.SimpleDateFormat("yyyyMMdd", Locale.ROOT).format(Date()) versionFile.writeText(currentDate) adapter.reloadAssets() @@ -384,10 +388,6 @@ class AssetsActivity : ThemedActivity() { return true } - override fun onBackPressed() { - finish() - } - override fun onResume() { super.onResume() diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt index 0a84dfa053..7392ccbe2c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt @@ -691,6 +691,7 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) { val validSources = routerSources.filter { it.routerId in validRouterIds && it.sourceGroupId in validGroupIds } if (validMembers.isNotEmpty()) SagerDatabase.routerMemberDao.insert(validMembers) if (validSources.isNotEmpty()) SagerDatabase.routerGroupSourceDao.insert(validSources) + SagerDatabase.routerGroupDao.clearInvalidSelections() } if (rule && content.has("rules")) { @@ -705,6 +706,9 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) { SagerDatabase.rulesDao.insert(rules) } } + if (profile) { + GroupManager.cleanupDanglingRouterMembers() + } if (setting && content.has("settings")) { val settings = BackupSerializer.getParcelableArray(content, "settings", KeyValuePair.CREATOR) PublicDatabase.kvPairDao.reset() 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 9838205ec4..e3ec619e8c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1573,13 +1573,23 @@ class ConfigurationFragment @JvmOverloads constructor( if (::proxyGroup.isInitialized) { outState.putParcelable("proxyGroup", proxyGroup) } + routerGroup?.let { + outState.putParcelable("routerGroup", it) + } } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) + var restored = false savedInstanceState?.getParcelable("proxyGroup")?.also { proxyGroup = it + restored = true + } + savedInstanceState?.getParcelable("routerGroup")?.also { + routerGroup = it + } + if (restored && !::configurationListView.isInitialized) { onViewCreated(requireView(), null) } } @@ -1829,7 +1839,9 @@ class ConfigurationFragment @JvmOverloads constructor( }) if (!select) { - undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!) + if (!inRouterGroupMode) { + undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!) + } setupItemTouchHelper() setupBottomBarScrollDriver() } @@ -1845,23 +1857,40 @@ class ConfigurationFragment @JvmOverloads constructor( val touchSlop = ViewConfiguration.get(requireContext()).scaledTouchSlop var lastRawY = 0f - configurationListView.setOnTouchListener { recyclerView, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> lastRawY = event.rawY - MotionEvent.ACTION_MOVE -> { - val cannotScroll = !recyclerView.canScrollVertically(-1) && - !recyclerView.canScrollVertically(1) - if (cannotScroll) { - val fingerDy = event.rawY - lastRawY - if (abs(fingerDy) >= touchSlop) { - mainActivity.driveBottomBar(-fingerDy.toInt()) - lastRawY = event.rawY + configurationListView.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { + override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> lastRawY = event.rawY + MotionEvent.ACTION_MOVE -> { + val cannotScroll = !recyclerView.canScrollVertically(-1) && + !recyclerView.canScrollVertically(1) + if (cannotScroll) { + val fingerDy = event.rawY - lastRawY + if (abs(fingerDy) >= touchSlop) { + mainActivity.driveBottomBar(-fingerDy.toInt()) + lastRawY = event.rawY + } } } } + return false } - false + }) + } + + override fun onDestroyView() { + adapter?.let { + ProfileManager.removeListener(it) + GroupManager.removeListener(it) + } + if (::undoManager.isInitialized) { + undoManager.flush() + } + if (::itemTouchHelper.isInitialized) { + itemTouchHelper.attachToRecyclerView(null) } + adapter = null + super.onDestroyView() } override fun onDestroy() { @@ -2073,6 +2102,7 @@ class ConfigurationFragment @JvmOverloads constructor( } private val updated = HashSet() + private var routerOrderChanged = false fun filter(name: String) { if (name.isEmpty()) { @@ -2092,6 +2122,14 @@ class ConfigurationFragment @JvmOverloads constructor( fun move(from: Int, to: Int) { if (from == to) return + if (inRouterGroupMode) { + val draggedItemId = configurationIdList.removeAt(from) + configurationIdList.add(to, draggedItemId) + routerOrderChanged = true + notifyItemMoved(from, to) + return + } + if (layoutManager is FixedGridLayoutManager) { moveDualColumn(from, to) } else { @@ -2138,6 +2176,24 @@ class ConfigurationFragment @JvmOverloads constructor( } fun commitMove() = runOnDefaultDispatcher { + val rg = routerGroup + if (inRouterGroupMode && rg != null) { + if (routerOrderChanged) { + routerOrderChanged = false + val orderedIds = ArrayList(configurationIdList) + SagerDatabase.routerMemberDao.updateOrders(rg.id, orderedIds) + if (DataStore.serviceState.started) { + SagerNet.reloadService() + } + } + onMainDispatcher { + if (layoutManager is FixedGridLayoutManager) { + notifyDataSetChanged() + } + } + return@runOnDefaultDispatcher + } + updated.forEach { SagerDatabase.proxyDao.updateProxy(it) } updated.clear() onMainDispatcher { @@ -2306,9 +2362,8 @@ class ConfigurationFragment @JvmOverloads constructor( val memberIds = SagerDatabase.routerMemberDao.getByRouter(rg.id) .sortedBy { it.userOrder } .map { it.proxyId } - newProfiles = memberIds.mapNotNull { id -> - SagerDatabase.proxyDao.getById(id) - } + val entities = SagerDatabase.proxyDao.getEntities(memberIds).associateBy { it.id } + newProfiles = memberIds.mapNotNull { entities[it] } } else { // Normal proxy-group mode var list = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt index 755eab89a0..840e1c7886 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt @@ -5,6 +5,7 @@ import android.view.Menu import android.view.MenuItem import android.widget.Toast import androidx.appcompat.widget.Toolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference @@ -51,7 +52,16 @@ class RouterGroupSettingsActivity : ThemedActivity(R.layout.layout_settings_acti override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { finish(); true } R.id.action_apply -> { editor?.save(); true } - R.id.action_delete -> { editor?.delete(); true } + R.id.action_delete -> { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_group_prompt) + .setPositiveButton(R.string.yes) { _, _ -> + editor?.delete() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } else -> super.onOptionsItemSelected(item) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt index 0fbe6faee1..5fd899ad9d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt @@ -148,17 +148,17 @@ class WebDAVSettingsActivity : ThemedActivity() { } .build() - val response = client.newCall(authRequest).execute() - - when (response.code) { - 401 -> throw Exception(getString(R.string.webdav_auth_error)) - 403 -> throw Exception(getString(R.string.webdav_permission_denied)) - 404 -> throw Exception(getString(R.string.webdav_server_not_found)) - in 500..599 -> throw Exception(getString(R.string.webdav_server_error)) - } + client.newCall(authRequest).execute().use { response -> + when (response.code) { + 401 -> throw Exception(getString(R.string.webdav_auth_error)) + 403 -> throw Exception(getString(R.string.webdav_permission_denied)) + 404 -> throw Exception(getString(R.string.webdav_server_not_found)) + in 500..599 -> throw Exception(getString(R.string.webdav_server_error)) + } - if (!response.isSuccessful) { - throw Exception(getString(R.string.webdav_connect_failed, response.code)) + if (!response.isSuccessful) { + throw Exception(getString(R.string.webdav_connect_failed, response.code)) + } } // 如果认证成功,再测试目录操作 @@ -185,9 +185,10 @@ class WebDAVSettingsActivity : ThemedActivity() { } .build() - val dirResponse = client.newCall(dirRequest).execute() - if (!dirResponse.isSuccessful && dirResponse.code != 405) { // 405 表示目录已存在 - throw Exception(getString(R.string.webdav_create_dir_failed)) + client.newCall(dirRequest).execute().use { dirResponse -> + if (!dirResponse.isSuccessful && dirResponse.code != 405) { // 405 表示目录已存在 + throw Exception(getString(R.string.webdav_create_dir_failed)) + } } } diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt b/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt index 14f2240fdf..02ada7b64d 100644 --- a/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt +++ b/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt @@ -7,6 +7,7 @@ import androidx.appcompat.content.res.AppCompatResources import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.Logs import java.io.File +import java.util.Locale // SagerNet Class @@ -50,9 +51,9 @@ fun Context.getDrawableByName(name: String?): Drawable? { fun Long.toBytesString(): String { val size = this.toDouble() return when { - this >= GB -> String.format("%.2f GiB", size / GB) - this >= MB -> String.format("%.2f MiB", size / MB) - this >= KB -> String.format("%.2f KiB", size / KB) + this >= GB -> String.format(Locale.getDefault(), "%.2f GiB", size / GB) + this >= MB -> String.format(Locale.getDefault(), "%.2f MiB", size / MB) + this >= KB -> String.format(Locale.getDefault(), "%.2f KiB", size / KB) else -> "$this Bytes" } } diff --git a/app/src/main/res/layout/layout_stun.xml b/app/src/main/res/layout/layout_stun.xml index c10b04a929..3351b75c32 100644 --- a/app/src/main/res/layout/layout_stun.xml +++ b/app/src/main/res/layout/layout_stun.xml @@ -61,6 +61,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/nat_stun_server_hint" + android:labelFor="@id/nat_stun_server" android:textAppearance="?attr/textAppearanceSubtitle2" /> - \ No newline at end of file + diff --git a/app/src/main/res/menu/add_group_menu.xml b/app/src/main/res/menu/add_group_menu.xml index 35177cd10e..3e13846357 100644 --- a/app/src/main/res/menu/add_group_menu.xml +++ b/app/src/main/res/menu/add_group_menu.xml @@ -5,10 +5,10 @@ android:id="@+id/action_update_all" android:icon="@drawable/ic_baseline_update_24" android:title="@string/update_all_subscription" - app:showAsAction="always" /> + app:showAsAction="ifRoom" /> - \ No newline at end of file + app:showAsAction="ifRoom" /> + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8ff56a678c..eb489ce367 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -593,7 +593,7 @@ مسیر پشتیبان آزمایش اتصال اتصال موفق بود - اتصال ناموفق بود: %s + اتصال ناموفق بود: %1$s پشتیبان‌گیری در WebDAV بازیابی از WebDAV پشتیبان‌گیری در WebDAV موفق بود diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d614b0973b..68225971c3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -526,7 +526,7 @@ バックアップパス 接続をテスト 接続に成功しました - 接続に失敗しました: %s + 接続に失敗しました: %1$s WebDAV にバックアップ WebDAV から復元 WebDAV へのバックアップに成功しました diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1cf8fd4874..926467c28e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -520,7 +520,7 @@ 백업 경로 연결 테스트 연결 성공 - 연결 실패: %s + 연결 실패: %1$s WebDAV에 백업 WebDAV에서 복원 WebDAV에 백업 성공 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b2f09e9c76..f079153acc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -600,7 +600,7 @@ Путь резервной копии Проверить соединение Соединение успешно - Ошибка соединения: %s + Ошибка соединения: %1$s Резервное копирование в WebDAV Восстановление из WebDAV Резервное копирование в WebDAV выполнено diff --git a/app/src/main/res/values-v28/themes.xml b/app/src/main/res/values-v28/themes.xml new file mode 100644 index 0000000000..a48cdc6016 --- /dev/null +++ b/app/src/main/res/values-v28/themes.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e1be97b789..017248019a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -540,7 +540,7 @@ 备份目录 测试连接 连接成功 - 连接失败: %s + 连接失败: %1$s 备份到 WebDAV 从 WebDAV 恢复 备份到 WebDAV 成功 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8835b48685..45b8df518d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -544,7 +544,7 @@ 備份目錄 測試連線 連線成功 - 連線失敗: %s + 連線失敗: %1$s 備份到 WebDAV 從 WebDAV 恢復 備份到 WebDAV 成功 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8555f4a1e9..76cf49a899 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -678,7 +678,7 @@ Backup Path Test Connection Connection Successful - Connection Failed: %s + Connection Failed: %1$s Backup to WebDAV Restore from WebDAV Backup to WebDAV successful diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index da5956eb0b..f08341bee0 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -58,7 +58,9 @@ - diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt index 8a690ec65d..e17939f0ba 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/NotificationTitlePolicyTest.kt @@ -4,11 +4,20 @@ import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.http.HttpBean import android.app.Notification import android.app.NotificationManager +import androidx.core.app.NotificationCompat import org.junit.Assert.assertEquals import org.junit.Test class NotificationTitlePolicyTest { + @Test + fun wakeLockChangesNeverPromoteHiddenNotification() { + assertEquals(NotificationCompat.PRIORITY_MIN, ServiceNotification.notificationPriority(false, false)) + assertEquals(NotificationCompat.PRIORITY_MIN, ServiceNotification.notificationPriority(false, true)) + assertEquals(NotificationCompat.PRIORITY_LOW, ServiceNotification.notificationPriority(true, false)) + assertEquals(NotificationCompat.PRIORITY_HIGH, ServiceNotification.notificationPriority(true, true)) + } + @Test fun vpnNotificationIsHiddenFromLockScreen() { val policy = ServiceNotification.vpnNotificationChannelPolicy() diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicyTest.kt index e6ffa7c7e6..7a2545e1b5 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicyTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicyTest.kt @@ -5,6 +5,13 @@ import org.junit.Test class TrafficLoopPolicyTest { + @Test + fun disablingSpeedStillPollsRouterSelectionWithoutBusySpinning() { + assertEquals(1_000L, TrafficLoopPolicy.delayMillis(0L, true, false)) + assertEquals(30_000L, TrafficLoopPolicy.delayMillis(0L, false, false)) + assertEquals(1_000L, TrafficLoopPolicy.delayMillis(-1L, true, false)) + } + @Test fun keepsConfiguredRefreshRateWhileHomePageIsVisible() { assertEquals( diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdaterTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdaterTest.kt new file mode 100644 index 0000000000..2a373dcda7 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdaterTest.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sagernet.bg.proto + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TrafficUpdaterTest { + @Test + fun firstSampleUsesElapsedSamplingTimeAndQueriesEachTagOnce() { + var now = 40_000L + var calls = 0 + val first = TrafficUpdater.TrafficLooperData("node", rx = 100L) + val second = TrafficUpdater.TrafficLooperData("node") + val updater = TrafficUpdater( + queryStats = { _, _ -> calls++; 2_000L }, + items = listOf(first, second), + monotonicMillis = { now }, + ) + now += 2_000L + updater.updateAll() + assertEquals(2, calls) + assertEquals(1_000L, first.rxRate) + assertEquals(first.rxRate, second.rxRate) + assertEquals(2_100L, first.rx) + assertTrue(first.hasTrafficDelta) + } + + @Test + fun identicalClockReadingDoesNotConsumeCounters() { + var now = 10L + var calls = 0 + val item = TrafficUpdater.TrafficLooperData("node") + val updater = TrafficUpdater({ _, _ -> calls++; 50L }, listOf(item), { now }) + updater.updateAll() + assertEquals(0, calls) + now += 1_000L + updater.updateAll() + assertEquals(50L, item.rx) + assertEquals(50L, item.rxRate) + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt b/app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt index 353f3c7045..e224d500d9 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/route/RouterReconcilerTest.kt @@ -8,6 +8,40 @@ import org.junit.Test class RouterReconcilerTest { + @Test + fun reusedProfileIdDoesNotStealStableMemberOrderOrSelection() { + val result = RouterReconciler.reconcile( + currentNodes = listOf( + RouterNodeSnapshot(100, "replacement", "US A", subscriptionId = 10), + RouterNodeSnapshot(200, "original", "US renamed", subscriptionId = 10), + ), + groups = listOf(group(1, setOf(10), "US", selectedProxyId = 100)), + previousMembers = mapOf(1L to listOf(RouterMemberSnapshot(100, "original", 10, 7))), + ) + + assertEquals(listOf(200L, 100L), result.membersByRouterId.getValue(1).map { it.proxyId }) + assertEquals(listOf(7L, 8L), result.membersByRouterId.getValue(1).map { it.userOrder }) + assertEquals(200L, result.selectedProxyIdsByRouterId.getValue(1)) + } + + @Test + fun selectionFollowsStableIdentityWhenExistingIdsSwapNodes() { + val result = RouterReconciler.reconcile( + currentNodes = listOf( + RouterNodeSnapshot(100, "b", "US A", subscriptionId = 10), + RouterNodeSnapshot(200, "a", "US B", subscriptionId = 10), + ), + groups = listOf(group(1, setOf(10), "US", selectedProxyId = 100)), + previousMembers = mapOf(1L to listOf( + RouterMemberSnapshot(100, "a", 10, 7), + RouterMemberSnapshot(200, "b", 10, 8), + )), + ) + + assertEquals(200L, result.selectedProxyIdsByRouterId.getValue(1)) + assertEquals(listOf(200L, 100L), result.membersByRouterId.getValue(1).map { it.proxyId }) + } + @Test fun remapsMemberAndSelectionBySourceScopedStableIdentity() { val result = RouterReconciler.reconcile( diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt index 3561cdd1e1..0ab008289b 100644 --- a/buildSrc/src/main/kotlin/Helpers.kt +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -63,7 +63,9 @@ fun Project.setupCommon() { showAll = true checkAllWarnings = true checkReleaseBuilds = true - warningsAsErrors = true + // Keep advisory dependency/style findings visible without promoting them + // to runtime correctness errors. Lint errors still abort the build. + warningsAsErrors = false textOutput = project.file("build/lint.txt") htmlOutput = project.file("build/lint.html") } @@ -217,4 +219,4 @@ fun Project.setupApp() { jniLibs.srcDir("executableSo") } } -} \ No newline at end of file +} diff --git a/docs/superpowers/plans/2026-09-05-neko-1.4.6.md b/docs/superpowers/plans/2026-09-05-neko-1.4.6.md new file mode 100644 index 0000000000..7b0897743a --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-neko-1.4.6.md @@ -0,0 +1,29 @@ +# NekoBox 1.4.6 Implementation Plan + +**Goal:** Repair confirmed compatibility, lifecycle, Router and UI defects and ship a verified 1.4.6 build. + +**Architecture:** Preserve the Room Router model and sing-box integration. Fix each defect at its owner; retain the 300-second URL_TEST interval and event-driven foreground refresh. + +**Tech Stack:** Kotlin, Android API 21–35, Room, Go/libcore, Gradle. + +**Spec:** User request to review, optimize and repair the project for 1.4.6, with prior authorization to push GitHub. + +## Constraints + +- Preserve existing routing, AdBlock, subscriptions and configuration files; do not inspect A7/isA8. +- Keep runtime failures distinct from dependency update and style advisories. Do not broadly suppress real lint defects. +- Device behavior and battery measurements require a connected phone; a build cannot prove them. + +## Tasks + +- [ ] Fix lint-confirmed API/locale/format/accessibility defects in UI, resources and format parsers. Re-run `:app:lintOssDebug --offline`. +- [ ] Review Router refresh/delete/import invariants; add focused regression tests for confirmed defects. +- [ ] Repair service startup cancellation and shutdown ordering in `BaseService.kt` / `ProxyInstance.kt`; keep native core alive until traffic sampling stops. +- [ ] Fix callback cleanup/concurrency and wake the traffic loop on foreground entry in `BaseService.kt` / `TrafficLooper.kt`. Preserve background throttling and update URL_TEST display even with speed display disabled. +- [ ] Check network URL_TEST refresh against bundled core implementation and address confirmed lifetime issues. +- [ ] Run JVM tests, Android-test compilation, lint and debug APK build; inspect packaged version/native libraries and record results. +- [ ] Set `nb4a.properties` to 1.4.6 / code 49, write release verification notes, commit and push `origin/router-groups`; verify remote hash. + +## Review checkpoints + +Each patch must link to a concrete failure or traced lifecycle. Review parallel edits before testing. Retain non-blocking maintenance advisories in the report. Do not declare all possible bugs eliminated. diff --git a/libcore/box.go b/libcore/box.go index 4410aa8006..e29aa12047 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -95,6 +95,7 @@ func NewSingBoxInstance(config string, localTransport LocalDNSTransport) (b *Box var options option.Options err = options.UnmarshalJSONContext(ctx, []byte(config)) if err != nil { + cancel() return nil, fmt.Errorf("decode config: %v", err) } @@ -207,6 +208,11 @@ func (b *BoxInstance) QueryStats(tag, direct string) int64 { } func (b *BoxInstance) SelectOutbound(tag string) bool { + b.access.Lock() + defer b.access.Unlock() + if b.state != 1 { + return false + } if b.selector != nil { return b.selector.SelectOutbound(tag) } @@ -214,6 +220,11 @@ func (b *BoxInstance) SelectOutbound(tag string) bool { } func (b *BoxInstance) SelectOutboundFor(selectorTag, tag string) bool { + b.access.Lock() + defer b.access.Unlock() + if b.state != 1 { + return false + } proxy, ok := b.Outbound().Outbound(selectorTag) if !ok { return false @@ -226,6 +237,11 @@ func (b *BoxInstance) SelectOutboundFor(selectorTag, tag string) bool { } func (b *BoxInstance) CurrentOutboundFor(groupTag string) string { + b.access.Lock() + defer b.access.Unlock() + if b.state != 1 { + return "" + } proxy, ok := b.Outbound().Outbound(groupTag) if !ok { return "" @@ -241,6 +257,11 @@ func (b *BoxInstance) CurrentOutboundFor(groupTag string) string { } func (b *BoxInstance) RefreshURLTestFor(groupTag string) bool { + b.access.Lock() + defer b.access.Unlock() + if b.state != 1 { + return false + } proxy, ok := b.Outbound().Outbound(groupTag) if !ok { return false @@ -249,7 +270,9 @@ func (b *BoxInstance) RefreshURLTestFor(groupTag string) bool { if !ok { return false } - urlTest.CheckOutbounds() + // The group's context is cancelled by Close. Do not hold up Android's + // caller or core shutdown while probes wait for unreachable nodes. + go urlTest.CheckOutbounds() return true } diff --git a/nb4a.properties b/nb4a.properties index 82fd56bc20..e5bcb76535 100644 --- a/nb4a.properties +++ b/nb4a.properties @@ -1,4 +1,4 @@ PACKAGE_NAME=com.nb4a -VERSION_NAME=1.4.5 -PRE_VERSION_NAME=pre-1.4.5-20260904-1 -VERSION_CODE=48 +VERSION_NAME=1.4.6 +PRE_VERSION_NAME=pre-1.4.6-20260905-1 +VERSION_CODE=49 From 6c2ecb65cca915cd11e4677ebe22e59cca04a71b Mon Sep 17 00:00:00 2001 From: Gitefy Date: Sat, 5 Sep 2026 11:21:28 +0800 Subject: [PATCH 12/29] fix: ensure node groups statically display nodes on homepage upon app open - Load profiles immediately on GroupFragment.onViewCreated instead of relying on onResume - Replace fragile configurationListView.size == 0 check with adapter.itemCount check - Use runOnMainDispatcher for ConfigurationAdapter UI updates to prevent View.post queue drops - Auto-reconcile router members on startup, pager reload, proxy import, and profile creation - Add fallback reconciliation in reloadProfiles if router members are empty but proxies exist - Broadcast routerGroupsUpdated via GroupManager.Listener on reconciliation completion - Fix router group mode filtering in ConfigurationAdapter onAdd, onUpdated, and onRemoved - Add GroupManagerListenerTest unit test verifying routerGroupsUpdated callback --- .../sagernet/database/GroupManager.kt | 2 + .../sagernet/database/ProfileManager.kt | 7 +- .../sagernet/ui/ConfigurationFragment.kt | 91 ++++++++++++++++--- .../nekohasekai/sagernet/ui/MainActivity.kt | 9 ++ .../database/GroupManagerListenerTest.kt | 43 +++++++++ 5 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/io/nekohasekai/sagernet/database/GroupManagerListenerTest.kt 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 594d0ebb22..140dabaca4 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt @@ -28,6 +28,7 @@ object GroupManager { suspend fun groupRemoved(groupId: Long) suspend fun groupUpdated(groupId: Long) + suspend fun routerGroupsUpdated() = Unit } interface Interface { @@ -234,6 +235,7 @@ object GroupManager { } } cleanupDanglingRouterMembers() + iterator { routerGroupsUpdated() } } fun markRouterRefreshFailed(sourceGroupId: Long, message: String) { 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 384c56016e..d5cd4a8b27 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -72,7 +72,7 @@ object ProfileManager { } } - suspend fun createProfile(groupId: Long, bean: AbstractBean): ProxyEntity { + suspend fun createProfile(groupId: Long, bean: AbstractBean, reconcile: Boolean = true): ProxyEntity { bean.applyDefaultValues() val profile = ProxyEntity(groupId = groupId).apply { @@ -81,6 +81,11 @@ object ProfileManager { userOrder = SagerDatabase.proxyDao.nextOrder(groupId) ?: 1 } profile.id = SagerDatabase.proxyDao.addProxy(profile) + if (reconcile && RouterGroupRepository.all().isNotEmpty()) { + runCatching { + GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers()) + } + } iterator { onAdd(profile) } return profile } 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 e3ec619e8c..f46888ceaa 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -114,6 +114,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.isActive import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -571,7 +572,12 @@ class ConfigurationFragment @JvmOverloads constructor( suspend fun import(proxies: List) { val targetId = DataStore.selectedGroupForImport() for (proxy in proxies) { - ProfileManager.createProfile(targetId, proxy) + ProfileManager.createProfile(targetId, proxy, reconcile = false) + } + if (RouterGroupRepository.all().isNotEmpty()) { + runCatching { + GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers()) + } } onMainDispatcher { DataStore.editingGroup = targetId @@ -1339,6 +1345,11 @@ class ConfigurationFragment @JvmOverloads constructor( if (wantRouterMode) { // ----- Router-group mode ----- + if (SagerDatabase.routerMemberDao.all().isEmpty() && SagerDatabase.proxyDao.getAll().isNotEmpty()) { + runCatching { + GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers()) + } + } val newRouterList = ArrayList(RouterGroupRepository.all()) if (generation != reloadGeneration.get()) return@runOnDefaultDispatcher @@ -1524,6 +1535,11 @@ class ConfigurationFragment @JvmOverloads constructor( override suspend fun groupUpdated(groupId: Long) = Unit + override suspend fun routerGroupsUpdated() { + if (!inRouterGroupMode) return + refreshRouterGroupSelections() + } + override suspend fun onAdd(profile: ProxyEntity) { if (inRouterGroupMode) return if (groupList.find { it.id == profile.groupId } == null) { @@ -1695,7 +1711,7 @@ class ConfigurationFragment @JvmOverloads constructor( override fun onResume() { super.onResume() - if (::configurationListView.isInitialized && configurationListView.size == 0) { + if (::configurationListView.isInitialized && (adapter?.itemCount ?: 0) == 0) { configurationListView.adapter = adapter runOnDefaultDispatcher { adapter?.reloadProfiles() @@ -1838,6 +1854,15 @@ class ConfigurationFragment @JvmOverloads constructor( } }) + val adapterKey = if (inRouterGroupMode) { + routerGroup?.id?.let { -it } + } else { + proxyGroup.id + } + if (adapterKey != null) { + (parentFragment as? ConfigurationFragment)?.adapter?.groupFragments?.put(adapterKey, this) + } + if (!select) { if (!inRouterGroupMode) { undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!) @@ -1845,6 +1870,10 @@ class ConfigurationFragment @JvmOverloads constructor( setupItemTouchHelper() setupBottomBarScrollDriver() } + + runOnDefaultDispatcher { + adapter?.reloadProfiles() + } } private fun setupBottomBarScrollDriver() { @@ -1879,6 +1908,15 @@ class ConfigurationFragment @JvmOverloads constructor( } override fun onDestroyView() { + val adapterKey = if (inRouterGroupMode) { + routerGroup?.id?.let { -it } + } else if (::proxyGroup.isInitialized) { + proxyGroup.id + } else null + if (adapterKey != null) { + (parentFragment as? ConfigurationFragment)?.adapter?.groupFragments?.remove(adapterKey) + } + adapter?.let { ProfileManager.removeListener(it) GroupManager.removeListener(it) @@ -2252,9 +2290,16 @@ class ConfigurationFragment @JvmOverloads constructor( } override suspend fun onAdd(profile: ProxyEntity) { - if (profile.groupId != proxyGroup.id) return + if (!inRouterGroupMode) { + if (profile.groupId != proxyGroup.id) return + } else { + val rg = routerGroup ?: return + val isMember = SagerDatabase.routerMemberDao.getByRouter(rg.id).any { it.proxyId == profile.id } + if (!isMember) return + } - configurationListView.post { + runOnMainDispatcher { + if (!isAdded || !::configurationListView.isInitialized) return@runOnMainDispatcher if (::undoManager.isInitialized) { undoManager.flush() } @@ -2267,13 +2312,15 @@ class ConfigurationFragment @JvmOverloads constructor( } override suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) { - if (profile.groupId != proxyGroup.id) return + if (!inRouterGroupMode && profile.groupId != proxyGroup.id) return + if (inRouterGroupMode && !configurationList.containsKey(profile.id)) return if (noTraffic) { (parentFragment as? ConfigurationFragment)?.refreshProfileState() } val index = configurationIdList.indexOf(profile.id) if (index < 0) return - configurationListView.post { + runOnMainDispatcher { + if (!isAdded || !::configurationListView.isInitialized) return@runOnMainDispatcher if (::undoManager.isInitialized) { undoManager.flush() } @@ -2292,7 +2339,7 @@ class ConfigurationFragment @JvmOverloads constructor( cachedProfile.dirty != updatedProfile.dirty || cachedProfile.displayName() != updatedProfile.displayName() configurationList[profile.id] = updatedProfile - if (noTraffic && !contentChanged) return@post + if (noTraffic && !contentChanged) return@runOnMainDispatcher val newHasMiddleRow = hasMiddleRow(updatedProfile) val holder = layoutManager.findViewByPosition(index) @@ -2327,11 +2374,13 @@ class ConfigurationFragment @JvmOverloads constructor( } override suspend fun onRemoved(groupId: Long, profileId: Long) { - if (groupId != proxyGroup.id) return + if (!inRouterGroupMode && groupId != proxyGroup.id) return + if (inRouterGroupMode && !configurationList.containsKey(profileId)) return val index = configurationIdList.indexOf(profileId) if (index < 0) return - configurationListView.post { + runOnMainDispatcher { + if (!isAdded || !::configurationListView.isInitialized) return@runOnMainDispatcher configurationIdList.removeAt(index) configurationList.remove(profileId) notifyItemRemoved(index) @@ -2354,14 +2403,30 @@ class ConfigurationFragment @JvmOverloads constructor( reloadProfiles() } + override suspend fun routerGroupsUpdated() { + if (inRouterGroupMode) { + reloadProfiles() + } + } + fun reloadProfiles() { val rg = routerGroup - val newProfiles: List + var newProfiles: List if (rg != null) { // Router-group mode: load members from the router group - val memberIds = SagerDatabase.routerMemberDao.getByRouter(rg.id) + var memberIds = SagerDatabase.routerMemberDao.getByRouter(rg.id) .sortedBy { it.userOrder } .map { it.proxyId } + if (memberIds.isEmpty() && SagerDatabase.proxyDao.getAll().isNotEmpty()) { + runBlocking { + runCatching { + GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers()) + } + } + memberIds = SagerDatabase.routerMemberDao.getByRouter(rg.id) + .sortedBy { it.userOrder } + .map { it.proxyId } + } val entities = SagerDatabase.proxyDao.getEntities(memberIds).associateBy { it.id } newProfiles = memberIds.mapNotNull { entities[it] } } else { @@ -2394,7 +2459,8 @@ class ConfigurationFragment @JvmOverloads constructor( selectedProfileIndex = newProfileIds.indexOf(selectedProxy) } - configurationListView.post { + runOnMainDispatcher { + if (!isAdded || !::configurationListView.isInitialized) return@runOnMainDispatcher configurationList.clear() configurationList.putAll(newProfileMap) configurationIdList.clear() @@ -2406,7 +2472,6 @@ class ConfigurationFragment @JvmOverloads constructor( } else if (newProfiles.isNotEmpty()) { configurationListView.scrollTo(0, true) } - } } 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 2bf664e482..26e26ce243 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -36,6 +36,7 @@ import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProfileManager import io.nekohasekai.sagernet.database.ProxyGroup import io.nekohasekai.sagernet.database.SubscriptionBean +import io.nekohasekai.sagernet.database.RouterGroupRepository import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener import io.nekohasekai.sagernet.databinding.LayoutMainBinding import io.nekohasekai.sagernet.fmt.AbstractBean @@ -120,6 +121,14 @@ class MainActivity : ThemedActivity(), DataStore.configurationStore.registerChangeListener(this) GroupManager.userInterface = GroupInterfaceAdapter(this) + runOnDefaultDispatcher { + if (RouterGroupRepository.all().isNotEmpty()) { + runCatching { + GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers()) + } + } + } + if (intent?.action == Intent.ACTION_VIEW) { onNewIntent(intent) } diff --git a/app/src/test/java/io/nekohasekai/sagernet/database/GroupManagerListenerTest.kt b/app/src/test/java/io/nekohasekai/sagernet/database/GroupManagerListenerTest.kt new file mode 100644 index 0000000000..db355cf744 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/database/GroupManagerListenerTest.kt @@ -0,0 +1,43 @@ +package io.nekohasekai.sagernet.database + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicBoolean + +class GroupManagerListenerTest { + + @Test + fun defaultRouterGroupsUpdatedCallbackDoesNotThrow() = runBlocking { + val listener = object : GroupManager.Listener { + override suspend fun groupAdd(group: ProxyGroup) = Unit + override suspend fun groupUpdated(group: ProxyGroup) = Unit + override suspend fun groupRemoved(groupId: Long) = Unit + override suspend fun groupUpdated(groupId: Long) = Unit + } + // Default implementation does not throw + listener.routerGroupsUpdated() + } + + @Test + fun iteratorInvokesRouterGroupsUpdated() = runBlocking { + val invoked = AtomicBoolean(false) + val listener = object : GroupManager.Listener { + override suspend fun groupAdd(group: ProxyGroup) = Unit + override suspend fun groupUpdated(group: ProxyGroup) = Unit + override suspend fun groupRemoved(groupId: Long) = Unit + override suspend fun groupUpdated(groupId: Long) = Unit + override suspend fun routerGroupsUpdated() { + invoked.set(true) + } + } + + GroupManager.addListener(listener) + try { + GroupManager.iterator { routerGroupsUpdated() } + assertTrue(invoked.get()) + } finally { + GroupManager.removeListener(listener) + } + } +} From 94707b837ff57fc44a6386fea602acde570485bc Mon Sep 17 00:00:00 2001 From: Gitefy Date: Sat, 5 Sep 2026 11:37:35 +0800 Subject: [PATCH 13/29] feat: prune app architectures, tools network module, webdav backup, and drawer router group - Retain only arm64-v8a ABI across Gradle splits, hev-tunnel, and libcore verification, pruning armeabi-v7a, x86, and x86_64 - Remove the Network module (NetworkFragment, layout_network.xml) from ToolsFragment and hide the single tab strip - Remove WebDAV backup mode, WebDAV settings activity/layout/preferences, and related constants and DataStore accessors - Remove nav_router_group from drawer sidebar menu and MainActivity dispatch (RouterGroupListActivity remains accessible from GroupFragment and ConfigurationFragment) --- app/build.gradle.kts | 7 +- app/src/main/AndroidManifest.xml | 6 - .../java/io/nekohasekai/sagernet/Constants.kt | 5 - .../sagernet/database/DataStore.kt | 16 - .../nekohasekai/sagernet/ui/BackupFragment.kt | 409 +----------------- .../nekohasekai/sagernet/ui/MainActivity.kt | 7 - .../sagernet/ui/NetworkFragment.kt | 23 - .../nekohasekai/sagernet/ui/ToolsFragment.kt | 18 +- .../sagernet/ui/WebDAVSettingsActivity.kt | 220 ---------- app/src/main/res/layout/layout_backup.xml | 66 --- app/src/main/res/layout/layout_network.xml | 62 --- .../res/layout/layout_webdav_settings.xml | 30 -- app/src/main/res/menu/main_drawer_menu.xml | 5 - app/src/main/res/xml/webdav_preferences.xml | 39 -- buildScript/compile-hevtun.sh | 2 +- buildSrc/src/main/kotlin/Helpers.kt | 5 +- 16 files changed, 11 insertions(+), 909 deletions(-) delete mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt delete mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt delete mode 100644 app/src/main/res/layout/layout_network.xml delete mode 100644 app/src/main/res/layout/layout_webdav_settings.xml delete mode 100644 app/src/main/res/xml/webdav_preferences.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5727dd8f63..439d844cae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,7 +104,7 @@ dependencies { } val buildHevTun by tasks.registering { - val hevAbis = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + val hevAbis = listOf("arm64-v8a") val bashExecutable = System.getenv("BASH_EXE") ?.takeIf { file(it).isFile } ?: listOf( @@ -132,10 +132,7 @@ val verifyLibcore by tasks.registering { } val requiredEntries = listOf( "classes.jar", - "jni/armeabi-v7a/libgojni.so", - "jni/arm64-v8a/libgojni.so", - "jni/x86/libgojni.so", - "jni/x86_64/libgojni.so" + "jni/arm64-v8a/libgojni.so" ) ZipFile(libcoreAar).use { archive -> val missingEntries = requiredEntries.filter { archive.getEntry(it) == null } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e35fc16813..5ee8cd2c3f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -289,12 +289,6 @@ android:launchMode="singleInstance" android:theme="@style/Theme.SagerNet.Dialog" /> - - - addPathSegment(segment) - } - }.build() - - val fileUrl = dirUrl.newBuilder() - .addPathSegment(fileName) - .build() - - Logs.d("WebDAV backup - Directory URL: $dirUrl") - Logs.d("WebDAV backup - File URL: $fileUrl") - - // 先检查目录是否存在 - val propfindRequest = Request.Builder() - .url(dirUrl) - .method("PROPFIND", null) - .header("Authorization", Credentials.basic( - DataStore.webdavUsername ?: "", - DataStore.webdavPassword ?: "" - )) - .header("Depth", "0") - .build() - - var needCreateDir = false - client.newCall(propfindRequest).execute().use { response -> - Logs.d("WebDAV backup - PROPFIND response: ${response.code}") - when (response.code) { - 404 -> needCreateDir = true - 207 -> needCreateDir = false // 目录存在 - 401 -> throw Exception("Authentication failed") - else -> { - if (!response.isSuccessful) { - val errorBody = response.body?.string() - Logs.e("WebDAV backup - PROPFIND error: $errorBody") - throw Exception("Failed to check directory (${response.code}): ${response.message}") - } - } - } - } - - // 如果需要,创建目录 - if (needCreateDir) { - Logs.d("WebDAV backup - Creating directory") - val mkcolRequest = Request.Builder() - .url(dirUrl) - .method("MKCOL", null) - .header("Authorization", Credentials.basic( - DataStore.webdavUsername ?: "", - DataStore.webdavPassword ?: "" - )) - .build() - - client.newCall(mkcolRequest).execute().use { response -> - if (!response.isSuccessful) { - val errorBody = response.body?.string() - Logs.e("WebDAV backup - MKCOL error: $errorBody") - throw Exception("Failed to create directory (${response.code}): ${response.message}") - } - } - } - - // 上传文件时使用正确的 Content-Type - val putRequest = Request.Builder() - .url(fileUrl) - .put(backupData.toRequestBody("application/zip".toMediaType())) - .apply { - header("Authorization", Credentials.basic( - DataStore.webdavUsername ?: "", - DataStore.webdavPassword ?: "" - )) - } - .build() - - client.newCall(putRequest).execute().use { response -> - if (!response.isSuccessful) { - val errorBody = response.body?.string() - Logs.e("WebDAV backup - PUT error: $errorBody") - throw Exception("Upload failed (${response.code}): ${response.message}\n$errorBody") - } - Logs.d("WebDAV backup - Upload successful") - } - - onMainDispatcher { - MessageStore.showMessage(activity, R.string.webdav_backup_success) - } - } catch (e: Exception) { - isWebDAVBackup = false // 确保发生异常时也重置标志 - Logs.w(e) - - val errorMessage = try { - if (isAdded) { - getString(R.string.webdav_backup_failed, e.message ?: "") - } else { - app.getString(R.string.webdav_backup_failed, e.message ?: "") - } - } catch (ex: Exception) { - "WebDAV backup failed: ${e.message ?: ""}" - } - - onMainDispatcher { - MessageStore.showMessage(activity, errorMessage) - } - } finally { - isBackupInProgress = false - } - } - } - - private fun restoreFromWebDAV() { - if (isRestoreInProgress) { - showMessage(R.string.restore_in_progress) - return - } - isRestoreInProgress = true - val activity = requireActivity() - restoreJob = runOnDefaultDispatcher { - try { - val client = OkHttpClient() - val baseUrl = DataStore.webdavServer!!.trimEnd('/') - val path = DataStore.webdavPath?.trim('/')?.takeIf { it.isNotEmpty() } ?: "Nekobox" - - if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) { - throw Exception("Invalid server URL: must start with http:// or https://") - } - - val baseHttpUrl = baseUrl.toHttpUrlOrNull() - ?: throw Exception("Invalid server URL: $baseUrl") - - val dirUrl = baseHttpUrl.newBuilder().apply { - path.split('/').filter { it.isNotEmpty() }.forEach { segment -> - addPathSegment(segment) - } - }.build() - - Logs.d("WebDAV restore - Directory URL: $dirUrl") - - // 先列出目录内容找到最新的备份文件 - val propfindRequest = Request.Builder() - .url(dirUrl) - .method("PROPFIND", null) - .header("Authorization", Credentials.basic( - DataStore.webdavUsername ?: "", - DataStore.webdavPassword ?: "" - )) - .header("Depth", "1") - .build() - - // 获取最新的备份文件名 - val latestBackup = client.newCall(propfindRequest).execute().use { response -> - if (!response.isSuccessful && response.code != 207) { - val errorBody = response.body?.string() - Logs.e("WebDAV restore - PROPFIND error: $errorBody") - throw Exception("Failed to list directory: ${response.message}") - } - - val responseBody = response.body?.string() ?: throw Exception("Empty response") - Logs.d("WebDAV restore - Directory listing: $responseBody") - - val patterns = listOf( - """[^<]*?nekobox_backup_[^<]*?\d{8}_\d{6}\.(json|zip)""".toRegex(), - """[^<]*?nekobox_backup_[^<]*?\d{8}_\d{6}\.(json|zip)""".toRegex(), - """[^<]*?nekobox_backup_[^<]*?\d{8}_\d{6}\.(json|zip)""".toRegex() - ) - - val backupFiles = mutableListOf() - - for (pattern in patterns) { - val matches = pattern.findAll(responseBody) - matches.forEach { match -> - val href = match.value - Logs.d("WebDAV restore - Found backup file with pattern ${pattern.pattern}: $href") - val fileName = """nekobox_backup_[^<]*?\d{8}_\d{6}\.(json|zip)""".toRegex() - .find(href)?.value - if (fileName != null) { - backupFiles.add(fileName) - } - } - if (backupFiles.isNotEmpty()) break - } - - Logs.d("WebDAV restore - Found ${backupFiles.size} backup files: ${backupFiles.joinToString()}") - - backupFiles.maxByOrNull { fileName -> - """(\d{8}_\d{6})""".toRegex().find(fileName)?.value ?: "" - } ?: throw Exception("No backup found") - } - - // 下载最新的备份文件 - val fileUrl = dirUrl.newBuilder() - .addPathSegment(latestBackup) - .build() - Logs.d("WebDAV restore - File URL: $fileUrl") - - val getRequest = Request.Builder() - .url(fileUrl) - .get() - .header("Authorization", Credentials.basic( - DataStore.webdavUsername ?: "", - DataStore.webdavPassword ?: "" - )) - .build() - - val content = client.newCall(getRequest).execute().use { response -> - if (!response.isSuccessful) { - val errorBody = response.body?.string() - Logs.e("WebDAV restore - GET error: $errorBody") - throw Exception("Download failed (${response.code}): ${response.message}") - } - response.body?.bytes() ?: throw Exception("Empty backup file") - } - - Logs.d("WebDAV restore - Successfully downloaded backup file, size: ${content.size}") - - // 根据文件类型处理内容 - val backupContent = if (latestBackup.endsWith(".zip")) { - // ZIP 文件处理 - ZipInputStream(content.inputStream()).use { zis -> - zis.nextEntry?.let { entry -> - if (entry.name.endsWith(".json")) { - zis.readBytes().toString(Charsets.UTF_8) - } else { - throw Exception("Invalid backup file format") - } - } ?: throw Exception("Invalid backup file format") - } - } else { - // JSON 文件处理 - content.toString(Charsets.UTF_8) - } - - // 解析并导入备份数据 - val json = JSONObject(backupContent) - onMainDispatcher { - // 如果 Fragment 已经被销毁,取消恢复操作 - if (!isAdded) { - MessageStore.showMessage(activity, R.string.restore_cancelled) - return@onMainDispatcher - } - - val import = LayoutImportBinding.inflate(layoutInflater) - if (!json.has("profiles")) { - import.backupConfigurations.isVisible = false - } - if (!json.has("rules")) { - import.backupRules.isVisible = false - } - if (!json.has("settings")) { - import.backupSettings.isVisible = false - } - - MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.backup_import) - .setView(import.root) - .setPositiveButton(R.string.backup_import) { _, _ -> - SagerNet.stopService() - - val binding = LayoutProgressBinding.inflate(layoutInflater) - binding.content.text = getString(R.string.backup_importing) - val dialog = AlertDialog.Builder(requireContext()) - .setView(binding.root) - .setCancelable(false) - .show() - runOnDefaultDispatcher { - runCatching { - // 再次检查是否已被取消 - if (!isAdded) { - MessageStore.showMessage(activity, R.string.restore_cancelled) - return@runOnDefaultDispatcher - } - finishImport( - json, - import.backupConfigurations.isChecked, - import.backupRules.isChecked, - import.backupSettings.isChecked - ) - ProcessPhoenix.triggerRebirth( - activity, Intent(activity, MainActivity::class.java) - ) - }.onFailure { - Logs.w(it) - onMainDispatcher { - MessageStore.showMessage(activity, it.readableMessage) - } - } - - onMainDispatcher { - dialog.dismiss() - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - } catch (e: Exception) { - Logs.w(e) - onMainDispatcher { - MessageStore.showMessage(activity, e.readableMessage) - } - } finally { - isRestoreInProgress = false - } - } } private fun doBackup( @@ -543,30 +159,7 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) { } val jsonContent = out.toStringPretty() - return if (isWebDAVBackup) { - ByteArrayOutputStream().use { bos -> - ZipOutputStream(bos).use { zos -> - zos.setLevel(Deflater.BEST_COMPRESSION) - - val entry = ZipEntry("nekobox_backup.json").apply { - method = ZipEntry.DEFLATED - } - - // 写入数据 - zos.putNextEntry(entry) - val bytes = jsonContent.toByteArray(Charsets.UTF_8) - zos.write(bytes) - zos.closeEntry() - - // 确保所有数据都被写入和压缩 - zos.finish() - } - bos.toByteArray() - } - } else { - // 本地导出和分享功能使用 JSON 格式 - jsonContent.toByteArray() - } + return jsonContent.toByteArray() } val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> 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 26e26ce243..0e6ee02f1d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -431,13 +431,6 @@ class MainActivity : ThemedActivity(), fun displayFragmentWithId(@IdRes id: Int): Boolean { when (id) { - R.id.nav_router_group -> { - // Open the router group manager as an Activity - binding.drawerLayout.closeDrawers() - startActivity(Intent(this, RouterGroupListActivity::class.java)) - return true - } - R.id.nav_configuration -> { displayFragment(ConfigurationFragment()) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt deleted file mode 100644 index b8bb941451..0000000000 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.nekohasekai.sagernet.ui - -import android.content.Intent -import android.os.Bundle -import android.view.View -import io.nekohasekai.sagernet.R -import io.nekohasekai.sagernet.databinding.LayoutNetworkBinding -import io.nekohasekai.sagernet.ktx.app - -class NetworkFragment : NamedFragment(R.layout.layout_network) { - - override fun name0() = app.getString(R.string.tools_network) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val binding = LayoutNetworkBinding.bind(view) - binding.stunTest.setOnClickListener { - startActivity(Intent(requireContext(), StunActivity::class.java)) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt index 32738ea98f..aacc2b00f5 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt @@ -2,9 +2,9 @@ package io.nekohasekai.sagernet.ui import android.os.Bundle import android.view.View +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter -import com.google.android.material.tabs.TabLayoutMediator import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.databinding.LayoutToolsBinding @@ -14,19 +14,13 @@ class ToolsFragment : ToolbarFragment(R.layout.layout_tools) { super.onViewCreated(view, savedInstanceState) toolbar.setTitle(R.string.menu_tools) - val tools = mutableListOf() - tools.add(NetworkFragment()) - tools.add(BackupFragment()) + val tools = listOf( + BackupFragment() + ) val binding = LayoutToolsBinding.bind(view) + binding.toolsTab.isVisible = false binding.toolsPager.adapter = ToolsAdapter(tools) - - TabLayoutMediator(binding.toolsTab, binding.toolsPager) { tab, position -> - tab.text = tools[position].name() - tab.view.setOnLongClickListener { // clear toast - true - } - }.attach() } inner class ToolsAdapter(val tools: List) : FragmentStateAdapter(this) { @@ -36,4 +30,4 @@ class ToolsFragment : ToolbarFragment(R.layout.layout_tools) { override fun createFragment(position: Int) = tools[position] } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt deleted file mode 100644 index 5fd899ad9d..0000000000 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt +++ /dev/null @@ -1,220 +0,0 @@ -package io.nekohasekai.sagernet.ui - -import android.os.Bundle -import android.view.MenuItem -import android.text.InputType -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.Toolbar -import androidx.preference.EditTextPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceDataStore -import io.nekohasekai.sagernet.R -import io.nekohasekai.sagernet.database.DataStore -import io.nekohasekai.sagernet.ktx.onMainDispatcher -import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher -import io.nekohasekai.sagernet.ktx.snackbar -import kotlinx.coroutines.launch -import okhttp3.Credentials -import okhttp3.OkHttpClient -import okhttp3.Request -import java.net.URL -import com.google.android.material.snackbar.Snackbar -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull - -class WebDAVSettingsActivity : ThemedActivity() { - - private lateinit var toolbar: Toolbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContentView(R.layout.layout_webdav_settings) - toolbar = findViewById(R.id.toolbar) - setSupportActionBar(toolbar) - supportActionBar?.apply { - setTitle(R.string.webdav_settings) - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(R.drawable.ic_navigation_close) - } - - supportFragmentManager.beginTransaction() - .replace(R.id.settings, WebDAVSettingsFragment()) - .commit() - } - - override fun onSupportNavigateUp(): Boolean { - finish() - return true - } - - class WebDAVSettingsFragment : PreferenceFragmentCompat(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { - private var lastClickTime = 0L - private val DEBOUNCE_TIME = 1000L // 1秒内不允许重复点击 - private var isFragmentAlive = true - - private fun isClickAllowed(): Boolean { - val currentTime = System.currentTimeMillis() - val isAllowed = currentTime - lastClickTime > DEBOUNCE_TIME - if (isAllowed) { - lastClickTime = currentTime - } - return isAllowed - } - - override fun onDestroy() { - isFragmentAlive = false - super.onDestroy() - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.preferenceDataStore = DataStore.configurationStore - addPreferencesFromResource(R.xml.webdav_preferences) - - findPreference("webdavServer")?.apply { - setOnBindEditTextListener { editText -> - editText.setSingleLine() - editText.setSelection(editText.text.length) - } - summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() - } - - findPreference("webdavUsername")?.apply { - setOnBindEditTextListener { editText -> - editText.setSingleLine() - editText.setSelection(editText.text.length) - } - summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() - } - - findPreference("webdavPassword")?.apply { - setOnBindEditTextListener { editText -> - editText.setSingleLine() - editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - editText.setSelection(editText.text.length) - } - // 使用与其他密码字段一致的隐藏摘要样式 - summaryProvider = GroupSettingsActivity.PasswordSummaryProvider - } - - findPreference("webdavPath")?.apply { - setOnBindEditTextListener { editText -> - editText.setSingleLine() - editText.setSelection(editText.text.length) - } - summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() - } - - findPreference("webdavTest")?.setOnPreferenceClickListener { - if (isClickAllowed()) { - testWebDAV() - } else { - Snackbar.make(requireView(), "请稍后再试", Snackbar.LENGTH_SHORT).show() - } - true - } - } - - private fun testWebDAV() { - runOnDefaultDispatcher { - try { - val server = DataStore.webdavServer ?: "" - if (server.isBlank()) { - throw Exception(getString(R.string.webdav_server_empty)) - } - - val url = URL(server) - val client = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .build() - - // 首先测试连接和认证 - val authRequest = Request.Builder() - .url(url) - .method("PROPFIND", null) - .apply { - val credentials = Credentials.basic( - DataStore.webdavUsername ?: "", - DataStore.webdavPassword ?: "" - ) - header("Authorization", credentials) - header("Depth", "0") - } - .build() - - client.newCall(authRequest).execute().use { response -> - when (response.code) { - 401 -> throw Exception(getString(R.string.webdav_auth_error)) - 403 -> throw Exception(getString(R.string.webdav_permission_denied)) - 404 -> throw Exception(getString(R.string.webdav_server_not_found)) - in 500..599 -> throw Exception(getString(R.string.webdav_server_error)) - } - - if (!response.isSuccessful) { - throw Exception(getString(R.string.webdav_connect_failed, response.code)) - } - } - - // 如果认证成功,再测试目录操作 - val path = (DataStore.webdavPath ?: "").trim('/') - if (path.isNotBlank()) { - val baseHttpUrl = server.toHttpUrlOrNull() - ?: throw Exception(getString(R.string.webdav_server_not_found)) - - val dirUrl = baseHttpUrl.newBuilder().apply { - path.split('/').filter { it.isNotEmpty() }.forEach { segment -> - addPathSegment(segment) - } - }.build() - - val dirRequest = Request.Builder() - .url(dirUrl) - .method("MKCOL", null) - .apply { - val credentials = Credentials.basic( - DataStore.webdavUsername ?: "", - DataStore.webdavPassword ?: "" - ) - header("Authorization", credentials) - } - .build() - - client.newCall(dirRequest).execute().use { dirResponse -> - if (!dirResponse.isSuccessful && dirResponse.code != 405) { // 405 表示目录已存在 - throw Exception(getString(R.string.webdav_create_dir_failed)) - } - } - } - - onMainDispatcher { - if (!isFragmentAlive) return@onMainDispatcher - Snackbar.make( - requireView(), - getString(R.string.webdav_test_success), - Snackbar.LENGTH_SHORT - ).show() - } - } catch (e: Exception) { - onMainDispatcher { - if (!isFragmentAlive) return@onMainDispatcher - Snackbar.make( - requireView(), - getString(R.string.webdav_test_failed, e.message), - Snackbar.LENGTH_SHORT - ).show() - } - } - } - } - - override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean { - return false - } - } -} diff --git a/app/src/main/res/layout/layout_backup.xml b/app/src/main/res/layout/layout_backup.xml index 4f85a5825f..92f8a39536 100644 --- a/app/src/main/res/layout/layout_backup.xml +++ b/app/src/main/res/layout/layout_backup.xml @@ -105,71 +105,5 @@ - - - - - - - - - - - - - - -