Compare commits

..

7 Commits

Author SHA1 Message Date
amir 58e3a0fcd7 v1.19: fix empty usage-history chart (always fetch utilization)
Build APK / build (push) Successful in 1m52s
recordHistory() only ever stored fiveHourUtilization/weeklyUtilization, but
fetchUsage() returned early with message-count data — before calling the
/usage utilization endpoint — whenever the org JSON carried a message limit.
So utilization was never populated and the history chart stayed stuck on
'Collecting history…'. The prior chart fix only corrected the throttle.

- UsageRepository: always attempt /usage (preferred signal that drives the
  weekly bar + history); fall back to org message-count data only when
  utilization is unavailable.
- UsageData.sessionReadingPct: utilization preferred, message-count % fallback,
  -1f when no reading — so message-only accounts also build history.
- PreferencesManager.recordHistory: record the session line from
  sessionReadingPct instead of utilization-only.
- UsageDataTest: cover sessionReadingPct.
2026-06-12 01:48:04 +00:00
amir 31e18ed5e9 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.
2026-06-10 11:12:02 +00:00
amir a6d930415c Fix empty usage-history chart + externalize signing secrets
Build APK / build (push) Successful in 2m18s
History chart: recordHistory() threw away the previous point whenever a
new reading landed within the 2-min de-dup window, but the foreground loop
refreshes every 30s — so history could never grow past one point while the
app was open and the chart stayed stuck on 'Collecting history…'. Now it
throttles by SKIPPING a too-soon reading instead of replacing the last one,
so points accumulate during normal use.

Security:
- Remove hardcoded release keystore passwords from build.gradle.kts; read
  from env vars / gitignored keystore.properties; CI injects from Gitea
  secrets (KEYSTORE_PASSWORD/KEY_PASSWORD). Signing identity unchanged.
- Make the cookie-never-plaintext invariant explicit on the read path.
- Drop custom ACTION_REFRESH from the exported widget intent-filter so other
  apps can't trigger refreshes; internal explicit PendingIntent still works.
