diff --git a/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt b/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt index 6eaa7ab..74d0802 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt @@ -1,5 +1,6 @@ package com.ubisam.ubgitea.core.network +import java.util.concurrent.TimeUnit import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback @@ -25,8 +26,30 @@ interface HttpTransport { suspend fun delete(url: String, bearerToken: String): HttpResponse } +// Every OkHttpTransport call site across the app (one per feature's ViewModel.Factory) used to +// build its own fresh, default-configured OkHttpClient: OkHttpClient()'s 10-second connect/read/ +// write timeouts turned out to be too tight for this app's actual Gitea host (a home Synology +// NAS, not a datacenter), so a merely-slow-but-working request -- a commit listing on a +// large-history repository, or a directory listing with many entries -- was indistinguishable +// from a genuinely offline device: both threw an IOException that GiteaHistoryApi/ +// GiteaRepositoryBrowserApi.authenticated wrap identically as ApiFailure.Network, which every +// feature's error mapping renders as "서버에 연결할 수 없습니다" (can't reach the server) -- +// live-reported against a large real repository even though the device had a normal connection +// the whole time. A single shared client, used as OkHttpTransport's default, fixes two things at +// once: longer timeouts give a slow-but-successful response room to actually complete instead of +// being misreported as offline, and sharing one connection pool across every feature's API calls +// (instead of each of the ~8 call sites opening its own pool) lets repeated calls to the same +// Gitea host reuse an already-open TCP/TLS connection rather than renegotiating one per feature. +private val sharedOkHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() +} + class OkHttpTransport( - private val client: OkHttpClient = OkHttpClient(), + private val client: OkHttpClient = sharedOkHttpClient, private val responseReader: (Response) -> HttpResponse = ::readHttpResponse, ) : HttpTransport { override suspend fun postForm(url: String, fields: Map): HttpResponse { diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SourceContentClassifier.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SourceContentClassifier.kt index 8fbf6aa..b8b894a 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SourceContentClassifier.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SourceContentClassifier.kt @@ -4,6 +4,7 @@ import com.ubisam.ubgitea.domain.model.RepositoryPath import com.ubisam.ubgitea.domain.model.SourceContent import java.nio.ByteBuffer import java.nio.charset.CharacterCodingException +import java.nio.charset.Charset import java.nio.charset.CodingErrorAction /** @@ -45,7 +46,7 @@ class SourceContentClassifier( return ContentClassification.Binary(sizeBytes) } - val decoded = decodeStrictUtf8(bytes) + val decoded = decodeText(bytes) ?: return ContentClassification.UnsupportedEncoding(sizeBytes) parseLfsPointer(decoded, sizeBytes)?.let { return it } @@ -73,8 +74,25 @@ class SourceContentClassifier( private fun ByteArray.startsWith(prefix: ByteArray): Boolean = size >= prefix.size && prefix.indices.all { this[it] == prefix[it] } - private fun decodeStrictUtf8(bytes: ByteArray): String? { - val decoder = Charsets.UTF_8.newDecoder() + // UTF-8 is tried first and covers the overwhelming majority of real repository files. Real C/ + // C++ sources browsed live against this app's target Gitea instance kept coming back + // UnsupportedEncoding even though they were legitimate text -- older and Windows-authored C/ + // C++ codebases (this project's own target audience skews Korean, per the app's own locale) + // very commonly save source comments in EUC-KR rather than UTF-8, predating that codebase's + // move (if any) to UTF-8. EUC-KR is tried second, strictly (REPORT, not REPLACE, so a byte + // sequence EUC-KR itself can't represent still correctly falls through to UnsupportedEncoding + // rather than being silently substituted) -- this doesn't weaken the "never guess or silently + // substitute undecodable input" rule the classifier is built around, it just tries a second + // well-defined, exact charset instead of only one before giving up. EUC-KR specifically (not + // the more complete MS949/CP949 superset some of those same files may technically use) because + // it's the one Korean charset Android's own Charset class documents as present on every device + // via ICU; CP949 support is not guaranteed and risks compiling and passing host-JVM unit tests + // while throwing UnsupportedCharsetException at runtime on-device. + private fun decodeText(bytes: ByteArray): String? = + decodeStrict(bytes, Charsets.UTF_8) ?: decodeStrict(bytes, EUC_KR) + + private fun decodeStrict(bytes: ByteArray, charset: Charset): String? { + val decoder = charset.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT) return try { @@ -207,5 +225,6 @@ class SourceContentClassifier( val RIFF_SIGNATURE = "RIFF".toByteArray(Charsets.US_ASCII) val WEBP_SIGNATURE = "WEBP".toByteArray(Charsets.US_ASCII) val PDF_SIGNATURE = "%PDF-".toByteArray(Charsets.US_ASCII) + val EUC_KR: Charset = Charset.forName("EUC-KR") } } diff --git a/app/src/main/res/values/strings_branchdetail.xml b/app/src/main/res/values/strings_branchdetail.xml index 5e075d0..479112b 100644 --- a/app/src/main/res/values/strings_branchdetail.xml +++ b/app/src/main/res/values/strings_branchdetail.xml @@ -1,4 +1,4 @@ 파일 - 히스토리 + 커밋 diff --git a/app/src/main/res/values/strings_codebrowser.xml b/app/src/main/res/values/strings_codebrowser.xml index db58190..29c361c 100644 --- a/app/src/main/res/values/strings_codebrowser.xml +++ b/app/src/main/res/values/strings_codebrowser.xml @@ -25,7 +25,7 @@ 서버 응답을 처리하지 못했습니다. 다시 시도 다시 로그인 - 히스토리 + 커밋 %1$d / %2$d 이미지를 표시할 수 없습니다. 이 CSV 파일은 비어 있습니다. diff --git a/app/src/main/res/values/strings_history.xml b/app/src/main/res/values/strings_history.xml index cb9bb50..5788565 100644 --- a/app/src/main/res/values/strings_history.xml +++ b/app/src/main/res/values/strings_history.xml @@ -21,7 +21,7 @@ 기타 변경 서버에 연결할 수 없습니다. 네트워크를 확인해 주세요. 로그인이 만료되었습니다. 다시 로그인해 주세요. - 이 저장소의 히스토리를 볼 권한이 없습니다. + 이 저장소의 커밋 내역을 볼 권한이 없습니다. 커밋이나 기준을 찾을 수 없습니다. 저장소가 비어 있거나 비교할 수 없는 상태입니다. 요청을 처리할 수 없습니다. diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SourceContentClassifierTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SourceContentClassifierTest.kt index a1cfe91..f9df763 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SourceContentClassifierTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SourceContentClassifierTest.kt @@ -238,6 +238,36 @@ class SourceContentClassifierTest { assertEquals(ContentClassification.UnsupportedEncoding(sizeBytes = 3), result) } + // --- EUC-KR fallback (legacy Korean-encoded C/C++ sources) --- + + @Test + fun classifiesEucKrTextAsTextWhenNotValidUtf8() { + // A real Korean comment saved in EUC-KR (common in older/Windows-authored C/C++ sources) + // is not valid UTF-8 byte-for-byte, so this exercises the fallback decode path, not the + // primary UTF-8 one. + val text = "한글 주석" + val bytes = text.toByteArray(charset("EUC-KR")) + + val result = classifier.classify(SourceContent.from(bytes)) + + assertEquals( + ContentClassification.Text(sizeBytes = bytes.size, renderedText = text, lineCount = 1, isTruncated = false), + result, + ) + } + + @Test + fun classifiesByteSequenceInvalidInBothUtf8AndEucKrAsUnsupportedEncoding() { + // 0xFF is not a valid lead byte in either UTF-8 or EUC-KR (EUC-KR lead bytes are + // 0xA1-0xFE), so this must still fall through to UnsupportedEncoding rather than being + // silently accepted by the fallback. + val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42) + + val result = classifier.classify(SourceContent.from(bytes)) + + assertEquals(ContentClassification.UnsupportedEncoding(sizeBytes = 3), result) + } + // --- Git LFS pointer --- @Test diff --git a/docs/development-log.md b/docs/development-log.md index fa1ef40..c0fb0f4 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -505,3 +505,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: a repository already starred before the current app session still shows an outlined (not filled) star until acted on this session — carried over unchanged from TASK-018's original design. No per-language color coding (no reliable local source for GitHub/Gitea's `linguist` color mapping) — language is shown as plain text. The hand-built palette's contrast was reasoned through directly for the primary/onPrimary pairing specifically, not verified with an automated contrast checker across every color role. Full live human confirmation of this batch is still pending, even though device screenshots taken this session already show all three pieces working correctly. - Next recommended task: Get live confirmation from the human product owner of everything still pending a look this session — pinch-zoom/pan on images, the CSV table viewer, the PDF viewer, Issues/PR numbered pagination, and now this theme/footer/star-language batch — then, if wanted, size and build the previously-scoped push-notification-on-favorited-repo-push feature as its own task. - Next owner: Claude, continuing per the standing direction. + +## 2026-08-27 — TASK-031: "커밋" label rename, EUC-KR decode fallback, HTTP timeout/connection-pooling fix + +- Agent and role: Claude, direct continuation of the same live session. Three separate live bug reports arrived while TASK-030 was being finished up: after confirming the "History" tab/screen was in fact the commit history, the human product owner asked for the label itself to say "커밋" instead; separately, "지금 .cpp, .h 파일은 잘 못읽는 것 같다 자꾸 UTF-8로 안전하게 읽을 수 없는 파일이래" (`.cpp`/`.h` files keep failing to open, saying they can't be safely read as UTF-8) plus "파일 양이 많아지면 불러오는데 너무 오래 걸려" (loading takes too long once there are a lot of files); and, while investigating those, a further live report — "LGD-P9-CLCI-4K는 커밋 이력을 보려고 하면 서버에 연결할 수 없습니다... 이거 이전에도 비슷한거 그랬었지 않았나?" (that repository's commit history says it can't reach the server — didn't something similar happen before?) — which traced back to the same underlying cause as the "too slow" complaint. +- Commit/diff: continued on `claude/task-015-notifications`; `strings_branchdetail.xml`, `strings_codebrowser.xml`, `strings_history.xml`, `feature/codebrowser/content/SourceContentClassifier.kt` (+ test), `core/network/HttpTransport.kt`, plus `tasks/031-commit-label-euckr-fallback-http-timeouts.md`. +- Feature/fixes implemented: (1) every user-facing "히스토리" label became "커밋" (a full `res/values` scan found exactly three occurrences: the branch-detail tab, the code-browser's history button, and one sentence in a permission-denied message); (2) the content classifier now tries EUC-KR (strictly, same as its existing UTF-8 attempt) before giving up, so legacy Korean-encoded C/C++ source files render instead of failing as `UnsupportedEncoding`; (3) `OkHttpTransport`'s default client changed from a fresh, unconfigured `OkHttpClient()` per call site to one shared client with longer timeouts (15s connect / 30s read / 15s write, versus the previous unconfigured 10s/10s/10s). +- Bug found while investigating: the "server unreachable" error on a large repository's commit history and the "loading takes too long" complaint traced back to the exact same root cause — every one of the app's ~8 `OkHttpTransport()` call sites built its own fresh, default-configured `OkHttpClient()`, whose 10-second timeouts were too tight for this app's actual Gitea host (a home Synology NAS, not a datacenter service); `GiteaRepositoryBrowserApi.authenticated`/`GiteaHistoryApi.get` both catch `IOException` broadly (which includes `SocketTimeoutException`) and wrap it as `ApiFailure.Network`, rendered identically to a genuinely offline device — a slow-but-working request on a large commit history or a directory with many entries was being cut off and misreported before it had a real chance to finish. +- How it was implemented: the EUC-KR fallback reuses the classifier's existing strict-decode discipline (`CodingErrorAction.REPORT`, never `REPLACE`) so a second well-defined charset is tried, not a permissive catch-all — a byte sequence invalid in both UTF-8 and EUC-KR still correctly falls through to `UnsupportedEncoding`. EUC-KR specifically, not the larger MS949/CP949 superset, because Android's own `Charset` documentation guarantees EUC-KR is bundled on every device via ICU while CP949 support isn't guaranteed — using CP949 risked passing this project's host-JVM unit tests while throwing `UnsupportedCharsetException` at runtime on the actual device. The HTTP fix is one shared, lazily-built `OkHttpClient` used as `OkHttpTransport`'s new default, which both gives slow-but-working requests room to complete and lets every feature's calls to the same host reuse one connection pool (avoiding a fresh TCP/TLS handshake per feature) instead of each of the ~8 call sites opening its own. +- Files changed: see `tasks/031-commit-label-euckr-fallback-http-timeouts.md`'s Scope section for the full list. +- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin` BUILD SUCCESSFUL; `./gradlew testDebugUnitTest` BUILD SUCCESSFUL, all unit tests passing (new coverage: EUC-KR-encoded text classifies as `Text`; a byte sequence invalid in both UTF-8 and EUC-KR still classifies as `UnsupportedEncoding`; the two pre-existing "malformed UTF-8" tests were checked against EUC-KR's own lead/trail-byte rules to confirm they still exercise the "both attempts fail" path). `./gradlew assembleDebug lintDebug` BUILD SUCCESSFUL, 0 lint errors. Installed on the human product owner's device over wireless `adb`. +- Review findings and resolutions: None from a separate review pass. +- Known limitations: the error message still doesn't distinguish "genuinely offline" from "reachable but slow" — a request still too slow even at 30 seconds, or a truly offline device, both still render the same message; the fix targets the common slow-NAS case specifically, not a full error-taxonomy redesign. EUC-KR only, not CP949's extended completion range (deliberate, see task file). No response caching was added — the connection-pool sharing cuts handshake overhead, not the request/response itself. +- Next recommended task: get live confirmation from the human product owner that "LGD-P9-CLCI-4K"'s commit history now loads, that `.cpp`/`.h` files open correctly, and that the "커밋" label reads right — not yet re-tested against those exact cases in this session. Also still pending from earlier in this session: the green theme/footer-height/language-star batch (TASK-030), pinch-zoom/pan, CSV, PDF, and Issues/PR numbered pagination. +- Next owner: Claude, continuing per the standing direction. diff --git a/tasks/031-commit-label-euckr-fallback-http-timeouts.md b/tasks/031-commit-label-euckr-fallback-http-timeouts.md new file mode 100644 index 0000000..5cde0c2 --- /dev/null +++ b/tasks/031-commit-label-euckr-fallback-http-timeouts.md @@ -0,0 +1,148 @@ +# TASK-031: "커밋" label rename, EUC-KR decode fallback, HTTP timeout/connection-pooling fix + +Status: implemented; all required checks pass; live-installed, awaiting the human product owner's confirmation +Owner: Claude +Reviewer: Codex + +## Outcome + +Three small, independent fixes reported live in the same testing session: the "히스토리" (History) +tab/button label is now "커밋" (Commit), matching the human product owner's own correct +description of what that screen actually shows; `.cpp`/`.h` (and any other legacy-encoded) source +files that use EUC-KR instead of UTF-8 — common in older or Windows-authored Korean C/C++ +codebases — now render instead of being rejected as `UnsupportedEncoding`; and a large repository's +commit history (and, more generally, any slow-but-working request against this app's home-NAS-hosted +Gitea instance) no longer gets misreported as "서버에 연결할 수 없습니다. 네트워크를 확인해 +주세요." (can't reach the server) purely because it took longer than the previous 10-second default +timeout to answer. + +## Scope + +- Included: `app/src/main/res/values/strings_branchdetail.xml`, `strings_codebrowser.xml`, + `strings_history.xml` (label/wording changes only); `feature/codebrowser/content/SourceContentClassifier.kt` + (+ matching test) for the EUC-KR fallback; `core/network/HttpTransport.kt` for the shared, + longer-timeout `OkHttpClient`. +- Excluded: No change to the actual commit-history or file-browsing functionality themselves — + this batch is a label, a decode fallback, and a network-client tuning change, not new features. + No attempt to distinguish "genuinely offline" from "slow but reachable" in the error message + itself (see Known limitations) — the fix is to stop timing out prematurely, not to redesign the + error taxonomy. + +## Design notes + +- **Label rename**: the human product owner directly confirmed, then asked for, this: the + "History" tab (branch detail) and "History" button (code browser, jumps to a file's commit + history) both said "히스토리"; both are now "커밋" to match what a Gitea user actually expects + that label to mean. `history_permission_denied`'s sentence ("이 저장소의 히스토리를 볼 권한이 + 없습니다") was reworded to "커밋 내역을 볼 권한이 없습니다" for the same reason — it's the only + other user-facing string in the app that used the word "히스토리" on its own (a full res/values + scan confirmed these were the only three occurrences). Internal identifiers (`HistoryScreen`, + `HistoryViewModel`, `history_*` resource-name prefixes, the `feature/history` package) were left + alone — renaming those is a much larger, purely-internal refactor with no user-visible benefit, + and touching them risked the exact kind of unrelated churn this session's standing "don't change + functionality" constraint warns against. +- **EUC-KR fallback, not a blind fallback**: the classifier's own design principle — decode with + `CodingErrorAction.REPORT`, never `REPLACE`, so a byte sequence that isn't cleanly one specific + encoding is refused rather than rendered as mojibake — is preserved exactly; a second charset is + now tried with the same strict decode, not a permissive one. EUC-KR specifically (not the more + complete MS949/CP949 superset some of the same files may technically use) because Android's own + `Charset` documentation lists EUC-KR as present on every device via bundled ICU data, while + CP949/MS949 support is not guaranteed — using it would have risked compiling and passing this + project's host-JVM unit tests while throwing `UnsupportedCharsetException` at runtime on the + actual device, which is exactly the kind of gap this project's tests wouldn't have caught without + a live device check. A byte sequence invalid in both UTF-8 and EUC-KR (e.g. `0xFF` as a lead + byte, valid in neither) still correctly falls through to `UnsupportedEncoding` — this is not a + catch-all "always render something" fallback, it's specifically shaped around the one legacy + encoding this app's real, live-tested repositories actually turned out to use. +- **HTTP timeouts and connection sharing, one root cause for two symptoms**: every one of the + app's ~8 `OkHttpTransport()` call sites (one per feature's `ViewModel.Factory`, plus `AuthSession`) + constructed its own fresh, default-configured `OkHttpClient()` — default 10-second connect/read/ + write timeouts. Traced directly from the live bug report (commit listing on a real, + larger-history repository — visible on device as "LGD-P9-CLCI-4K" — reliably produced the + generic offline error) through `GiteaRepositoryBrowserApi.authenticated`/`GiteaHistoryApi.get`, + both of which catch `IOException` broadly (this necessarily includes `SocketTimeoutException`) + and wrap it as `ApiFailure.Network`, which every feature's error mapping renders identically to a + truly offline device. This app's actual Gitea host is a home Synology NAS, not a datacenter + service, so a legitimately slow-but-working response — a large commit history walk, a directory + listing with many entries (the second, related complaint: loading feeling too slow as file counts + grow) — was being cut off and misreported before it had a real chance to finish. One shared + `OkHttpClient` (15s connect / 30s read / 15s write, versus the previous unconfigured 10s/10s/10s) + is now `OkHttpTransport`'s default, replacing the previous `OkHttpClient()` bare default: longer + timeouts give a slow response room to actually complete instead of erroring out early, and + because it's now one shared instance (not ~8 independently-constructed ones), OkHttp's own + connection pool is reused across every feature's calls to the same host within a session — a + second call to the same Gitea host during the same app session can reuse an already-open TCP/TLS + connection instead of paying a fresh handshake, which is a genuine (not just perceived) latency + win on top of the timeout fix. + +## Acceptance criteria + +- [x] The branch-detail tab and the code-browser's "view this file's history" button both read + "커밋" instead of "히스토리". +- [x] `history_permission_denied`'s message no longer contains the word "히스토리". +- [x] A `.cpp`/`.h` (or any text) file encoded in EUC-KR, not UTF-8, renders as text instead of + `UnsupportedEncoding`. +- [x] A byte sequence invalid in both UTF-8 and EUC-KR still correctly classifies as + `UnsupportedEncoding` — the fallback doesn't swallow genuinely unrecognized encodings. +- [x] `OkHttpTransport`'s default client now uses longer, explicit timeouts (15s connect / 30s + read / 15s write) instead of an unconfigured default `OkHttpClient()`, and is shared (one + instance, one connection pool) across every call site instead of one fresh client per call + site. +- [x] Build, unit tests, and lint pass. +- [ ] Live confirmation from the human product owner that the "LGD-P9-CLCI-4K" repository's commit + history now loads instead of failing, that `.cpp`/`.h` files open correctly, and that the + "커밋" label reads correctly — the build has been reinstalled on the device but not yet + re-tested against those exact cases in this session. + +## Required checks + +```text +./gradlew compileDebugKotlin compileDebugUnitTestKotlin +./gradlew testDebugUnitTest +./gradlew assembleDebug lintDebug +``` + +## Evidence + +- `strings_branchdetail.xml`/`strings_codebrowser.xml`: `히스토리` → `커밋`. +- `strings_history.xml`: `history_permission_denied` reworded to say "커밋 내역". +- `SourceContentClassifier.kt`: `decodeStrictUtf8` replaced by `decodeText` (tries UTF-8, then + EUC-KR, both strict) built on a new shared `decodeStrict(bytes, charset)` helper; new `EUC_KR` + charset constant. +- `SourceContentClassifierTest.kt`: two new tests — EUC-KR-encoded Korean text now classifies as + `Text` with the correctly decoded string; a byte sequence invalid in both UTF-8 and EUC-KR still + classifies as `UnsupportedEncoding`. The two pre-existing "malformed UTF-8" tests were checked + (not just left alone) to confirm their specific byte sequences are also invalid under EUC-KR's + own lead/trail-byte rules, so they still correctly exercise the "both attempts fail" path rather + than accidentally now succeeding via the new fallback. +- `HttpTransport.kt`: new private `sharedOkHttpClient` (lazy, 15s/30s/15s timeouts), now + `OkHttpTransport`'s default `client` parameter, replacing the previous bare `OkHttpClient()`. +- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL. +- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing (including the two new + ones above). +- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors. +- Installed on the human product owner's device over wireless `adb` (`adb install -r`, streamed + install succeeded); not yet re-tested live against the specific repository/files that originally + reported these bugs in this session. + +## Known limitations + +- The error message itself still doesn't distinguish "genuinely offline" from "reachable but slow" + — a request that's still too slow even at the new 30-second read timeout (or a device that's + truly offline) both still render as the same "서버에 연결할 수 없습니다" message. The fix here + is to stop the common case of a merely-slow-but-working NAS response from being misclassified, + not a full error-taxonomy redesign. +- EUC-KR only, not the larger CP949/MS949 superset — a file using a Hangul syllable or symbol only + representable in CP949's extended completion range (not present in the older EUC-KR/KS X 1001 + set) will still classify as `UnsupportedEncoding`. This was a deliberate choice given Android's + charset-availability guarantees (see Design notes), not an oversight. +- No caching or ETag/conditional-request support was added — the connection-pool sharing reduces + handshake overhead for repeated calls to the same host, but each call still does a full request/ + response round trip; a directory or commit list re-opened a second time still re-fetches from + scratch. +- Live confirmation against the exact repository and files that originally reported these bugs is + still pending in this session (see Acceptance criteria). + +## Review findings + +None yet — pending Codex/human review.