Fix widget/app showing stale or no data; add live in-app refresh

Three reliability bugs made data inconsistent:

1. Empty-overwrite: a failed or partial fetch returned an empty
   UsageData that the worker/app saved unconditionally, wiping the last
   good reading and blanking the widget. Added UsageData.mergedWith()
   so a fetch that returns nothing usable keeps the previous snapshot,
   and a partial fetch falls back per-metric. Never blank again.

2. No in-app auto-refresh: onResume only refreshed when the cache was
   >5 min old and there was no live timer. Replaced with a foreground
   lifecycle loop that refreshes on open and every 30s while visible,
   always painting cached data first. Manual button keeps the spinner;
   the loop is silent. App refresh now also pushes the widget update.

3. Spurious logout: a single transient 401/403 (e.g. a Cloudflare
   challenge) called clearSession() immediately, logging the user out
   and showing "Not signed in". Now clears only after 3 consecutive
   auth failures; the counter resets on any successful read.

Battery-friendly: no foreground service. Background widget refresh
stays on the existing alarm + 15-min WorkManager, but with the merge
fix the widget always shows the last data it pulled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:00:07 +00:00
parent 0520f0dc5e
commit 1b5c764ee8
6 changed files with 118 additions and 27 deletions
@@ -42,6 +42,15 @@ class ClaudeUsageWidget : AppWidgetProvider() {
@Volatile internal var isRefreshing = false @Volatile internal var isRefreshing = false
@Volatile internal var currentRotation = 0f @Volatile internal var currentRotation = 0f
/** Redraw every placed widget from the current cached data (call after a refresh). */
fun notifyDataChanged(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
android.content.ComponentName(context, ClaudeUsageWidget::class.java)
)
ids.forEach { updateWidget(context, manager, it) }
}
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) { fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
val prefs = PreferencesManager(context) val prefs = PreferencesManager(context)
val apiData = prefs.getUsageData() val apiData = prefs.getUsageData()
@@ -10,6 +10,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData import me.khodak.claudeusage.data.UsageData
@@ -27,6 +30,9 @@ class MainActivity : AppCompatActivity() {
private val notifPermLauncher = private val notifPermLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ } registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
/** Live refresh loop that runs only while the app is in the foreground. */
private var autoRefreshJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
@@ -75,12 +81,22 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val cached = prefs.getUsageData() updateUI(prefs.getUsageData()) // show cached instantly — never a blank screen
updateUI(cached) if (prefs.isLoggedIn()) startAutoRefresh()
if (prefs.isLoggedIn()) { }
val staleMs = 5 * 60 * 1000L
if ((cached?.lastUpdated ?: 0L) < System.currentTimeMillis() - staleMs) { override fun onPause() {
refreshUsage() super.onPause()
autoRefreshJob?.cancel()
}
/** Refresh immediately on open, then every [REFRESH_INTERVAL_MS] while foregrounded. */
private fun startAutoRefresh() {
autoRefreshJob?.cancel()
autoRefreshJob = lifecycleScope.launch {
while (isActive) {
doRefresh(silent = true)
delay(REFRESH_INTERVAL_MS)
} }
} }
} }
@@ -133,24 +149,33 @@ class MainActivity : AppCompatActivity() {
} }
} }
/** Manual "Refresh Now" button — shows the spinner. */
private fun refreshUsage() { private fun refreshUsage() {
binding.btnRefresh.isEnabled = false lifecycleScope.launch { doRefresh(silent = false) }
binding.progressIndicator.visibility = View.VISIBLE }
lifecycleScope.launch {
val data = try { private suspend fun doRefresh(silent: Boolean) {
repo.fetchUsage() if (!silent) {
} catch (e: Exception) { binding.btnRefresh.isEnabled = false
if (e is kotlinx.coroutines.CancellationException) throw e binding.progressIndicator.visibility = View.VISIBLE
prefs.getUsageData()?.copy(errorMessage = "Network error") }
?: UsageData(errorMessage = "Network error") val fresh = try {
} repo.fetchUsage()
prefs.saveUsageData(data) } catch (e: Exception) {
prefs.recordHistory(data) if (e is kotlinx.coroutines.CancellationException) throw e
Notifier.checkAndNotify(this@MainActivity, prefs, data) UsageData(errorMessage = "Network error")
updateUI(data) }
if (binding.tvDebugInfo.visibility == View.VISIBLE) { // Preserve last-good data so a failed/partial fetch never blanks the UI or widget.
binding.tvDebugInfo.text = repo.lastDebugInfo val merged = fresh.mergedWith(prefs.getUsageData())
} prefs.saveUsageData(merged)
prefs.recordHistory(fresh)
Notifier.checkAndNotify(this, prefs, fresh)
updateUI(merged)
ClaudeUsageWidget.notifyDataChanged(this) // opening the app refreshes the widget too
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
binding.tvDebugInfo.text = repo.lastDebugInfo
}
if (!silent) {
binding.btnRefresh.isEnabled = true binding.btnRefresh.isEnabled = true
binding.progressIndicator.visibility = View.GONE binding.progressIndicator.visibility = View.GONE
} }
@@ -244,6 +269,7 @@ class MainActivity : AppCompatActivity() {
} }
companion object { companion object {
private const val REFRESH_INTERVAL_MS = 30_000L // live refresh cadence while app is open
private const val SESSION_FILL = 0xFFCC785C.toInt() private const val SESSION_FILL = 0xFFCC785C.toInt()
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt() private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color weekly pace marker private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color weekly pace marker
@@ -55,6 +55,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai") if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
if (orgUsageData?.hasRateLimitData == true) { if (orgUsageData?.hasRateLimitData == true) {
prefs.resetAuthFailCount()
return@withContext base.copy( return@withContext base.copy(
messagesUsed = orgUsageData.messagesUsed, messagesUsed = orgUsageData.messagesUsed,
messagesLimit = orgUsageData.messagesLimit, messagesLimit = orgUsageData.messagesLimit,
@@ -77,6 +78,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n") if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n")
val utilData = tryParseUtilizationBody(body) val utilData = tryParseUtilizationBody(body)
if (utilData != null) { if (utilData != null) {
prefs.resetAuthFailCount()
return@withContext base.copy( return@withContext base.copy(
fiveHourUtilization = utilData.fiveHourUtilization, fiveHourUtilization = utilData.fiveHourUtilization,
weeklyUtilization = utilData.weeklyUtilization, weeklyUtilization = utilData.weeklyUtilization,
@@ -102,8 +104,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code") if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code")
if (code == 401 || code == 403) { if (code == 401 || code == 403) {
prefs.clearSession() if (prefs.incAuthFailCount() >= AUTH_FAIL_LIMIT) {
return@withContext UsageData(errorMessage = "Session expired — please sign in again") prefs.clearSession()
prefs.resetAuthFailCount()
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
}
// Transient auth failure — keep showing last-good data instead of logging out.
return@withContext base
} }
val rateLimitData = extractRateLimitHeaders(resp.headers) val rateLimitData = extractRateLimitHeaders(resp.headers)
@@ -341,5 +348,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
companion object { companion object {
private const val TAG = "UsageRepo" private const val TAG = "UsageRepo"
// Clear the session only after this many consecutive 401/403s, so one transient
// auth failure (Cloudflare challenge, brief edge hiccup) doesn't sign the user out.
private const val AUTH_FAIL_LIMIT = 3
} }
} }
@@ -31,8 +31,9 @@ class UsageUpdateWorker(
val animJob = launch { rotateRefreshIcon() } val animJob = launch { rotateRefreshIcon() }
try { try {
val data = UsageRepository(prefs).fetchUsage() val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data) // Preserve last-good data so a failed/partial fetch never blanks the widget.
prefs.recordHistory(data) prefs.saveUsageData(data.mergedWith(prefs.getUsageData()))
prefs.recordHistory(data) // history records only fresh readings
Notifier.checkAndNotify(context, prefs, data) Notifier.checkAndNotify(context, prefs, data)
} catch (_: Exception) {} } catch (_: Exception) {}
animJob.cancel() animJob.cancel()
@@ -74,6 +74,18 @@ class PreferencesManager(context: Context) {
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank() fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
// Consecutive 401/403 counter — we only clear the session after several in a row, so a
// single transient auth failure (e.g. a Cloudflare challenge) doesn't log the user out.
fun getAuthFailCount(): Int = prefs.getInt(KEY_AUTH_FAILS, 0)
fun incAuthFailCount(): Int {
val n = getAuthFailCount() + 1
prefs.edit().putInt(KEY_AUTH_FAILS, n).apply()
return n
}
fun resetAuthFailCount() {
if (getAuthFailCount() != 0) prefs.edit().putInt(KEY_AUTH_FAILS, 0).apply()
}
// ── Usage history (for the in-app chart) ───────────────────────────────── // ── Usage history (for the in-app chart) ─────────────────────────────────
/** /**
@@ -140,6 +152,7 @@ class PreferencesManager(context: Context) {
private const val KEY_NOTIFY_ENABLED = "notify_enabled" private const val KEY_NOTIFY_ENABLED = "notify_enabled"
private const val KEY_NOTIFY_SESSION_PCT = "notify_session_pct" private const val KEY_NOTIFY_SESSION_PCT = "notify_session_pct"
private const val KEY_NOTIFY_WEEKLY_PCT = "notify_weekly_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 private 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 HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
@@ -49,4 +49,36 @@ data class UsageData(
resetAtEpoch > 0 -> resetAtEpoch resetAtEpoch > 0 -> resetAtEpoch
else -> -1L else -> -1L
} }
/** True if this fetch produced any usable usage reading at all. */
val hasAnyReading: Boolean get() =
fiveHourUtilization >= 0f || weeklyUtilization >= 0f || hasRateLimitData
/**
* Merge a fresh fetch over the last cached reading so a failed or partial refresh never
* blanks the widget. If this fetch got nothing usable, the whole previous snapshot is kept
* (with its original timestamp, so the footer shows the data's true age). Otherwise each
* metric this fetch didn't return falls back to the previous value.
*/
fun mergedWith(previous: UsageData?): UsageData {
if (previous == null || !previous.hasAnyReading) return this
if (!hasAnyReading) {
// Keep last-good data; only carry this attempt's login/session context forward.
return previous.copy(
isLoggedIn = isLoggedIn,
sessionStartEpoch = if (sessionStartEpoch > 0) sessionStartEpoch else previous.sessionStartEpoch,
weeklyActiveDaysMask = if (weeklyActiveDaysMask != 0) weeklyActiveDaysMask else previous.weeklyActiveDaysMask
)
}
return copy(
fiveHourUtilization = if (fiveHourUtilization >= 0f) fiveHourUtilization else previous.fiveHourUtilization,
utilizationResetAtEpoch = if (fiveHourUtilization >= 0f) utilizationResetAtEpoch else previous.utilizationResetAtEpoch,
weeklyUtilization = if (weeklyUtilization >= 0f) weeklyUtilization else previous.weeklyUtilization,
weeklyResetAtEpoch = if (weeklyUtilization >= 0f) weeklyResetAtEpoch else previous.weeklyResetAtEpoch,
messagesLimit = if (messagesLimit > 0) messagesLimit else previous.messagesLimit,
messagesUsed = if (messagesUsed >= 0) messagesUsed else previous.messagesUsed,
messagesRemaining = if (messagesRemaining >= 0) messagesRemaining else previous.messagesRemaining,
resetAtEpoch = if (resetAtEpoch > 0) resetAtEpoch else previous.resetAtEpoch
)
}
} }