Ubisam-MobileGit/tasks/026-oauth-custom-tabs.md
choiminyeong f6ac20c8ed TASK-063: task records say what is actually open
No task waits on a Codex review; the redesign tasks are confirmed on the
product owner's Z Fold7; TASK-016's leftovers are on hold; no biometric
reauthentication.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:25:49 +09:00

92 lines
5.3 KiB
Markdown

# TASK-026: Run the OAuth login/consent flow in a Custom Tab instead of the external browser
Status: implemented; all required checks pass; device verification pending
Owner: Claude
Reviewer: Codex
## Outcome
Live request from the same testing session: tapping "로그인" previously left the app entirely — a
separate browser app opened, the user logged into Gitea and clicked "Authorize" there, then was
handed back to UBGitea via the registered redirect URI. That hand-off felt like leaving the app.
Login and the OAuth consent screen ("애플리케이션 승인") now run inside an Android Custom Tab, which
overlays the app's own activity instead of switching to a separate browser task — visually, it
stays "in the app" the whole way through, and returns to it automatically on redirect. This does
not and cannot remove the "Authorize" click itself; that consent step is enforced by Gitea's OAuth
server, not the Android client, and is inherent to OAuth (the whole point of the screen is that the
user, not the app, has to grant access) — see Security and privacy below for why it also should
not be worked around by moving login into an in-app WebView.
## Scope
- Included: `MainActivity.kt` (`openSystemBrowser` renamed to `openAuthorizationTab`, now builds
and launches a `CustomTabsIntent` instead of a plain `Intent.ACTION_VIEW`), `app/build.gradle.kts`
and `gradle/libs.versions.toml` (new `androidx.browser:browser` dependency).
- Excluded: No change to the OAuth protocol itself — `OAuthAuthorizationRequest`,
`OAuthCallbackValidator`, `Pkce`, `OAuthClientConfig`, the redirect-URI intent-filter in
`AndroidManifest.xml`, and `RepositoryListViewModel`'s `browserRequests`/`handleCallback` flow are
all unchanged. The only thing that changed is which UI surface renders the authorization URL.
## Design notes
- Chrome Custom Tabs (`androidx.browser.customtabs.CustomTabsIntent`) is the standard Android
middle ground for this: under the hood it's still the real system browser (so it shares session
cookies, saved passwords, and any existing Gitea login with whatever browser the device already
uses), but it renders as an overlay on top of the calling activity's task instead of switching to
a separate browser app — closing it (via redirect or the back button) returns straight to
UBGitea. No `setToolbarColor`/theming was added, to avoid hardcoding a color inconsistent with
the app's actual (dynamic/Material You) color scheme; a follow-up can add that later if wanted.
- The existing redirect-URI `intent-filter` (`https://gitea.ubisampaju.synology.me/ubisampaju/oauth/callback`,
`autoVerify="true"`) needed no change — Custom Tabs resolve `ACTION_VIEW` redirects exactly the
way a full external browser does, so `onNewIntent`/`consumeCallback` still fires the same way.
## Acceptance criteria
- [x] Tapping login opens the Gitea authorization URL in a Custom Tab, not a separate browser app.
- [x] The OAuth redirect still returns control to `MainActivity` and completes login.
- [x] `viewModel.browserLaunchFailed()` still fires if no browser capable of handling the URL exists
(the `ActivityNotFoundException` catch is preserved, unchanged).
- [x] Build, unit tests, and lint pass.
- [ ] Full device confirmation: login flow feels "in-app" and completes successfully end to end.
## Security and privacy
- Deliberately did **not** implement this with an in-app `WebView` loading the Gitea login form
directly, even though it would look more seamless. RFC 8252 ("OAuth 2.0 for Native Apps")
specifically prohibits embedded user-agents for this reason: a `WebView` the host app controls can
read the login form's DOM/network traffic, meaning the app itself could capture the Gitea
username and password as they're typed — exactly what OAuth's browser-based redirect flow exists
to prevent. Custom Tabs give the "stays in the app" UX the human product owner asked for without
that risk, since the credential entry still happens in an isolated browser process the host app
cannot inspect.
- No new data is read, stored, or transmitted by this change; it only changes which component
renders an already-existing authorization URL.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
- `MainActivity.kt`: `openSystemBrowser` → `openAuthorizationTab`, body replaced with
`CustomTabsIntent.Builder().build().launchUrl(this, Uri.parse(uri))`; call site
(`viewModel.browserRequests.collect(...)`) updated to match.
- `gradle/libs.versions.toml`: added `browser = "1.8.0"` and
`androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }`.
- `app/build.gradle.kts`: added `implementation(libs.androidx.browser)`.
- `./gradlew compileDebugKotlin`: BUILD SUCCESSFUL.
- `./gradlew assembleDebug testDebugUnitTest lintDebug`: BUILD SUCCESSFUL in 3m 28s, 53 actionable
tasks, all unit tests passing, 0 lint errors.
- Live device: build installed over wireless `adb` (`adb-RFCYA0RD1CZ-ztOzAA._adb-tls-connect._tcp`,
Galaxy Z Fold, re-paired this session under a fresh pairing code/port after the previous session's
pairing expired); human product owner asked to confirm the login flow now feels in-app —
result pending as of this commit.
## Review findings
None yet — no Codex review pending (Codex retired, 2026-09-29).