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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user