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:
parent
e2c25d8662
commit
e219016998
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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" }
|
||||
|
||||
91
tasks/026-oauth-custom-tabs.md
Normal file
91
tasks/026-oauth-custom-tabs.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user