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"
)
}
}
@@ -107,7 +107,7 @@ class PreferencesManager(context: Context) {
// (Previously we deleted the last point and re-added in place — but the foreground loop
// refreshes every 30s, well inside this 2-min window, so history could never grow past a
// single point while the app was open and the chart stayed on "Collecting history…".)
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) return
if (!shouldRecordHistory(history.lastOrNull()?.epochMs, now, MIN_HISTORY_GAP_MS)) return
history.add(
UsageSnapshot(
epochMs = now,
@@ -153,10 +153,21 @@ class PreferencesManager(context: Context) {
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
private const val KEY_AUTH_FAILS = "auth_fail_count"
private const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
internal const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
private const val MAX_HISTORY_POINTS = 600
/**
* Pure throttle decision for [recordHistory]: should a new point be appended?
* Returns false only when a previous point exists ([lastEpochMs] != null) AND the gap to
* [now] is below [minGapMs]; true otherwise (including the first-ever point, lastEpochMs == null).
* No Android dependencies — kept separate so the throttle rule is unit-testable.
*/
internal fun shouldRecordHistory(lastEpochMs: Long?, now: Long, minGapMs: Long): Boolean {
if (lastEpochMs == null) return true
return now - lastEpochMs >= minGapMs
}
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
return try {
buildEncryptedPrefs(context)