TASK-026: run OAuth login/consent in a Custom Tab instead of the external browser

Login previously left the app entirely for a separate browser task. Now uses
androidx.browser Custom Tabs, which overlays the app's own activity instead of
switching away, and returns automatically on redirect. Deliberately not a
WebView, per RFC 8252: an in-app WebView could read the Gitea login form's
credentials directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-08-27 20:27:23 +09:00
parent e2c25d8662
commit e219016998
5 changed files with 119 additions and 7 deletions

View File

@ -57,6 +57,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.browser)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)

View File

@ -8,6 +8,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -193,7 +194,7 @@ class MainActivity : ComponentActivity() {
)
}
LaunchedEffect(viewModel) {
viewModel.browserRequests.collect(::openSystemBrowser)
viewModel.browserRequests.collect(::openAuthorizationTab)
}
}
}
@ -206,13 +207,17 @@ class MainActivity : ComponentActivity() {
consumeCallback(intent)
}
private fun openSystemBrowser(uri: String) {
// OAuth's authorization/consent step must run in a real browser context, never an in-app
// WebView the host app controls (RFC 8252) — a WebView could read the Gitea login form's
// credentials directly. A Custom Tab is the standard middle ground: it's the actual system
// browser underneath (session cookies, saved passwords, the works), but it's presented as an
// overlay on top of this activity instead of switching away to a separate browser app/task,
// which is what the human product owner asked for ("앱 내부에서 처리").
private fun openAuthorizationTab(uri: String) {
try {
startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
},
)
CustomTabsIntent.Builder()
.build()
.launchUrl(this, Uri.parse(uri))
} catch (_: ActivityNotFoundException) {
viewModel.browserLaunchFailed()
}

View File

@ -386,3 +386,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Known limitations: The upper scroll bound is measured once per file (via a repeated single-character string of the longest line's length), not re-measured if the panel is resized mid-session (e.g., device rotation) — not expected to matter in practice since a file's selected diff is re-entered fresh on any real layout change, but noted for completeness. Live confirmation of this specific fix (does the jitter actually stop) is pending as of this commit, same as the rest of the TASK-025 batch.
- Next recommended task: Get the human product owner's live confirmation of the diff view specifically (in addition to the rest of the TASK-025 batch), then continue to the two remaining queued items.
- Next owner: Claude, continuing per the standing direction.
## 2026-08-27 — TASK-026: run OAuth login/consent in a Custom Tab instead of the external browser
- Agent and role: Claude, same live testing session, responding to a direct request after the human product owner re-paired their device over wireless `adb` under a fresh pairing code (the previous session's pairing had expired).
- Commit/diff: continued on `claude/task-015-notifications`; `MainActivity.kt`, `app/build.gradle.kts`, `gradle/libs.versions.toml`, `tasks/026-oauth-custom-tabs.md`.
- Feature implemented: tapping login previously opened the OAuth authorization URL via a plain `Intent.ACTION_VIEW`, switching fully away to a separate browser app for both the Gitea login form and the "Authorize this application" consent screen, then handing control back to UBGitea via the existing redirect-URI intent filter. It now opens in a Chrome Custom Tab (`androidx.browser.customtabs.CustomTabsIntent`) instead, which overlays the calling activity's own task rather than switching to a separate browser app/task — visually stays "in the app" through login and redirect back.
- How it was implemented: added the `androidx.browser:browser` dependency (new to this project) and swapped `openSystemBrowser`'s plain `Intent.ACTION_VIEW` for `CustomTabsIntent.Builder().build().launchUrl(...)`, renaming the method to `openAuthorizationTab` to match what it now does; the `ActivityNotFoundException` → `viewModel.browserLaunchFailed()` fallback was preserved unchanged. Deliberately did not implement this with an in-app `WebView` loading the login form directly — RFC 8252 prohibits embedded user-agents for OAuth precisely because a host-app-controlled `WebView` can read the login form's credentials as they're typed; Custom Tabs give the requested "stays in the app" feel without that risk, since the actual system browser (with its own isolated process, cookies, and saved passwords) still renders the page. No existing OAuth protocol code (`OAuthAuthorizationRequest`, `OAuthCallbackValidator`, `Pkce`, `OAuthClientConfig`, the manifest's redirect-URI intent filter) needed any change — Custom Tabs resolve the same `ACTION_VIEW` redirect a full external browser would.
- Files changed: `MainActivity.kt`, `app/build.gradle.kts`, `gradle/libs.versions.toml`, `tasks/026-oauth-custom-tabs.md`.
- Checks and results: `./gradlew compileDebugKotlin` BUILD SUCCESSFUL; `./gradlew assembleDebug testDebugUnitTest lintDebug` BUILD SUCCESSFUL in 3m 28s, 53 actionable tasks, all unit tests passing, 0 lint errors. Installed on the human product owner's device over wireless `adb` after re-pairing.
- Review findings and resolutions: None from a separate review pass — the WebView-vs-Custom-Tabs security tradeoff was reasoned through directly against RFC 8252 rather than found as a defect afterward.
- Known limitations: The Custom Tab's toolbar isn't tinted to match the app's own color scheme (left plain rather than hardcoding a color inconsistent with the app's dynamic/Material You theming) — a minor visual polish item, not a functional gap. The OAuth consent ("Authorize") click itself is unchanged and unavoidable by design; only the surface it renders on changed. Live end-to-end confirmation of the login flow is pending as of this commit.
- Next recommended task: Get the human product owner's live confirmation of the login flow, then continue to the two remaining queued items from TASK-024/025 (image viewer, Issues/PR numbered pagination).
- Next owner: Claude, continuing per the standing direction.

View File

@ -9,9 +9,11 @@ junit = "4.13.2"
coroutines = "1.10.2"
okhttp = "5.4.0"
gson = "2.13.1"
browser = "1.8.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }

View File

@ -0,0 +1,91 @@
# 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 — pending Codex/human review.