Skip to content

feat(fmt): import Hysteria 2 JSON configs (parseHysteria2Json) - #92

Merged
hawkff merged 2 commits into
mainfrom
feat/022-hysteria2-json-import
Jun 26, 2026
Merged

hawkff merged 2 commits into
mainfrom
feat/022-hysteria2-json-import

Conversation

@hawkff

@hawkff hawkff commented Jun 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

HysteriaFmt's JSON import only handled HY1 (parseHysteria1Json hard-coded protocolVersion=1 with a "TODO parse HY2 JSON+YAML"). Standalone HY2 JSON configs (a common distribution form, and a marketed headline feature) fell through to the HY1 parser and imported wrong. (Plan 022 spike → implemented.)

Changes

  • parseHysteria2Json: maps the official HY2 client config schema → HysteriaBean (server w/ default port 443 + IPv6-safe split, string auth, tls {sni,insecure}, obfs {salamander|gecko}, bandwidth {up,down} accepting bare ints or unit-suffixed strings like "100 mbps"/"1 gbps").
  • RawUpdater.parseJSON: HY2 detection = server + string auth + !(up||up_mbps) ahead of the HY1 branch (HY1 detection unchanged).
  • Robolectric tests (core fields, salamander, gecko, bare-host default port, bandwidth string units). Establishes the repo's Robolectric pattern (@Config(sdk=[33], application=Application::class)).

Testing

  • Verified on Android: testOssDebugUnitTest green on Depot CI. CodeRabbit clean (3 review findings fixed: IPv6/default-port, bandwidth string units, relaxed discriminator). Spike findings recorded in the plan file.

Greptile Summary

