Harden WebView nav + add test suite + fix lint for production
Build APK / build (push) Successful in 1m29s
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user