Harden WebView nav + add test suite + fix lint for production
Build APK / build (push) Successful in 1m29s

Security:
- LoginActivity WebView now enforces a host allow-list in
  shouldOverrideUrlLoading: only claude.ai + required SSO/CDN hosts
  (Google, Apple, Cloudflare, gstatic, recaptcha) can navigate; everything
  else is blocked. market://intent:// still blocked; about:/data: allowed.
  Device-verified: claude.ai login + Cloudflare challenge still load.

Tests (33, pure-JVM JUnit4, no device needed):
- Extracted shouldRecordHistory() pure throttle decision (regression guard
  for the empty-history-chart bug) + HistoryThrottleTest.
- UsageDataTest (mergedWith last-good/partial-union, computed props),
  PaceCalcTest, PeakHoursTest.
- Added junit:junit:4.13.2 as testImplementation only.

Build quality:
- widget_layout.xml: suppress false-positive UseAppTint lint on the widget
  refresh button (app:tint doesn't work in RemoteViews; android:tint is
  correct here) so lintDebug is clean.

Verified locally: 33 unit tests pass, lintDebug 0 errors, signed
assembleRelease OK (apksigner verified, signer identity unchanged),
emulator smoke test launches + renders without crash.
This commit is contained in:
2026-06-10 11:12:02 +00:00
parent a6d930415c
commit 31e18ed5e9
8 changed files with 392 additions and 4 deletions
@@ -85,9 +85,19 @@ class LoginActivity : AppCompatActivity() {
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
val uri = request.url
val url = uri.toString()
// Keep blocking app-redirect schemes outright.
if (url.startsWith("market://") || url.startsWith("intent://")) return true
return false
val scheme = uri.scheme?.lowercase()
// about:blank and data: URIs are used by the login page itself (e.g. blank
// bootstrap frames, inline images). They have no host to allow-list, so let them load.
if (scheme == "about" || scheme == "data") return false
// Host ALLOW-LIST: only navigate to claude.ai and its SSO/CDN providers.
// OAuth runs as full-page redirects in THIS WebView (multiple windows disabled,
// JS-opened windows disabled), so the list MUST include the Google/Apple SSO
// and Cloudflare challenge hosts or sign-in breaks. Anything else is blocked.
return !isHostAllowed(uri.host)
}
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
@@ -169,4 +179,35 @@ class LoginActivity : AppCompatActivity() {
binding.webView.destroy()
super.onDestroy()
}
/**
* True if [host] belongs to an allowed domain (exact match or a subdomain via dot-suffix).
* A null/blank host is never allowed. accounts.google.com, challenges.cloudflare.com, etc. are
* covered by their parent-domain suffixes (google.com, cloudflare.com).
*/
private fun isHostAllowed(host: String?): Boolean {
if (host.isNullOrBlank()) return false
val h = host.lowercase()
return ALLOWED_DOMAINS.any { d -> h == d || h.endsWith(".$d") }
}
companion object {
// WebView host allow-list. Login navigation is restricted to claude.ai and the SSO/CDN
// hosts it redirects through. This MUST include the SSO providers (Google, Apple) and
// Cloudflare challenge hosts — OAuth happens as same-WebView full-page redirects, so
// dropping any of these breaks sign-in. Subdomains are matched by dot-suffix.
private val ALLOWED_DOMAINS = listOf(
"claude.ai",
"anthropic.com",
"google.com", // covers accounts.google.com
"gstatic.com",
"googleusercontent.com",
"googleapis.com",
"apple.com",
"icloud.com",
"cloudflare.com", // covers challenges.cloudflare.com
"cloudflareinsights.com",
"recaptcha.net"
)
}
}