# 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).