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:
@@ -42,6 +42,15 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
@Volatile internal var isRefreshing = false
|
||||
@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) {
|
||||
val prefs = PreferencesManager(context)
|
||||
val apiData = prefs.getUsageData()
|
||||
|
||||
@@ -10,6 +10,9 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import me.khodak.claudeusage.data.PreferencesManager
|
||||
import me.khodak.claudeusage.data.UsageData
|
||||
@@ -27,6 +30,9 @@ class MainActivity : AppCompatActivity() {
|
||||
private val notifPermLauncher =
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
@@ -75,12 +81,22 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val cached = prefs.getUsageData()
|
||||
updateUI(cached)
|
||||
if (prefs.isLoggedIn()) {
|
||||
val staleMs = 5 * 60 * 1000L
|
||||
if ((cached?.lastUpdated ?: 0L) < System.currentTimeMillis() - staleMs) {
|
||||
refreshUsage()
|
||||
updateUI(prefs.getUsageData()) // show cached instantly — never a blank screen
|
||||
if (prefs.isLoggedIn()) startAutoRefresh()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
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() {
|
||||
lifecycleScope.launch { doRefresh(silent = false) }
|
||||
}
|
||||
|
||||
private suspend fun doRefresh(silent: Boolean) {
|
||||
if (!silent) {
|
||||
binding.btnRefresh.isEnabled = false
|
||||
binding.progressIndicator.visibility = View.VISIBLE
|
||||
lifecycleScope.launch {
|
||||
val data = try {
|
||||
}
|
||||
val fresh = try {
|
||||
repo.fetchUsage()
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
prefs.getUsageData()?.copy(errorMessage = "Network error")
|
||||
?: UsageData(errorMessage = "Network error")
|
||||
UsageData(errorMessage = "Network error")
|
||||
}
|
||||
prefs.saveUsageData(data)
|
||||
prefs.recordHistory(data)
|
||||
Notifier.checkAndNotify(this@MainActivity, prefs, data)
|
||||
updateUI(data)
|
||||
// Preserve last-good data so a failed/partial fetch never blanks the UI or widget.
|
||||
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.progressIndicator.visibility = View.GONE
|
||||
}
|
||||
@@ -244,6 +269,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
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 WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
||||
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 (orgUsageData?.hasRateLimitData == true) {
|
||||
prefs.resetAuthFailCount()
|
||||
return@withContext base.copy(
|
||||
messagesUsed = orgUsageData.messagesUsed,
|
||||
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")
|
||||
val utilData = tryParseUtilizationBody(body)
|
||||
if (utilData != null) {
|
||||
prefs.resetAuthFailCount()
|
||||
return@withContext base.copy(
|
||||
fiveHourUtilization = utilData.fiveHourUtilization,
|
||||
weeklyUtilization = utilData.weeklyUtilization,
|
||||
@@ -102,9 +104,14 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url → $code")
|
||||
|
||||
if (code == 401 || code == 403) {
|
||||
if (prefs.incAuthFailCount() >= AUTH_FAIL_LIMIT) {
|
||||
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 body = resp.body?.string() ?: ""
|
||||
@@ -341,5 +348,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
|
||||
companion object {
|
||||
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() }
|
||||
try {
|
||||
val data = UsageRepository(prefs).fetchUsage()
|
||||
prefs.saveUsageData(data)
|
||||
prefs.recordHistory(data)
|
||||
// Preserve last-good data so a failed/partial fetch never blanks the widget.
|
||||
prefs.saveUsageData(data.mergedWith(prefs.getUsageData()))
|
||||
prefs.recordHistory(data) // history records only fresh readings
|
||||
Notifier.checkAndNotify(context, prefs, data)
|
||||
} catch (_: Exception) {}
|
||||
animJob.cancel()
|
||||
|
||||
@@ -74,6 +74,18 @@ class PreferencesManager(context: Context) {
|
||||
|
||||
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) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -140,6 +152,7 @@ class PreferencesManager(context: Context) {
|
||||
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
|
||||
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
|
||||
|
||||
@@ -49,4 +49,36 @@ data class UsageData(
|
||||
resetAtEpoch > 0 -> resetAtEpoch
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user