diff --git a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt index f77ce04..48146b1 100644 --- a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt +++ b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt @@ -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() diff --git a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt index bfe14c9..64f8d88 100644 --- a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt +++ b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt @@ -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 diff --git a/app/src/main/java/me/khodak/claudeusage/UsageRepository.kt b/app/src/main/java/me/khodak/claudeusage/UsageRepository.kt index cfe2015..bfe8c83 100644 --- a/app/src/main/java/me/khodak/claudeusage/UsageRepository.kt +++ b/app/src/main/java/me/khodak/claudeusage/UsageRepository.kt @@ -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,8 +104,13 @@ class UsageRepository(private val prefs: PreferencesManager) { if (BuildConfig.DEBUG) Log.d(TAG, "GET $url → $code") if (code == 401 || code == 403) { - prefs.clearSession() - return@withContext UsageData(errorMessage = "Session expired — please sign in again") + 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) @@ -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 } } diff --git a/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt b/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt index 4b30796..de837eb 100644 --- a/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt +++ b/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt @@ -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() diff --git a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt index 8559b0c..5f05df9 100644 --- a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt +++ b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt @@ -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 diff --git a/app/src/main/java/me/khodak/claudeusage/data/UsageData.kt b/app/src/main/java/me/khodak/claudeusage/data/UsageData.kt index 41884fe..26b1f79 100644 --- a/app/src/main/java/me/khodak/claudeusage/data/UsageData.kt +++ b/app/src/main/java/me/khodak/claudeusage/data/UsageData.kt @@ -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 + ) + } }