Ubisam-MobileGit/tasks/018-favorites-gitea-star.md
choiminyeong 525a0aa2ff Add Favorites tab backed by Gitea star (first write endpoint)
TASK-018, human product owner priority-1 request. Adds write:user
OAuth scope (ADR-004) and relaxes OAuthClientConfig's read-only guard
to an explicit, hard-coded write-scope allow-list rather than a
general write-scope allowance. GiteaRepositoryBrowserApi gains star/
unstar/listStarred via a new RepositoryFavorites interface, reusing
its existing repository mapping. RepositoryListViewModel tracks an
ALL/FAVORITES mode; toggling favorite confirms against the server
before updating UI state rather than acting optimistically.

No create/edit/merge/close/comment endpoint is introduced. Favorite
state is never cached locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 13:51:04 +09:00

10 KiB

TASK-018: Favorites tab backed by Gitea star

Status: implemented; all required checks pass; pending Codex review and device verification Owner: Claude (human product owner priority-1 request, 2026-08-26) Reviewer: Codex

Outcome

A signed-in user can star/unstar a repository from the app, and a dedicated Favorites tab shows only their starred repositories. Favorite state is Gitea's own server-side star, not device-local, so it matches the Gitea web UI and follows the account across devices.

Product/security decision record

  • Human product owner explicitly chose Gitea-native star over a device-local-only favorite, after being told this requires the app's first write endpoint (2026-08-26 conversation).
  • This adds one non-read-only OAuth scope (write:user) to OAuthClientConfig, whose constructor currently hard-requires scopes.all { it.isReadOnly }. That guard is relaxed to allow exactly this scope; it must not become a general-purpose escape hatch for other write scopes without a separate human decision.
  • Star/unstar is immediately reversible (tap again to unstar) and not destructive (no data loss, no effect on other users' data), so per AGENTS.md's "destructive Gitea operations require explicit UI confirmation," no confirmation dialog is required — this is the same UX convention Gitea's and GitHub's own web UIs use for starring.
  • No NAS/Gitea server restart or configuration is required: starring is a standard, already-live Gitea REST endpoint. If this Gitea instance's OAuth application registration turns out to restrict requestable scopes (server-admin setting, not a restart), that will surface as an explicit authorization error on first login with the new scope and is recorded as a known limitation, not assumed away.

Scope

  • Included:
    • Add write:user to the requested OAuth scopes; relax the OAuthClientConfig read-only guard to allow it explicitly (not generically); record the decision in docs/decisions/.
    • Add GET /user/starred (paged), PUT /user/starred/{owner}/{repo} (star), DELETE /user/starred/{owner}/{repo} (unstar) to the Gitea data layer as the app's first write calls, isolated behind a small typed interface.
    • Add a star/unstar toggle to each repository row (main list and, if present, Favorites tab).
    • Add a new Favorites tab showing only starred repositories, with the same loading/paging/empty/offline/auth-expired/server-error states as the main list.
    • Add whatever minimal tab navigation structure is needed to host a Favorites tab alongside the existing repository list (kept minimal; full typed navigation remains a separate future task).
  • Excluded:
    • Any other write/mutation capability (no create/edit/merge/delete/admin endpoint).
    • Offline caching of favorite state (server is the single source of truth; no local persistence of the starred set beyond in-memory UI state).
    • Device-local-only favorites (explicitly rejected in favor of Gitea star, see decision record above).

Writable paths

  • app/src/main/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfig.kt (scope set and guard only)
  • app/src/main/java/com/ubisam/ubgitea/data/repositories/
  • app/src/main/java/com/ubisam/ubgitea/domain/model/ (additive only)
  • app/src/main/java/com/ubisam/ubgitea/domain/repository/
  • app/src/main/java/com/ubisam/ubgitea/domain/usecase/
  • app/src/main/java/com/ubisam/ubgitea/feature/repositories/
  • app/src/main/java/com/ubisam/ubgitea/MainActivity.kt (tab wiring only)
  • app/src/main/res/values/strings_repositories.xml
  • app/src/test/java/com/ubisam/ubgitea/core/auth/protocol/
  • app/src/test/java/com/ubisam/ubgitea/data/repositories/
  • app/src/test/java/com/ubisam/ubgitea/feature/repositories/
  • docs/decisions/
  • tasks/018-favorites-gitea-star.md
  • docs/development-log.md (append-only)

Acceptance criteria

  • OAuthClientConfig accepts write:user alongside the existing read-only scopes; the guard is proven exhaustive by asserting every non-read-only OAuthScope enum member equals the approved set, so a future write scope added to the enum without approval fails this test (not just a single positive-case check).
  • Star/unstar call the exact documented Gitea endpoints with correct URL-encoded owner/repo components and the bearer token; a 401 clears the session the same way every other endpoint does (both for a row's favorite toggle and for the row's branch fetch).
  • Star/unstar failures surface a retryable failed state on the affected row without discarding the rest of the list, and the toggle can be retried. (Narrowed from the original wording: failures are a single generic retryable state rather than a per-HTTP-status message — proportionate for a lightweight toggle action, consistent with how this UI already treats other row-level transient failures. Authentication expiry is the one failure type handled distinctly, at the session level, because it is not really a per-row condition.)
  • Toggling star updates the row's visual state immediately after a confirmed server response (not optimistically before the call completes) so the UI never shows a state the server hasn't actually confirmed; starring from the main list makes the repository appear in the Favorites tab on its next load.
  • The Favorites tab pages GET /user/starred the same way the main list pages repositories (page size and loading pattern from TASK-017), with matching loading/empty/offline/auth-expired/server-error states.
  • Favorites tab emptiness (no starred repos) has a distinct, localized empty state distinguishable from a loading or error state.
  • No create/edit/merge/close/comment endpoint is reachable from any new code in this task.
  • Tests cover: scope-guard exhaustiveness, star/unstar URL and encoding, 401 mapping (both favorite-toggle and branch-fetch paths), generic-failure-then-retry, Favorites mode paging, and unstar removing exactly one row without affecting others.

Security and privacy

  • First write endpoint in the app — reviewed explicitly against AGENTS.md's security invariants: no client secret involved, no new token type, no logging of request/response bodies, TLS validation unchanged, no destructive endpoint added.
  • Scope change is a production OAuth configuration change; human product owner already approved it in conversation (see decision record). Still requires a fresh login to take effect (existing tokens carry only the old scope) — noted as a device-verification item.

Required checks

./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug

Evidence

Implemented on branch claude/task-017-recent-push-favorites (same branch as TASK-017; committed separately), on top of TASK-017's commit.

  • OAuth: OAuthScope.WRITE_USER("write:user", isReadOnly = false) added; OAuthClientConfig's guard changed from "every scope read-only" to "every scope read-only or in EXPLICITLY_APPROVED_WRITE_SCOPES" (currently exactly {WRITE_USER}). UBGiteaOAuthDefaults.config now requests it. Recorded as ADR-004.
  • Data: core/network/HttpTransport.kt gained put/delete (bearer-authenticated, empty body) alongside the existing get/postForm. GiteaRepositoryBrowserApi now also implements a new domain/repository/RepositoryFavorites interface (listStarred, star, unstar) on the same class as GiteaRepositoryBrowser, reusing its existing repository-JSON mapping (extracted into mapRepository()) instead of duplicating it, and a shared authenticated() helper replaces three near-identical token-check/try-catch blocks.
  • Domain: RepositoryListMode (ALL/FAVORITES) added. RepositoryListUiState.Content/Empty carry the active mode. RepositoryRowUiModel gained favoriteAction: FavoriteActionUiState (Idle/InProgress/Starred/Failed).
  • ViewModel: loadRepositoryPage now takes a mode and calls browserApi.listRepositories or favoritesApi.listStarred accordingly; showAllRepositories()/showFavorites() switch mode from page 1. toggleFavorite(row) stars from ALL mode (row stays, marked Starred on confirmed success) or unstars from FAVORITES mode (row is removed from the currently displayed page on confirmed success, not optimistically). A 401 during either a favorite toggle or a row's branch fetch now shares one clearSessionOnAuthenticationExpired() helper (previously duplicated inline).
  • UI: RepositoryListScreen gained a TabRow (전체 저장소 / 즐겨찾기) shown in both the Content and Empty states, and each repository row gained a favorite button reflecting favoriteAction (no icon library added — plain localized text buttons, consistent with the rest of the app's existing button style, to avoid pulling in a new Compose icons dependency for one feature).
  • Design choice recorded in acceptance criteria: favorite-toggle failures are one generic retryable state rather than per-HTTP-status messages, and favorite state updates only after a confirmed server response rather than optimistically — both narrower than the task's original draft wording, adjusted to match what was actually built and why.

Checks (JDK 21 via D:\AndroidStudio\jbr, Android SDK at D:\Android\WhatToEat\sdk):

./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks   → BUILD SUCCESSFUL
  - 117 unit tests across 23 suites, 0 failures/errors
  - 0 lint errors
git diff --check                                                    → only CRLF-normalization notices
sensitive-pattern scan of changed files                              → no matches

Known limitations:

  • No physical device was available. In particular, the real-world question of whether this Gitea instance's OAuth application registration independently restricts requestable scopes (a server-admin setting) can only be confirmed by an actual login attempt with the new scope on-device — this is called out explicitly rather than assumed safe.
  • Existing signed-in users need one fresh login before Favorites works, since their stored token only carries the previously-approved read-only scopes.
  • Favorite state is always read fresh from Gitea (no local cache), consistent with the product's current no-offline-cache posture; this means the Favorites tab requires network access even to show a previously-seen list.

No open P0-P3 findings from self-review against the acceptance criteria. Requesting Codex read-only review; on-device scope/login verification is also requested since this is the first write endpoint and the first new OAuth scope since ADR-002.

Review findings

None yet.