Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58e3a0fcd7 | |||
| 31e18ed5e9 | |||
| a6d930415c | |||
| c69147530e |
@@ -56,6 +56,20 @@ jobs:
|
||||
fi
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore
|
||||
|
||||
# Signing passwords are no longer hardcoded in build.gradle.kts — inject them at build time.
|
||||
- name: Write signing credentials
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
if [ -z "${{ secrets.KEYSTORE_PASSWORD }}" ] || [ -z "${{ secrets.KEY_PASSWORD }}" ]; then
|
||||
echo "::error::KEYSTORE_PASSWORD / KEY_PASSWORD secrets not set — cannot sign release."
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}"
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS || 'claudewidget' }}"
|
||||
} > keystore.properties
|
||||
|
||||
- name: Build release APK
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: ./gradlew :app:assembleRelease --no-daemon
|
||||
|
||||
@@ -10,3 +10,4 @@ captures/
|
||||
.cxx/
|
||||
*.keystore
|
||||
*.jks
|
||||
keystore.properties
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
// Signing credentials are NEVER committed. They are read from (in order):
|
||||
// 1. environment variables (KEYSTORE_PASSWORD / KEY_PASSWORD / KEY_ALIAS) — used by CI
|
||||
// 2. keystore.properties at the repo root (gitignored) — used locally
|
||||
// See keystore.properties.example. Debug builds need none of this.
|
||||
val keystoreProps = Properties().apply {
|
||||
val f = rootProject.file("keystore.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
fun signingCred(envName: String, propName: String): String? =
|
||||
System.getenv(envName) ?: keystoreProps.getProperty(propName)
|
||||
|
||||
android {
|
||||
namespace = "me.khodak.claudeusage"
|
||||
compileSdk = 34
|
||||
@@ -11,16 +24,26 @@ android {
|
||||
applicationId = "me.khodak.claudeusage"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 18
|
||||
versionName = "1.17"
|
||||
versionCode = 20
|
||||
versionName = "1.19"
|
||||
}
|
||||
|
||||
val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword")
|
||||
val releaseKeyPassword = signingCred("KEY_PASSWORD", "keyPassword")
|
||||
val releaseKeyAlias = signingCred("KEY_ALIAS", "keyAlias") ?: "claudewidget"
|
||||
val hasSigningCreds = releaseStorePassword != null && releaseKeyPassword != null
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
// Only wire the keystore when credentials are present, so debug builds and
|
||||
// credential-less checkouts configure cleanly. Same keystore file + alias as before —
|
||||
// signing identity is unchanged; only the password source moved out of version control.
|
||||
if (hasSigningCreds) {
|
||||
storeFile = file("claude-widget-release.keystore")
|
||||
storePassword = "ClaudeWidget2026!"
|
||||
keyAlias = "claudewidget"
|
||||
keyPassword = "ClaudeWidget2026!"
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +52,13 @@ android {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// Sign only when creds were supplied; otherwise fail loudly at assembleRelease rather
|
||||
// than silently shipping an unsigned APK. Tag builds in CI inject creds (see workflow).
|
||||
if (hasSigningCreds) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
logger.warn("No signing credentials (KEYSTORE_PASSWORD/keystore.properties) — release APK will be unsigned.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,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")
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@
|
||||
<receiver
|
||||
android:name=".ClaudeUsageWidget"
|
||||
android:exported="true">
|
||||
<!-- Only the system APPWIDGET_UPDATE action is exposed. ACTION_REFRESH is delivered via
|
||||
the widget's own explicit PendingIntent (explicit broadcasts need no intent-filter),
|
||||
so other apps can no longer trigger refreshes by sending that action externally. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
<action android:name="me.khodak.claudeusage.ACTION_REFRESH" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,17 +54,15 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
|
||||
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
|
||||
|
||||
if (orgUsageData?.hasRateLimitData == true) {
|
||||
prefs.resetAuthFailCount()
|
||||
return@withContext base.copy(
|
||||
messagesUsed = orgUsageData.messagesUsed,
|
||||
messagesLimit = orgUsageData.messagesLimit,
|
||||
messagesRemaining = orgUsageData.messagesRemaining,
|
||||
resetAtEpoch = orgUsageData.resetAtEpoch
|
||||
)
|
||||
}
|
||||
// NOTE: message-count data from the org endpoint (orgUsageData) is a FALLBACK only — do
|
||||
// NOT return here. The /usage utilization endpoint below is the preferred signal and the
|
||||
// one the weekly bar + in-app history chart depend on, so it must be attempted whenever we
|
||||
// have an org id. Returning early here was why the history chart stayed empty: any account
|
||||
// whose org JSON carried a message limit short-circuited before /usage, so
|
||||
// fiveHourUtilization/weeklyUtilization never got populated and recordHistory() had nothing
|
||||
// to store. The org message data is used only if /usage yields nothing (fallback below).
|
||||
|
||||
// Step 2: /usage endpoint — returns utilization percentages
|
||||
// Step 2: /usage endpoint — returns utilization percentages (preferred signal)
|
||||
try {
|
||||
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
||||
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
|
||||
@@ -87,7 +85,19 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "/usage failed: ${e.message}")
|
||||
if (BuildConfig.DEBUG) Log.w(TAG, "/usage failed: ${e.message}")
|
||||
}
|
||||
|
||||
// /usage gave no utilization → fall back to the message-count data the org endpoint
|
||||
// already returned (this is the early-return that used to live before Step 2).
|
||||
if (orgUsageData?.hasRateLimitData == true) {
|
||||
prefs.resetAuthFailCount()
|
||||
return@withContext base.copy(
|
||||
messagesUsed = orgUsageData.messagesUsed,
|
||||
messagesLimit = orgUsageData.messagesLimit,
|
||||
messagesRemaining = orgUsageData.messagesRemaining,
|
||||
resetAtEpoch = orgUsageData.resetAtEpoch
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: fallback endpoints (message-count style)
|
||||
|
||||
@@ -24,9 +24,14 @@ class PreferencesManager(context: Context) {
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun getCookies(): String? = try {
|
||||
fun getCookies(): String? {
|
||||
// Cookies are never written in fallback (plaintext) mode — make that invariant explicit on
|
||||
// the read side too, so any future write that bypasses the guard still can't surface here.
|
||||
if (usingFallbackPrefs) return null
|
||||
return try {
|
||||
securePrefs.getString(KEY_COOKIES, null)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun clearSession() {
|
||||
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
|
||||
@@ -89,22 +94,27 @@ class PreferencesManager(context: Context) {
|
||||
// ── Usage history (for the in-app chart) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append a history point if [data] carries a real utilization reading.
|
||||
* Append a history point if [data] carries a real reading.
|
||||
* The session line uses [UsageData.sessionReadingPct] (utilization preferred, message-count
|
||||
* progress as fallback) so accounts that only expose message counts still build history.
|
||||
* De-duplicates rapid double-fires (manual refresh + background worker landing
|
||||
* together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and
|
||||
* prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS].
|
||||
*/
|
||||
fun recordHistory(data: UsageData) {
|
||||
if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return
|
||||
val sessionPct = data.sessionReadingPct
|
||||
if (sessionPct < 0f && data.weeklyUtilization < 0f) return
|
||||
val now = System.currentTimeMillis()
|
||||
val history = getHistory().toMutableList()
|
||||
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) {
|
||||
history.removeAt(history.size - 1) // collapse near-simultaneous readings
|
||||
}
|
||||
// Throttle to at most one point per MIN_HISTORY_GAP_MS by SKIPPING a too-soon reading.
|
||||
// (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 (!shouldRecordHistory(history.lastOrNull()?.epochMs, now, MIN_HISTORY_GAP_MS)) return
|
||||
history.add(
|
||||
UsageSnapshot(
|
||||
epochMs = now,
|
||||
sessionPct = data.fiveHourUtilization,
|
||||
sessionPct = sessionPct,
|
||||
weeklyPct = data.weeklyUtilization
|
||||
)
|
||||
)
|
||||
@@ -146,10 +156,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)
|
||||
|
||||
@@ -43,6 +43,19 @@ data class UsageData(
|
||||
|
||||
val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask)
|
||||
|
||||
/**
|
||||
* Session utilization as a 0-100 reading for the history chart, preferring the /usage
|
||||
* utilization endpoint and falling back to message-count progress. Returns -1f when there is
|
||||
* no usable reading — so message-only accounts (no utilization endpoint) still build history
|
||||
* instead of leaving the chart permanently on "Collecting history…".
|
||||
*/
|
||||
val sessionReadingPct: Float get() = when {
|
||||
fiveHourUtilization >= 0f -> fiveHourUtilization
|
||||
messagesLimit > 0 && effectiveUsed >= 0 ->
|
||||
((effectiveUsed.toFloat() / messagesLimit.toFloat()) * 100f).coerceIn(0f, 100f)
|
||||
else -> -1f
|
||||
}
|
||||
|
||||
// The best reset time we have (utilization endpoint is preferred)
|
||||
val effectiveResetEpoch: Long get() = when {
|
||||
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/widget_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
@@ -48,6 +49,7 @@
|
||||
android:src="@android:drawable/ic_menu_rotate"
|
||||
android:background="@android:color/transparent"
|
||||
android:tint="#999999"
|
||||
tools:ignore="UseAppTint"
|
||||
android:contentDescription="Refresh" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 28 KiB |
@@ -5,5 +5,5 @@
|
||||
<color name="surface_dark">#1E1E1E</color>
|
||||
<color name="text_primary">#FFFFFF</color>
|
||||
<color name="text_secondary">#888888</color>
|
||||
<color name="ic_launcher_bg">#16222B</color>
|
||||
<color name="ic_launcher_bg">#0B1D27</color>
|
||||
</resources>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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 sessionReadingPctPrefersUtilization() {
|
||||
// Utilization present → used verbatim (kept as Float, not truncated), even alongside messages.
|
||||
val d = UsageData(fiveHourUtilization = 42.5f, messagesUsed = 10, messagesLimit = 100)
|
||||
assertEquals(42.5f, d.sessionReadingPct, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionReadingPctFallsBackToMessages() {
|
||||
// No utilization → derive from message counts so message-only accounts still get history.
|
||||
val d = UsageData(messagesUsed = 30, messagesLimit = 120)
|
||||
assertEquals(25f, d.sessionReadingPct, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionReadingPctMessagesCoercedTo100() {
|
||||
val d = UsageData(messagesUsed = 150, messagesLimit = 100)
|
||||
assertEquals(100f, d.sessionReadingPct, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionReadingPctNegativeWhenNoReading() {
|
||||
// No utilization and no message data → -1f, so recordHistory skips the point.
|
||||
assertEquals(-1f, UsageData().sessionReadingPct, 0f)
|
||||
}
|
||||
|
||||
@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()))
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,12 @@
|
||||
# Release signing credentials for local builds.
|
||||
# Copy to `keystore.properties` (which is gitignored) and fill in the real values.
|
||||
# These are NEVER committed. CI injects the same values from Gitea Actions secrets.
|
||||
#
|
||||
# cp keystore.properties.example keystore.properties
|
||||
#
|
||||
# The keystore file itself (app/claude-widget-release.keystore) is also gitignored.
|
||||
# Signing identity is unchanged from older builds — same keystore, same alias.
|
||||
|
||||
storePassword=CHANGE_ME
|
||||
keyPassword=CHANGE_ME
|
||||
keyAlias=claudewidget
|
||||