This PR adds parseHysteria2Json to import standalone HY2 JSON client configs that were previously falling through to the HY1 parser and being mis-imported. A new discriminator branch in RawUpdater.parseJSON detects HY2 configs by the presence of server + string auth and the absence of HY1-only up/up_mbps keys.

  • parseHysteria2Json maps the official HY2 schema (server with IPv6-safe port splitting, string auth, tls, salamander/gecko obfs, bandwidth accepting bare ints or unit-suffixed strings) into HysteriaBean with protocolVersion = 2.
  • parseBandwidthMbps normalizes bandwidth strings (\"100 mbps\", \"1 gbps\", bare ints) to integer Mbps, clamped to ≥ 0.
  • Robolectric unit tests cover core fields, both obfs types, bare-host default port 443, and bandwidth string units, establishing the project's Robolectric test pattern.

Confidence Score: 4/5

Safe to merge; the new parser and discriminator correctly handle the documented HY2 schema, with only minor polish items remaining.

The implementation is well-scoped and the IPv6/port logic, obfs dispatch, and bandwidth normalisation are all correct for the inputs the tests cover. The three findings are non-blocking: a regex compiled on every call, a silent import failure for the unlikely case of a blank auth field, and a missing test for an IPv6-with-port server string that the code already handles correctly.

The discriminator in RawUpdater.kt and the getStr blank-string behaviour are worth a second look together, but neither requires a fix before merging.

Important Files Changed

Filename Overview
app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt Adds parseHysteria2Json (IPv6-safe server split, string auth, tls/obfs/bandwidth) and private parseBandwidthMbps helper; regex is recompiled on every call and the getStr blank-auth edge case is undocumented.
app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt Adds HY2 discriminator branch (server + non-blank string auth + no up/up_mbps) ahead of the existing HY1 check; ordering and logic look correct for the documented schema distinctions.
app/src/test/java/io/nekohasekai/sagernet/fmt/HysteriaFmtTest.kt New Robolectric test class covering core fields, salamander, gecko, bare-host default port, and bandwidth string units; IPv6 with explicit port is not tested.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[parseJSON receives JSONObject] --> B{has 'server' AND\nhas 'up' OR 'up_mbps'?}
    B -- Yes --> C[parseHysteria1Json\nprotocolVersion=1]
    B -- No --> D{has 'server' AND\ngetStr 'auth' != null AND\nno 'up' / 'up_mbps'?}
    D -- Yes --> E[parseHysteria2Json\nprotocolVersion=2]
    E --> F[Parse server:\nIPv6-safe lastColon split\ndefault port 443]
    F --> G[auth → TYPE_STRING\nauthPayload]
    G --> H[tls block:\nsni / allowInsecure]
    H --> I{obfs type?}
    I -- salamander --> J[OBFS_SALAMANDER\nset obfuscation]
    I -- gecko --> K[OBFS_GECKO\npassword + min/max pkt size]
    I -- else/none --> L[OBFS_NONE]
    J & K & L --> M[bandwidth block:\nparseBandwidthMbps\nbps/kbps/mbps/gbps/tbps → Mbps int]
    D -- No --> N[Continue to other\nprotocol branches\nSSR / SS / TrojanGo / ...]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[parseJSON receives JSONObject] --> B{has 'server' AND\nhas 'up' OR 'up_mbps'?}
    B -- Yes --> C[parseHysteria1Json\nprotocolVersion=1]
    B -- No --> D{has 'server' AND\ngetStr 'auth' != null AND\nno 'up' / 'up_mbps'?}
    D -- Yes --> E[parseHysteria2Json\nprotocolVersion=2]
    E --> F[Parse server:\nIPv6-safe lastColon split\ndefault port 443]
    F --> G[auth → TYPE_STRING\nauthPayload]
    G --> H[tls block:\nsni / allowInsecure]
    H --> I{obfs type?}
    I -- salamander --> J[OBFS_SALAMANDER\nset obfuscation]
    I -- gecko --> K[OBFS_GECKO\npassword + min/max pkt size]
    I -- else/none --> L[OBFS_NONE]
    J & K & L --> M[bandwidth block:\nparseBandwidthMbps\nbps/kbps/mbps/gbps/tbps → Mbps int]
    D -- No --> N[Continue to other\nprotocol branches\nSSR / SS / TrojanGo / ...]
Loading

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

hawkff added 2 commits June 26, 2026 16:05
HysteriaFmt's JSON import only handled HY1 - parseHysteria1Json hard-coded
protocolVersion=1 with a 'TODO parse HY2 JSON+YAML'. Standalone HY2 JSON configs (a common
distribution form) fell through to the HY1 parser and imported wrong.

- Add parseHysteria2Json mapping the official HY2 client config schema (server, auth, tls
  {sni,insecure}, obfs {salamander|gecko}, bandwidth {up,down}) to HysteriaBean, mirroring
  parseHysteria2(url).
- RawUpdater.parseJSON: add an HY2 detection branch (server && auth && (tls||obfs||bandwidth))
  ahead of the HY1 branch; HY1 detection (up/up_mbps) is unchanged.
- Robolectric tests for core fields + salamander + gecko obfs (org.json needs Robolectric).
- Clash/Mihomo YAML HY2 already flows through the Clash branch, so no duplication.

Spike findings recorded in plans/022-hysteria2-json-import.md.
@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Hysteria 2 JSON parsing for client configs, routes matching subscription JSON objects to the new parser in RawUpdater, and adds tests covering core fields, obfuscation variants, default server ports, and bandwidth unit conversion.

Changes

Hysteria 2 JSON import

Layer / File(s) Summary
HY2 parser and bandwidth normalization
app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt
Adds parseHysteria2Json() and parseBandwidthMbps() to map HY2 JSON into HysteriaBean, including server parsing, TLS fields, obfs mapping, bandwidth conversion, and optional names.
RawUpdater HY2 detection
app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
Imports the new parser and adds a parseJSON branch for HY2 objects with server and auth but without up or up_mbps.
HY2 parser tests
app/src/test/java/io/nekohasekai/sagernet/fmt/HysteriaFmtTest.kt
Adds Robolectric tests for core field mapping, obfs variants, default port handling, and bandwidth unit conversion.

Sequence Diagram(s)

sequenceDiagram
  participant RawUpdater
  participant JSONObject
  participant HysteriaBean
  RawUpdater->>JSONObject: parseJSON matches HY2 server/auth object
  JSONObject->>HysteriaBean: parseHysteria2Json() populates protocolVersion, server, auth, tls, obfs, bandwidth
  HysteriaBean-->>RawUpdater: return parsed bean
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I hopped through JSON fields tonight,
sniffing ports and bandwidth just right.
Gecko and salamander, side by side,
443 stays near when hosts are bare inside.
Hoppity hop — Hysteria 2 takes flight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding Hysteria 2 JSON import support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description matches the code changes and clearly explains the new Hysteria 2 JSON import support and tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

Comment on lines +295 to +299
/** Read a HY2 bandwidth value as Mbps: a bare int, or a unit-suffixed string (bps/kbps/mbps/gbps/tbps). */
private fun parseBandwidthMbps(obj: JSONObject, key: String): Int? {
obj.getIntNya(key)?.let { return it }
val raw = obj.getStr(key)?.trim()?.lowercase() ?: return null
val match = Regex("""^(\d+(?:\.\d+)?)\s*([a-z]*)$""").matchEntire(raw) ?: return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Regex(...) is compiled into a new Pattern object on every invocation of parseBandwidthMbps. Because this function is called twice per bean (once for "up", once for "down"), every import re-compiles the same pattern. Lifting it to a top-level val compiles it once at class-load time.

Suggested change
/** Read a HY2 bandwidth value as Mbps: a bare int, or a unit-suffixed string (bps/kbps/mbps/gbps/tbps). */
private fun parseBandwidthMbps(obj: JSONObject, key: String): Int? {
obj.getIntNya(key)?.let { return it }
val raw = obj.getStr(key)?.trim()?.lowercase() ?: return null
val match = Regex("""^(\d+(?:\.\d+)?)\s*([a-z]*)$""").matchEntire(raw) ?: return null
private val BANDWIDTH_REGEX = Regex("""^(\d+(?:\.\d+)?)\s*([a-z]*)$""")
/** Read a HY2 bandwidth value as Mbps: a bare int, or a unit-suffixed string (bps/kbps/mbps/gbps/tbps). */
private fun parseBandwidthMbps(obj: JSONObject, key: String): Int? {
obj.getIntNya(key)?.let { return it }
val raw = obj.getStr(key)?.trim()?.lowercase() ?: return null
val match = BANDWIDTH_REGEX.matchEntire(raw) ?: return null

Comment on lines +1099 to +1100
json.has("server") && json.getStr("auth") != null &&
!json.has("up") && !json.has("up_mbps") -> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Blank auth string silently falls through HY2 detection

getStr("auth") (from Formats.kt) returns null for both absent keys and blank string values. So a well-formed HY2 config that carries "auth": "" (or any whitespace-only value) will fail this check, skip the HY2 branch, and be silently dropped since no later branch matches it either. An empty auth would fail server-side anyway, but the failure mode here is a quiet no-op import rather than a visible error. Consider using json.has("auth") if the intent is purely structural detection, or adding a comment clarifying that blank auth is intentionally excluded.

Comment on lines +80 to +85
fun parseHysteria2Json_bareHostDefaultsPort443() {
val bean = JSONObject("""{ "server": "example.com", "auth": "a" }""").parseHysteria2Json()
assertEquals("example.com", bean.serverAddress)
assertEquals("443", bean.serverPorts)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 No test for IPv6 server with explicit port

The bareHostDefaultsPort443 test exercises the no-port branch of the IPv6-safe split, but there is no test for "server": "[::1]:8443" — the case that is most likely to regress, since it requires the code to find the colon after the closing bracket (]) rather than one of the colons inside the address literal. Adding a single assertion for this form would lock in the bracket-aware split behaviour.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

Inline comments:
In `@app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt`:
- Around line 301-309: The bandwidth parsing in HysteriaFmt.parse rate
conversion is truncating sub-Mbps values to 0 because mbps.toInt() floors
fractional results, which can unintentionally turn an explicit cap into
unlimited. Update the conversion logic in the mbps calculation path to round or
otherwise clamp any positive parsed value to at least 1 before returning it, and
keep the return value as a valid non-zero limit for small rates. Also review the
final coerceAtLeast(0) in this code path since the regex only accepts digits and
makes negative handling unnecessary.
- Around line 243-255: The server parsing logic in HysteriaFmt.kt still splits
bare IPv6 literals on the last colon, so Hysteria2 parsing treats an address
like 2001:db8::1 as host plus port. Update the server handling in the relevant
parsing method to detect unbracketed IPv6 literals before the last-colon port
split, and only extract a port when the input is a true host:port form. Keep the
existing bracketed IPv6 path via wrapIPV6Host() unchanged, and ensure
serverAddress/serverPorts are set correctly for bare IPv6, IPv4, and hostname
inputs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e940a0c8-fc8d-4526-9590-cb9157ded8ac

📥 Commits

Reviewing files that changed from the base of the PR and between 92097a8 and f12ee8a.

📒 Files selected for processing (3)
  • app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt
  • app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
  • app/src/test/java/io/nekohasekai/sagernet/fmt/HysteriaFmtTest.kt

Comment on lines +243 to +255
val server = optString("server")
// Only split off a port when there's an explicit one. A bare host or an IPv6 literal
// without a port must keep the whole string as the address (default port 443),
// matching parseHysteria2(url)'s HttpUrl behavior.
val lastColon = server.lastIndexOf(':')
val portPart = if (lastColon >= 0) server.substring(lastColon + 1) else ""
if (lastColon >= 0 && portPart.toIntOrNull() != null && !server.endsWith("]")) {
serverAddress = server.substring(0, lastColon)
serverPorts = portPart
} else {
serverAddress = server
serverPorts = "443"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 'fun\s+wrapIPV6Host' --type=kt

Repository: hawkff/NekoBoxForAndroid

Length of output: 189


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the IPv6 wrapper and the Hysteria formatter code paths.
rg -n -C 3 'wrapIPV6Host|parseHysteria2|displayAddress\(' app/src/main/java

# Read the relevant file around the cited lines.
wc -l app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt
sed -n '1,340p' app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt

Repository: hawkff/NekoBoxForAndroid

Length of output: 31695


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the helper definition if it lives elsewhere.
rg -n -C 4 'fun\s+wrapIPV6Host|wrapIPV6Host\(' app/src/main/java --glob '*.kt'

Repository: hawkff/NekoBoxForAndroid

Length of output: 3431


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the IPv6 wrapper and the Hysteria formatter code paths.
rg -n -C 3 'wrapIPV6Host|parseHysteria2|displayAddress\(' app/src/main/java --glob '*.kt'

# Read the relevant file around the cited lines.
wc -l app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt
sed -n '1,340p' app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt

Repository: hawkff/NekoBoxForAndroid

Length of output: 27786


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the helper definition if it lives elsewhere.
rg -n -C 4 'fun\s+wrapIPV6Host|wrapIPV6Host\(' app/src/main/java --glob '*.kt'

Repository: hawkff/NekoBoxForAndroid

Length of output: 3431


Handle bare IPv6 literals before splitting on :. 2001:db8::1 currently becomes serverAddress = "2001:db8:" and serverPorts = "1". The bracketed case is already safe because wrapIPV6Host() unwraps then re-wraps.

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

In `@app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt` around
lines 243 - 255, The server parsing logic in HysteriaFmt.kt still splits bare
IPv6 literals on the last colon, so Hysteria2 parsing treats an address like
2001:db8::1 as host plus port. Update the server handling in the relevant
parsing method to detect unbracketed IPv6 literals before the last-colon port
split, and only extract a port when the input is a true host:port form. Keep the
existing bracketed IPv6 path via wrapIPV6Host() unchanged, and ensure
serverAddress/serverPorts are set correctly for bare IPv6, IPv4, and hostname
inputs.

Comment on lines +301 to +309
val mbps = when (match.groupValues[2]) {
"", "mbps", "m" -> value
"bps", "b" -> value / 1_000_000.0
"kbps", "k" -> value / 1_000.0
"gbps", "g" -> value * 1_000.0
"tbps", "t" -> value * 1_000_000.0
else -> return null
}
return mbps.toInt().coerceAtLeast(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sub-Mbps bandwidth truncates to 0.

mbps.toInt() floors fractional results, so values below 1 Mbps (e.g. "500 kbps" → 0.5 → 0, "800 bps" → 0) collapse to 0. Since HY2 treats 0 as unlimited, a small explicit cap silently becomes uncapped. Consider rounding and/or clamping positive results to at least 1.

Also note the regex only matches \d+, so a negative value is impossible and coerceAtLeast(0) is effectively dead.

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

In `@app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt` around
lines 301 - 309, The bandwidth parsing in HysteriaFmt.parse rate conversion is
truncating sub-Mbps values to 0 because mbps.toInt() floors fractional results,
which can unintentionally turn an explicit cap into unlimited. Update the
conversion logic in the mbps calculation path to round or otherwise clamp any
positive parsed value to at least 1 before returning it, and keep the return
value as a valid non-zero limit for small rates. Also review the final
coerceAtLeast(0) in this code path since the regex only accepts digits and makes
negative handling unnecessary.

@hawkff
hawkff merged commit 629667d into main Jun 26, 2026
12 checks passed
@hawkff
hawkff deleted the feat/022-hysteria2-json-import branch June 26, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant