From 31e18ed5e99563ad7477a02ba83137f8e46526f6 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Wed, 10 Jun 2026 11:12:02 +0000 Subject: [PATCH] Harden WebView nav + add test suite + fix lint for production 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. --- app/build.gradle.kts | 3 + .../me/khodak/claudeusage/LoginActivity.kt | 45 ++++- .../claudeusage/data/PreferencesManager.kt | 15 +- app/src/main/res/layout/widget_layout.xml | 2 + .../khodak/claudeusage/HistoryThrottleTest.kt | 44 +++++ .../me/khodak/claudeusage/PaceCalcTest.kt | 75 +++++++++ .../me/khodak/claudeusage/PeakHoursTest.kt | 58 +++++++ .../me/khodak/claudeusage/UsageDataTest.kt | 154 ++++++++++++++++++ 8 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/me/khodak/claudeusage/HistoryThrottleTest.kt create mode 100644 app/src/test/java/me/khodak/claudeusage/PaceCalcTest.kt create mode 100644 app/src/test/java/me/khodak/claudeusage/PeakHoursTest.kt create mode 100644 app/src/test/java/me/khodak/claudeusage/UsageDataTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c9347f..0c8449c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,4 +101,7 @@ dependencies { // Lifecycle implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // Unit tests (pure JVM — no Robolectric, no Android framework) + testImplementation("junit:junit:4.13.2") } diff --git a/app/src/main/java/me/khodak/claudeusage/LoginActivity.kt b/app/src/main/java/me/khodak/claudeusage/LoginActivity.kt index 356bc60..f223c17 100644 --- a/app/src/main/java/me/khodak/claudeusage/LoginActivity.kt +++ b/app/src/main/java/me/khodak/claudeusage/LoginActivity.kt @@ -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" + ) + } } diff --git a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt index 2dd3dc0..bb44fcc 100644 --- a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt +++ b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt @@ -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) diff --git a/app/src/main/res/layout/widget_layout.xml b/app/src/main/res/layout/widget_layout.xml index 8653ea8..90b4e2c 100644 --- a/app/src/main/res/layout/widget_layout.xml +++ b/app/src/main/res/layout/widget_layout.xml @@ -1,5 +1,6 @@ diff --git a/app/src/test/java/me/khodak/claudeusage/HistoryThrottleTest.kt b/app/src/test/java/me/khodak/claudeusage/HistoryThrottleTest.kt new file mode 100644 index 0000000..76576a7 --- /dev/null +++ b/app/src/test/java/me/khodak/claudeusage/HistoryThrottleTest.kt @@ -0,0 +1,44 @@ +package me.khodak.claudeusage + +import me.khodak.claudeusage.data.PreferencesManager +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-JVM tests for the history-throttle decision extracted from PreferencesManager.recordHistory. + * No Android Context — exercises only PreferencesManager.shouldRecordHistory. + */ +class HistoryThrottleTest { + + private val gap = PreferencesManager.MIN_HISTORY_GAP_MS + + @Test + fun returnsTrueWhenNoPreviousPoint() { + // First-ever reading (lastEpochMs == null) is always recorded. + assertTrue(PreferencesManager.shouldRecordHistory(null, now = 1_000_000L, minGapMs = gap)) + } + + @Test + fun returnsFalseWhenWithinGap() { + val last = 1_000_000L + // One millisecond before the gap elapses → throttled. + val now = last + gap - 1L + assertFalse(PreferencesManager.shouldRecordHistory(last, now, gap)) + } + + @Test + fun returnsTrueExactlyAtGapBoundary() { + val last = 1_000_000L + // Exactly at the gap (>= boundary is inclusive) → recorded. + val now = last + gap + assertTrue(PreferencesManager.shouldRecordHistory(last, now, gap)) + } + + @Test + fun returnsTrueAfterGap() { + val last = 1_000_000L + val now = last + gap + 1L + assertTrue(PreferencesManager.shouldRecordHistory(last, now, gap)) + } +} diff --git a/app/src/test/java/me/khodak/claudeusage/PaceCalcTest.kt b/app/src/test/java/me/khodak/claudeusage/PaceCalcTest.kt new file mode 100644 index 0000000..d747dcb --- /dev/null +++ b/app/src/test/java/me/khodak/claudeusage/PaceCalcTest.kt @@ -0,0 +1,75 @@ +package me.khodak.claudeusage + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-JVM tests for PaceCalc.compute. The clock is injected via the `now` parameter, so these are + * deterministic and require no Android framework. + */ +class PaceCalcTest { + + private val window = PaceCalc.SESSION_WINDOW_MS // 5h + + @Test + fun computeReturnsPaceForValidFutureReset() { + // Place "now" halfway through the window (elapsedFraction = 0.5, within [0.03, 1.0)). + val resetEpoch = 10_000_000_000L + val now = resetEpoch - window / 2 + val pace = PaceCalc.compute(usedPct = 25f, resetEpoch = resetEpoch, windowMs = window, now = now) + + assertNotNull(pace) + val p = pace!! + // markerPct mirrors elapsedFraction*100 → ~50, and must stay in 0..100. + assertTrue("markerPct in range", p.markerPct in 0..100) + assertEquals50(p.markerPct) + } + + @Test + fun markerPctAlwaysInRangeNearWindowEnd() { + // Near the end of the window (95% elapsed, still < 1.0) marker approaches 100, never exceeds. + // Kept off the exact boundary so Float rounding of (window-elapsed)/window can't tip it to 1.0. + val resetEpoch = 10_000_000_000L + val now = resetEpoch - (window * 5L / 100L) // 95% elapsed + val pace = PaceCalc.compute(usedPct = 80f, resetEpoch = resetEpoch, windowMs = window, now = now) + + assertNotNull(pace) + assertTrue(pace!!.markerPct in 0..100) + } + + @Test + fun computeReturnsNullForPastReset() { + // Reset already passed → elapsedFraction >= 1.0 → null. + val resetEpoch = 10_000_000_000L + val now = resetEpoch + 60_000L + assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = resetEpoch, windowMs = window, now = now)) + } + + @Test + fun computeReturnsNullTooEarlyInWindow() { + // Less than 3% elapsed → not enough signal → null. + val resetEpoch = 10_000_000_000L + val now = resetEpoch - window + (window * 0.01f).toLong() // 1% elapsed + assertNull(PaceCalc.compute(usedPct = 5f, resetEpoch = resetEpoch, windowMs = window, now = now)) + } + + @Test + fun computeReturnsNullForNonPositiveReset() { + assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = 0L, windowMs = window)) + assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = -1L, windowMs = window)) + } + + @Test + fun computeReturnsNullForNegativeUsedPct() { + val resetEpoch = 10_000_000_000L + val now = resetEpoch - window / 2 + assertNull(PaceCalc.compute(usedPct = -1f, resetEpoch = resetEpoch, windowMs = window, now = now)) + } + + private fun assertEquals50(markerPct: Int) { + // elapsedFraction 0.5 → (0.5*100).toInt() == 50. + org.junit.Assert.assertEquals(50, markerPct) + } +} diff --git a/app/src/test/java/me/khodak/claudeusage/PeakHoursTest.kt b/app/src/test/java/me/khodak/claudeusage/PeakHoursTest.kt new file mode 100644 index 0000000..6da8044 --- /dev/null +++ b/app/src/test/java/me/khodak/claudeusage/PeakHoursTest.kt @@ -0,0 +1,58 @@ +package me.khodak.claudeusage + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.TimeZone + +/** + * Pure-JVM tests for PeakHours.isPeak. The clock is injected via `now`, and PeakHours evaluates the + * window in America/Los_Angeles regardless of the device timezone — so we build each instant in PT + * explicitly, making these deterministic without touching the system clock. + */ +class PeakHoursTest { + + private val pt: TimeZone = TimeZone.getTimeZone("America/Los_Angeles") + + /** Absolute epoch millis for the given wall-clock time in PT on a fixed reference date. */ + private fun ptInstant(year: Int, month0: Int, day: Int, hour: Int): Long { + val cal = GregorianCalendar(pt) + cal.clear() + cal.set(year, month0, day, hour, 0, 0) + return cal.timeInMillis + } + + @Test + fun activeOnWeekdayInsideWindow() { + // 2026-06-10 is a Wednesday. 8 AM PT is inside 5–11 AM. + assertTrue(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 8))) + } + + @Test + fun inactiveAtStartBoundaryIsInclusiveButBeforeIsNot() { + // 5 AM PT is inside (inclusive start); 4 AM PT is outside. + assertTrue(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 5))) + assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 4))) + } + + @Test + fun inactiveAtEndBoundaryExclusive() { + // 11 AM PT is the exclusive end → not peak. + assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 11))) + } + + @Test + fun inactiveOnWeekendEvenInsideHours() { + // 2026-06-13 is a Saturday, 2026-06-14 is a Sunday. 8 AM PT, but weekend → not peak. + assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 13, 8))) + assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 14, 8))) + } + + @Test + fun inactiveOnWeekdayEvening() { + // 8 PM PT Wednesday → outside the morning window. + assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 20))) + } +} diff --git a/app/src/test/java/me/khodak/claudeusage/UsageDataTest.kt b/app/src/test/java/me/khodak/claudeusage/UsageDataTest.kt new file mode 100644 index 0000000..b745276 --- /dev/null +++ b/app/src/test/java/me/khodak/claudeusage/UsageDataTest.kt @@ -0,0 +1,154 @@ +package me.khodak.claudeusage + +import me.khodak.claudeusage.data.UsageData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-JVM tests for the UsageData data class: derived metrics and the mergedWith fallback logic. + * UsageData has no Android dependencies, so these run on the plain JVM. + */ +class UsageDataTest { + + @Test + fun progressPercentMessagesPath() { + // messagesLimit > 0 and effectiveUsed derived from messagesUsed → 25/100 = 25%. + val d = UsageData(messagesUsed = 25, messagesLimit = 100) + assertEquals(25, d.progressPercent) + } + + @Test + fun progressPercentMessagesPathCoercedTo100() { + // Used above limit must clamp to 100, never exceed. + val d = UsageData(messagesUsed = 150, messagesLimit = 100) + assertEquals(100, d.progressPercent) + } + + @Test + fun progressPercentUtilizationPathWhenNoMessages() { + // No messages limit → fall through to utilization, truncated to Int and coerced. + val d = UsageData(fiveHourUtilization = 42.9f) + assertEquals(42, d.progressPercent) + } + + @Test + fun progressPercentUtilizationCoercedTo100() { + val d = UsageData(fiveHourUtilization = 130f) + assertEquals(100, d.progressPercent) + } + + @Test + fun progressPercentZeroWhenNoReadings() { + assertEquals(0, UsageData().progressPercent) + } + + @Test + fun effectiveUsedPrefersMessagesUsed() { + assertEquals(30, UsageData(messagesUsed = 30, messagesLimit = 100).effectiveUsed) + } + + @Test + fun effectiveUsedDerivedFromRemaining() { + // No messagesUsed, but remaining + limit present → limit - remaining. + val d = UsageData(messagesRemaining = 40, messagesLimit = 100) + assertEquals(60, d.effectiveUsed) + } + + @Test + fun effectiveUsedUnknownIsNegativeOne() { + assertEquals(-1, UsageData().effectiveUsed) + } + + @Test + fun effectiveRemainingPrefersRemaining() { + assertEquals(40, UsageData(messagesRemaining = 40, messagesLimit = 100).effectiveRemaining) + } + + @Test + fun effectiveRemainingDerivedFromUsed() { + val d = UsageData(messagesUsed = 70, messagesLimit = 100) + assertEquals(30, d.effectiveRemaining) + } + + @Test + fun effectiveRemainingUnknownIsNegativeOne() { + assertEquals(-1, UsageData().effectiveRemaining) + } + + @Test + fun hasAnyReadingTrueWithUtilization() { + assertTrue(UsageData(fiveHourUtilization = 10f).hasAnyReading) + assertTrue(UsageData(weeklyUtilization = 5f).hasAnyReading) + } + + @Test + fun hasAnyReadingTrueWithRateLimitData() { + // hasRateLimitData true when messagesLimit > 0. + assertTrue(UsageData(messagesLimit = 100).hasAnyReading) + } + + @Test + fun hasAnyReadingFalseWhenEmpty() { + assertFalse(UsageData().hasAnyReading) + } + + @Test + fun mergedWithEmptyFetchKeepsPreviousMetrics() { + // (a) previous has a reading; this fetch is empty (all defaults) → previous metrics kept. + val previous = UsageData( + fiveHourUtilization = 55f, + utilizationResetAtEpoch = 1_000L, + weeklyUtilization = 20f, + weeklyResetAtEpoch = 2_000L, + messagesLimit = 100, + messagesUsed = 40 + ) + val emptyFetch = UsageData(isLoggedIn = true) + val merged = emptyFetch.mergedWith(previous) + + assertEquals(55f, merged.fiveHourUtilization, 0f) + assertEquals(20f, merged.weeklyUtilization, 0f) + assertEquals(100, merged.messagesLimit) + assertEquals(40, merged.messagesUsed) + assertEquals(1_000L, merged.utilizationResetAtEpoch) + assertEquals(2_000L, merged.weeklyResetAtEpoch) + // Login context from the fresh (empty) attempt is carried forward. + assertTrue(merged.isLoggedIn) + } + + @Test + fun mergedWithPartialFetchUnionsBothWindows() { + // (b) previous only had five-hour; this fetch only has weekly → result has both. + val previous = UsageData( + fiveHourUtilization = 33f, + utilizationResetAtEpoch = 500L + ) + val partial = UsageData( + weeklyUtilization = 77f, + weeklyResetAtEpoch = 900L + ) + val merged = partial.mergedWith(previous) + + assertEquals(33f, merged.fiveHourUtilization, 0f) + assertEquals(500L, merged.utilizationResetAtEpoch) + assertEquals(77f, merged.weeklyUtilization, 0f) + assertEquals(900L, merged.weeklyResetAtEpoch) + } + + @Test + fun mergedWithNullPreviousReturnsThis() { + // (c) previous == null → returns this unchanged. + val fetch = UsageData(fiveHourUtilization = 12f) + assertSame(fetch, fetch.mergedWith(null)) + } + + @Test + fun mergedWithPreviousWithoutReadingReturnsThis() { + // previous has no usable reading → this is returned unchanged. + val fetch = UsageData(fiveHourUtilization = 12f) + assertSame(fetch, fetch.mergedWith(UsageData())) + } +}