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
@@ -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() {
binding.btnRefresh.isEnabled = false
binding.progressIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val data = try {
repo.fetchUsage()
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
prefs.getUsageData()?.copy(errorMessage = "Network error")
?: UsageData(errorMessage = "Network error")
}
prefs.saveUsageData(data)
prefs.recordHistory(data)
Notifier.checkAndNotify(this@MainActivity, prefs, data)
updateUI(data)
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
binding.tvDebugInfo.text = repo.lastDebugInfo
}
lifecycleScope.launch { doRefresh(silent = false) }
}
private suspend fun doRefresh(silent: Boolean) {
if (!silent) {
binding.btnRefresh.isEnabled = false
binding.progressIndicator.visibility = View.VISIBLE
}
val fresh = try {
repo.fetchUsage()
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
UsageData(errorMessage = "Network error")
}
// 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