- Gate an unguarded Log.w behind BuildConfig.DEBUG.
2026-06-10 10:28:37 +00:00
amir c69147530e v1.18: center the tank + driver in the launcher icon
Build APK / build (push) Successful in 1m58s
The subject was filling the frame edge-to-edge, so launcher masking
clipped the gun barrel, treads, and the driver. Auto-detect the tank+bot
(erode away the debris specks, take that bbox), recenter on a square
canvas at ~74% with matching navy #0B1D27 margin so the whole subject
stays inside any mask shape. Regenerated all densities + icon.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:40:00 +00:00
amir 5a5f6ed1e4 v1.17: updated tank launcher icon (close-up, navy #16222B)
Build APK / build (push) Successful in 1m55s
Swap in the closer-up tank art. Source padded to square (no distortion),
adaptive foreground inset ~10% so edges don't clip on round masks, bg
color updated to #16222B. Regenerated all densities + repo icon.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:31:02 +00:00
amir a43fa5be92 v1.16: simplify usage alerts to fixed 90% and 100% (less aggressive)
Build APK / build (push) Successful in 1m50s
Replace the configurable threshold sliders with two fixed alert levels —
90% and 100% — per metric. Anti-spam now uses hysteresis instead of the
API reset-epoch (which could drift and re-fire): each level fires once
when crossed and re-arms only after usage drops back below it. Alerts are
posted only by the background worker, never the in-app refresh loop, so
you're not pinged while looking at the app. UI drops the sliders for a
one-line description; settings keep just the on/off switch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:43:14 +00:00
amir 1d4356c1d7 v1.15: new pixel-art tank launcher icon
Build APK / build (push) Successful in 1m29s
Replace the hexagon "C" launcher icon with the tank-crushing-electronics
art. Adaptive icon (anydpi-v26): full art as foreground over teal
#284950 background, so it masks cleanly to any launcher shape; legacy
PNG bitmaps generated for all densities. Refreshes repo-root icon.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:34:17 +00:00
46 changed files with 609 additions and 254 deletions
+14
View File
@@ -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
+1
View File
@@ -10,3 +10,4 @@ captures/
.cxx/
*.keystore
*.jks
keystore.properties
+37 -5
View File
@@ -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 = 15
versionName = "1.14"
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")
}
+3 -1
View File
@@ -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"
)
}
}
@@ -103,43 +103,15 @@ class MainActivity : AppCompatActivity() {
private fun setupNotificationSettings() {
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
binding.sliderSession.value = prefs.getSessionThreshold().toFloat().coerceIn(50f, 100f)
binding.sliderWeekly.value = prefs.getWeeklyThreshold().toFloat().coerceIn(50f, 100f)
applyThresholdLabels()
applyNotifyControlsEnabled(prefs.isNotifyEnabled())
binding.switchNotify.setOnCheckedChangeListener { _, checked ->
prefs.setNotifyEnabled(checked)
applyNotifyControlsEnabled(checked)
if (checked) requestNotificationPermission()
}
binding.sliderSession.addOnChangeListener { _, value, _ ->
prefs.setSessionThreshold(value.toInt())
applyThresholdLabels()
}
binding.sliderWeekly.addOnChangeListener { _, value, _ ->
prefs.setWeeklyThreshold(value.toInt())
applyThresholdLabels()
}
// Alerts default on, so prompt for the runtime permission once on first launch
// (a user who never toggles the switch would otherwise never be asked).
if (prefs.isNotifyEnabled()) requestNotificationPermission()
}
private fun applyThresholdLabels() {
binding.tvSessionThreshLabel.text = "Session alert at ${prefs.getSessionThreshold()}%"
binding.tvWeeklyThreshLabel.text = "Weekly alert at ${prefs.getWeeklyThreshold()}%"
}
private fun applyNotifyControlsEnabled(enabled: Boolean) {
binding.sliderSession.isEnabled = enabled
binding.sliderWeekly.isEnabled = enabled
val alpha = if (enabled) 1f else 0.4f
binding.tvSessionThreshLabel.alpha = alpha
binding.tvWeeklyThreshLabel.alpha = alpha
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
@@ -169,7 +141,8 @@ class MainActivity : AppCompatActivity() {
val merged = fresh.mergedWith(prefs.getUsageData())
prefs.saveUsageData(merged)
prefs.recordHistory(fresh)
Notifier.checkAndNotify(this, prefs, fresh)
// Note: alerts fire only from the background worker, not here — no point pinging you
// with a notification while you're already looking at the app.
updateUI(merged)
ClaudeUsageWidget.notifyDataChanged(this) // opening the app refreshes the widget too
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
@@ -12,16 +12,18 @@ import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
/**
* Posts a notification when session or weekly utilization crosses the user's threshold.
* Each metric fires at most once per limit window: we remember the reset-epoch we alerted
* for, and only re-arm when that window rolls over (epoch changes) — so the user isn't
* pinged every 5 minutes while sitting above the line.
* Fires exactly two alerts per metric — one at 90% and one at 100% — and no more.
*
* Uses hysteresis, not the API's reset timestamp: each level fires once when usage first
* crosses it, and only re-arms after usage drops back below that level (i.e. a new window /
* usage reset). That keeps it quiet even if the reset time drifts between fetches. Only the
* background worker calls this — never the in-app refresh loop — so you're not pinged while
* you're already looking at the app.
*/
object Notifier {
private const val CHANNEL_ID = "usage_alerts"
private const val SESSION_NOTIF_ID = 2001
private const val WEEKLY_NOTIF_ID = 2002
private val LEVELS = intArrayOf(90, 100)
fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) {
if (!prefs.isNotifyEnabled()) return
@@ -29,53 +31,50 @@ object Notifier {
if (!mgr.areNotificationsEnabled()) return // OS-level or runtime permission off
ensureChannel(context)
val session = data.fiveHourUtilization
if (session >= 0f) {
maybeFire(
context, mgr, prefs,
key = "session",
util = session.toInt(),
threshold = prefs.getSessionThreshold(),
resetEpoch = data.effectiveResetEpoch,
notifId = SESSION_NOTIF_ID,
title = "Session usage at ${session.toInt()}%",
body = "Your current 5-hour window is nearly used up."
)
evaluate(context, mgr, prefs, "session", data.fiveHourUtilization, "5-hour session")
evaluate(context, mgr, prefs, "weekly", data.weeklyUtilization, "weekly")
}
val weekly = data.weeklyUtilization
if (weekly >= 0f) {
maybeFire(
context, mgr, prefs,
key = "weekly",
util = weekly.toInt(),
threshold = prefs.getWeeklyThreshold(),
resetEpoch = data.weeklyResetAtEpoch,
notifId = WEEKLY_NOTIF_ID,
title = "Weekly usage at ${weekly.toInt()}%",
body = "You're approaching your weekly Claude limit."
)
}
}
private fun maybeFire(
private fun evaluate(
context: Context,
mgr: NotificationManagerCompat,
prefs: PreferencesManager,
key: String,
util: Int,
threshold: Int,
resetEpoch: Long,
notifId: Int,
title: String,
body: String
metric: String,
utilization: Float,
label: String
) {
if (util < threshold) return
// Already alerted for this exact window? Skip. (resetEpoch<=0 means "unknown window" —
// fall back to a coarse marker so we still alert once instead of never.)
val windowMarker = if (resetEpoch > 0) resetEpoch else 1L
if (prefs.getNotifiedResetEpoch(key) == windowMarker) return
if (utilization < 0f) return
val util = utilization.toInt()
for ((i, level) in LEVELS.withIndex()) {
val key = "${metric}_$level"
if (util >= level) {
if (!prefs.wasNotified(key)) {
fire(context, mgr, notifId(metric, i), level, label, util)
prefs.setNotified(key, true)
}
} else if (prefs.wasNotified(key)) {
prefs.setNotified(key, false) // dropped below the line → re-arm for next window
}
}
}
private fun fire(
context: Context,
mgr: NotificationManagerCompat,
notifId: Int,
level: Int,
label: String,
util: Int
) {
val title: String
val body: String
if (level >= 100) {
title = "${label.replaceFirstChar { it.uppercase() }} limit reached"
body = "You've hit 100% of your $label limit."
} else {
title = "${label.replaceFirstChar { it.uppercase() }} at $util%"
body = "You're at $level% of your $label limit."
}
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_claude_burst)
.setContentTitle(title)
@@ -84,15 +83,17 @@ object Notifier {
.setAutoCancel(true)
.setContentIntent(openAppIntent(context))
.build()
try {
mgr.notify(notifId, notif)
prefs.setNotifiedResetEpoch(key, windowMarker)
} catch (_: SecurityException) {
// Notifications revoked between the check and post — nothing to do.
}
}
// Stable id per (metric, level) so re-posts replace rather than stack.
private fun notifId(metric: String, levelIndex: Int) =
2000 + (if (metric == "weekly") 10 else 0) + levelIndex
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -100,7 +101,7 @@ object Notifier {
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID, "Usage alerts", NotificationManager.IMPORTANCE_DEFAULT
).apply { description = "Alerts when you approach your Claude usage limits" }
).apply { description = "Alerts at 90% and 100% of your Claude usage limits" }
)
}
@@ -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
)
)
@@ -127,19 +137,13 @@ class PreferencesManager(context: Context) {
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
fun getSessionThreshold(): Int = prefs.getInt(KEY_NOTIFY_SESSION_PCT, 90)
fun setSessionThreshold(pct: Int) = prefs.edit().putInt(KEY_NOTIFY_SESSION_PCT, pct).apply()
fun getWeeklyThreshold(): Int = prefs.getInt(KEY_NOTIFY_WEEKLY_PCT, 85)
fun setWeeklyThreshold(pct: Int) = prefs.edit().putInt(KEY_NOTIFY_WEEKLY_PCT, pct).apply()
/**
* Tracks the reset-epoch a metric was last notified for, so we alert at most once
* per limit window. When the window rolls over (reset epoch changes), it re-arms.
* Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
* cleared when usage drops back below it — so each level fires once per window.
*/
fun getNotifiedResetEpoch(key: String): Long = prefs.getLong("notified_$key", 0L)
fun setNotifiedResetEpoch(key: String, epoch: Long) =
prefs.edit().putLong("notified_$key", epoch).apply()
fun wasNotified(key: String): Boolean = prefs.getBoolean("notified_$key", false)
fun setNotified(key: String, v: Boolean) =
prefs.edit().putBoolean("notified_$key", v).apply()
companion object {
private const val KEY_COOKIES = "session_cookies"
@@ -150,14 +154,23 @@ class PreferencesManager(context: Context) {
private const val KEY_ACTIVE_MASK = "active_mask"
private const val KEY_HISTORY = "usage_history"
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
private const val KEY_NOTIFY_SESSION_PCT = "notify_session_pct"
private const val KEY_NOTIFY_WEEKLY_PCT = "notify_weekly_pct"
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
+5 -32
View File
@@ -323,40 +323,13 @@
</LinearLayout>
<TextView
android:id="@+id/tvSessionThreshLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="Session alert at 90%"
android:textColor="#FFFFFF"
android:textSize="14sp" />
<com.google.android.material.slider.Slider
android:id="@+id/sliderSession"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="50"
android:valueTo="100"
android:stepSize="5"
android:value="90" />
<TextView
android:id="@+id/tvWeeklyThreshLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Weekly alert at 85%"
android:textColor="#FFFFFF"
android:textSize="14sp" />
<com.google.android.material.slider.Slider
android:id="@+id/sliderWeekly"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="50"
android:valueTo="100"
android:stepSize="5"
android:value="85" />
android:layout_marginTop="12dp"
android:text="One alert at 90% and one at 100%, for both your session and weekly limits. Each fires once until usage resets."
android:textColor="#AAAAAA"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
</LinearLayout>
@@ -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>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
+1
View File
@@ -5,4 +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">#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 511 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()))
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+12
View File
@@ -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