diff --git a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt index 9448670..ad1faaf 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt @@ -291,9 +291,12 @@ class GiteaRepositoryBrowserApi( throw ApiFailure.InvalidResponse() } + // Gitea's Go handlers serialize a nil slice as JSON `null` rather than `[]` for some + // list endpoints when the underlying collection is empty (e.g. a repository with no + // branches) — that is a valid "no items" response, not a malformed one. private inline fun parseList(body: String): List = try { val type = object : TypeToken>() {}.type - gson.fromJson>(body, type) ?: throw ApiFailure.InvalidResponse() + gson.fromJson>(body, type) ?: emptyList() } catch (_: JsonParseException) { throw ApiFailure.InvalidResponse() } diff --git a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt index c9363da..3a073fb 100644 --- a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt @@ -174,6 +174,18 @@ class GiteaRepositoryBrowserApiTest { assertEquals(listOf(null, null), branches.items.map { it.commitAtEpochMillis }) } + @Test + fun `a literal JSON null body maps to an empty page instead of failing closed`() = runTest { + // Gitea's Go handlers serialize a nil slice as the JSON literal `null` rather than `[]` + // for some list endpoints when there are no items (e.g. a repository with no branches). + val transport = QueueTransport(response("null")) + + val branches = api(transport).listBranches(repository, 1, 20) + + assertTrue(branches.items.isEmpty()) + assertNull(branches.nextPage) + } + @Test fun `tree preserves ref and encoded path while returning a local immutable page`() = runTest { val transport = QueueTransport( diff --git a/docs/development-log.md b/docs/development-log.md index f328aa0..9975005 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -333,3 +333,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: Not retested live against `CSOT-T8-iPTN-AMA` (or any repository with a real missing/malformed branch timestamp) on a physical device with this exact fix — Codex reproduced and diagnosed the original crash on-device, but this fix itself has only unit-test coverage so far. - Next recommended task: A physical-device retest of this fix (Codex has working wireless-adb device access per TASK-020/021's evidence). No other roadmap items are open. - Next owner: Codex or the human product owner, for the device retest; otherwise no further items are unassigned. + +## 2026-08-26 — TASK-023: tolerate a literal JSON null list response + +- Agent and role: Claude, live incident diagnosis and fix, with the human product owner's own device as the reporting/verification device (their first real end-to-end use of the app this session). +- Commit/diff: continued on `claude/task-015-notifications`; `data/repositories/GiteaRepositoryBrowserApi.kt`, `data/repositories/GiteaRepositoryBrowserApiTest.kt`. +- Features implemented: A `parseList`-backed endpoint (branches, tags, commits, repository listing) whose response body is the literal JSON `null` — Go's `encoding/json` serializes a `nil` slice this way instead of `[]` — now maps to an empty list instead of throwing `ApiFailure.InvalidResponse()`. Previously this showed a misleading "Gitea 서버에서 오류가 발생했습니다" (server error) message on a completely successful `200 OK` response. +- How it was diagnosed: The human product owner set up wireless `adb` debugging to their own physical device this session (paired via `adb pair`/`adb connect`); this was used to install successive debug builds directly and read `logcat` live rather than round-tripping screenshots. First confirmed via the Gitea server's own Container Manager request log that the failing `branches` call was a genuine `200 OK` in ~7ms, ruling out an actual server-side 5xx despite the app's own error message claiming one. Then added three rounds of temporary `BuildConfig.DEBUG`-gated `Log.e` diagnostics (the caught exception, per-item mapping detail, then the raw response body) to narrow the cause down to `bodyPreview=null` — a literal `null` string, not empty or malformed JSON. All diagnostic logging was removed before committing; only the real fix and a matching test remain. +- Files changed: `data/repositories/GiteaRepositoryBrowserApi.kt`, `data/repositories/GiteaRepositoryBrowserApiTest.kt`, `tasks/023-tolerate-null-list-responses.md`. +- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL, all unit tests passing, 0 lint errors; `git diff --check` clean; secret-pattern scan clean. Verified live: reinstalled over wireless `adb` and the human product owner confirmed `CSOT-T8-iPTN-AMA` now opens correctly. +- Review findings and resolutions: The fix was made at the shared `parseList` helper level, not narrowly in `listBranches`, since the same Go nil-slice-to-null serialization can equally affect tags, commits, or repository listing — narrowing it to only the reported endpoint would have left the same latent bug in every sibling endpoint. +- Known limitations: `CSOT-T8-MACRO_SA` (the other repository that showed the same symptom) was not individually re-confirmed by the human product owner, though it goes through the identical code path. +- Next recommended task: None outstanding on the proposed roadmap. TASK-016's two previously-blocked items (full authenticated-screen device regression, R8/ProGuard minification decision) are now more tractable, since a real device session with working wireless `adb` and the human product owner's active participation is established for the first time this session. +- Next owner: Claude, continuing per the standing direction, or the human product owner if they'd rather drive further device testing directly. diff --git a/tasks/023-tolerate-null-list-responses.md b/tasks/023-tolerate-null-list-responses.md new file mode 100644 index 0000000..f596fca --- /dev/null +++ b/tasks/023-tolerate-null-list-responses.md @@ -0,0 +1,48 @@ +# TASK-023: Tolerate a literal JSON null list response + +Status: implemented; all required checks pass; physical-device retest confirmed +Owner: Claude +Reviewer: Codex + +## Outcome + +A repository whose branch (or any other list-shaped) endpoint returns the literal JSON body `null` — Go's `encoding/json` serializes a `nil` slice this way instead of `[]` — is treated as an empty list, not a parse failure. Previously this surfaced as a generic "Gitea 서버에서 오류가 발생했습니다" (server error) on both the repository-list row's last-pushed-branch indicator and the repository's reference (branch/tag) screen, even though the HTTP response itself was a completely successful `200 OK`. + +## Incident evidence + +Reproduced live on the human product owner's authenticated device via wireless `adb`, against real repositories `RobotAutomationDevelopment-Dept/CSOT-T8-iPTN-AMA` and `CSOT-T8-MACRO_SA`. Confirmed via the Gitea container's own request log (Container Manager → 로그) that the branches request returned `200 OK` in ~7ms — not a server-side failure. A temporary debug-only diagnostic (`Log.e`, removed before commit) then captured the actual response body for the failing call: `bodyLen=5 bodyPreview=null` — the literal 4-character JSON `null` (plus a trailing artifact in the length count), not an array. `GiteaRepositoryBrowserApi.parseList` deserialized that to Kotlin `null` via Gson (no exception), then unconditionally threw `ApiFailure.InvalidResponse()` on a null parse result — a general-purpose fallback meant for a body that failed to deserialize at all, not for a body that correctly deserializes to "no items." + +## Acceptance criteria + +- [x] A JSON `null` response body from any `parseList`-backed endpoint (branches, tags, commits, etc.) maps to an empty list rather than a thrown failure. +- [x] Existing malformed/genuinely-invalid JSON still throws `ApiFailure.InvalidResponse()` (only the specific null-body case changed). +- [x] A unit test reproduces the literal `null` body case for `listBranches`. +- [x] Verified live on the reporting device: `CSOT-T8-iPTN-AMA` (the repository used for diagnosis) now opens correctly instead of showing the generic server-error screen. `CSOT-T8-MACRO_SA` was not individually re-confirmed by the human product owner, but is expected to behave identically since the fix is in the shared `parseList` helper both repositories go through. +- [x] Build, unit tests, lint, and secret scan pass. + +## Writable paths + +- `app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt` +- `app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt` +- `tasks/023-tolerate-null-list-responses.md` + +## Required checks + +```text +./gradlew assembleDebug +./gradlew testDebugUnitTest +./gradlew lintDebug +``` + +## Evidence + +- `GiteaRepositoryBrowserApi.kt`'s private `parseList` helper: `gson.fromJson>(body, type) ?: throw ApiFailure.InvalidResponse()` changed to `?: emptyList()`. This is the single shared helper used by `listBranches`, `listTags`, `listCommits`, and repository listing, so the fix applies uniformly rather than only to the one endpoint that happened to be reported — the same Go nil-slice-to-null serialization can equally affect any of them. +- A genuinely malformed body (unparseable JSON) still throws `ApiFailure.InvalidResponse()` via the existing `catch (_: JsonParseException)` — only a body that Gson *successfully* parses to `null` changed behavior. +- Diagnostic method: added temporary `BuildConfig.DEBUG`-gated `Log.e` calls at three points (`loadReferences`'s catch, `listBranches`'s per-item mapping, and the raw response body) to narrow the failure down via live logcat over a wireless-`adb` connection to the reporting device, then removed all three before committing — none of that scaffolding is part of this change. +- `./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks`: BUILD SUCCESSFUL, all unit tests passing, 0 lint errors. +- `git diff --check`: clean. Secret-pattern scan of the diff: no matches. +- Live device retest: reinstalled the fixed build over wireless `adb` on the reporting device (paired via wireless debugging in this session) and the human product owner confirmed `CSOT-T8-iPTN-AMA` now opens correctly. + +## Review findings + +None yet — pending Codex/human review.