From 0520f0dc5e4dc20001e16d46a22680e74c31332a Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Thu, 4 Jun 2026 11:49:47 +0000 Subject: [PATCH] v1.14: usage history chart + threshold notifications Add an in-app 7-day history chart and opt-in usage alerts, the two features requested from the macOS Claude-Usage-Tracker that map cleanly to an Android widget app. History: - UsageSnapshot model; PreferencesManager records session/weekly utilization on every refresh (7-day retention, <=600 points, collapses readings under 2 min apart to avoid worker+manual double-logging). - HistoryChartView: dependency-free Canvas line chart (session/weekly, 0/50/100% gridlines), breaks the line across >35-min gaps. - New HISTORY card with chart + legend. Notifications: - Notifier posts when session/weekly crosses a user threshold, at most once per limit window (keyed on reset-epoch, re-arms on rollover). - USAGE ALERTS card: enable switch + session/weekly sliders (50-100%, defaults 90/85). POST_NOTIFICATIONS permission + runtime request. - Wired into the existing 5-min background worker. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 + app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 1 + .../me/khodak/claudeusage/HistoryChartView.kt | 131 ++++++++++++++++++ .../me/khodak/claudeusage/MainActivity.kt | 62 +++++++++ .../java/me/khodak/claudeusage/Notifier.kt | 115 +++++++++++++++ .../khodak/claudeusage/UsageUpdateWorker.kt | 2 + .../claudeusage/data/PreferencesManager.kt | 64 +++++++++ .../khodak/claudeusage/data/UsageSnapshot.kt | 11 ++ app/src/main/res/layout/activity_main.xml | 131 ++++++++++++++++++ 10 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/me/khodak/claudeusage/HistoryChartView.kt create mode 100644 app/src/main/java/me/khodak/claudeusage/Notifier.kt create mode 100644 app/src/main/java/me/khodak/claudeusage/data/UsageSnapshot.kt diff --git a/README.md b/README.md index deda8ff..e8629b4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ Android home screen widget that shows your Claude Pro usage at a glance. yellow β†’ orange β†’ red β†’ purple (burning way too fast), with an "X% over/under pace" label. - **Peak-hours indicator** β€” a Claude burst icon that lights up πŸ”₯ during Anthropic's peak window (5–11 AM Pacific, Mon–Fri), when tokens burn faster, with a countdown to the window close. +- **Usage history chart** β€” the app plots your session and weekly utilization over the past 7 days, + so you can see your consumption trend, not just the current snapshot. +- **Usage alerts** β€” opt-in notifications when session or weekly usage crosses a threshold you set + (sliders, 50–100%). Each alert fires at most once per limit window, so you're never spammed. - Tap the widget to open the app; tap ⟳ to force-refresh - Responsive: works as 4Γ—1 (compact) or 4Γ—2 (full) - Auto-refreshes every 5 minutes in the background diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca2d445..43ba482 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "me.khodak.claudeusage" minSdk = 26 targetSdk = 34 - versionCode = 14 - versionName = "1.13" + versionCode = 15 + versionName = "1.14" } signingConfigs { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9fd3276..cee668d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + = emptyList() + + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFF2A2A2A.toInt(); strokeWidth = dp(1f); style = Paint.Style.STROKE + } + private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFF666666.toInt(); textSize = sp(10f) + } + private val sessionPaint = linePaint(0xFFCC785C.toInt()) + private val weeklyPaint = linePaint(0xFF7B8FCC.toInt()) + private val emptyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFF666666.toInt(); textSize = sp(13f); textAlign = Paint.Align.CENTER + } + + private val padL = dp(28f) + private val padR = dp(8f) + private val padT = dp(10f) + private val padB = dp(18f) + + fun setData(data: List) { + points = data + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val left = padL + val top = padT + val right = width - padR + val bottom = height - padB + if (right <= left || bottom <= top) return + + // Y gridlines + labels at 0 / 50 / 100% + for (pct in intArrayOf(0, 50, 100)) { + val y = bottom - (pct / 100f) * (bottom - top) + canvas.drawLine(left, y, right, y, gridPaint) + canvas.drawText("$pct", dp(4f), y + sp(3.5f), labelPaint) + } + + val plottable = points.filter { it.sessionPct >= 0f || it.weeklyPct >= 0f } + if (plottable.size < 2) { + canvas.drawText( + "Collecting history… check back later", + (left + right) / 2f, (top + bottom) / 2f, emptyPaint + ) + return + } + + val tMin = plottable.first().epochMs + val tMax = plottable.last().epochMs + val tSpan = (tMax - tMin).coerceAtLeast(1L) + + fun x(ms: Long) = left + (ms - tMin).toFloat() / tSpan * (right - left) + fun y(pct: Float) = bottom - (pct / 100f) * (bottom - top) + + drawSeries(canvas, plottable, sessionPaint, ::x, ::y) { it.sessionPct } + drawSeries(canvas, plottable, weeklyPaint, ::x, ::y) { it.weeklyPct } + + // X axis time labels (start … end) + val fmt = SimpleDateFormat("MMM d, h a", Locale.US) + labelPaint.textAlign = Paint.Align.LEFT + canvas.drawText(fmt.format(Date(tMin)), left, height - dp(4f), labelPaint) + labelPaint.textAlign = Paint.Align.RIGHT + canvas.drawText(fmt.format(Date(tMax)), right, height - dp(4f), labelPaint) + labelPaint.textAlign = Paint.Align.LEFT + } + + private inline fun drawSeries( + canvas: Canvas, + data: List, + paint: Paint, + x: (Long) -> Float, + y: (Float) -> Float, + value: (UsageSnapshot) -> Float + ) { + val path = Path() + var penDown = false + var prevMs = 0L + for (p in data) { + val v = value(p) + if (v < 0f) { penDown = false; continue } + val px = x(p.epochMs); val py = y(v) + if (!penDown || p.epochMs - prevMs > GAP_MS) { + path.moveTo(px, py) + } else { + path.lineTo(px, py) + } + penDown = true + prevMs = p.epochMs + } + canvas.drawPath(path, paint) + } + + private fun linePaint(c: Int) = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = c; strokeWidth = dp(2f); style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND; strokeCap = Paint.Cap.ROUND + } + + private fun dp(v: Float) = v * resources.displayMetrics.density + private fun sp(v: Float) = v * resources.displayMetrics.scaledDensity + + companion object { + // Don't connect points separated by more than ~35 min (a missed refresh cycle or two). + private const val GAP_MS = 35 * 60 * 1000L + } +} diff --git a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt index 612c965..bfe14c9 100644 --- a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt +++ b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt @@ -1,9 +1,14 @@ package me.khodak.claudeusage +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.view.View +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import me.khodak.claudeusage.data.PreferencesManager @@ -19,6 +24,9 @@ class MainActivity : AppCompatActivity() { private lateinit var prefs: PreferencesManager private lateinit var repo: UsageRepository + private val notifPermLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) @@ -51,6 +59,8 @@ class MainActivity : AppCompatActivity() { }) } + setupNotificationSettings() + binding.btnDebug.setOnClickListener { if (binding.tvDebugInfo.visibility == android.view.View.GONE) { binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet β€” tap Refresh first" } @@ -75,6 +85,54 @@ class MainActivity : AppCompatActivity() { } } + private fun setupNotificationSettings() { + binding.switchNotify.isChecked = prefs.isNotifyEnabled() + binding.sliderSession.value = prefs.getSessionThreshold().toFloat().coerceIn(50f, 100f) + binding.sliderWeekly.value = prefs.getWeeklyThreshold().toFloat().coerceIn(50f, 100f) + applyThresholdLabels() + applyNotifyControlsEnabled(prefs.isNotifyEnabled()) + + binding.switchNotify.setOnCheckedChangeListener { _, checked -> + prefs.setNotifyEnabled(checked) + applyNotifyControlsEnabled(checked) + if (checked) requestNotificationPermission() + } + binding.sliderSession.addOnChangeListener { _, value, _ -> + prefs.setSessionThreshold(value.toInt()) + applyThresholdLabels() + } + binding.sliderWeekly.addOnChangeListener { _, value, _ -> + prefs.setWeeklyThreshold(value.toInt()) + applyThresholdLabels() + } + + // Alerts default on, so prompt for the runtime permission once on first launch + // (a user who never toggles the switch would otherwise never be asked). + if (prefs.isNotifyEnabled()) requestNotificationPermission() + } + + private fun applyThresholdLabels() { + binding.tvSessionThreshLabel.text = "Session alert at ${prefs.getSessionThreshold()}%" + binding.tvWeeklyThreshLabel.text = "Weekly alert at ${prefs.getWeeklyThreshold()}%" + } + + private fun applyNotifyControlsEnabled(enabled: Boolean) { + binding.sliderSession.isEnabled = enabled + binding.sliderWeekly.isEnabled = enabled + val alpha = if (enabled) 1f else 0.4f + binding.tvSessionThreshLabel.alpha = alpha + binding.tvWeeklyThreshLabel.alpha = alpha + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + notifPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + private fun refreshUsage() { binding.btnRefresh.isEnabled = false binding.progressIndicator.visibility = View.VISIBLE @@ -87,6 +145,8 @@ class MainActivity : AppCompatActivity() { ?: 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 @@ -157,6 +217,8 @@ class MainActivity : AppCompatActivity() { binding.tvError.text = data.errorMessage binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE + + binding.historyChart.setData(prefs.getHistory()) } private fun formatReset(epochMs: Long): String { diff --git a/app/src/main/java/me/khodak/claudeusage/Notifier.kt b/app/src/main/java/me/khodak/claudeusage/Notifier.kt new file mode 100644 index 0000000..48d7914 --- /dev/null +++ b/app/src/main/java/me/khodak/claudeusage/Notifier.kt @@ -0,0 +1,115 @@ +package me.khodak.claudeusage + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import me.khodak.claudeusage.data.PreferencesManager +import me.khodak.claudeusage.data.UsageData + +/** + * Posts a notification when session or weekly utilization crosses the user's threshold. + * Each metric fires at most once per limit window: we remember the reset-epoch we alerted + * for, and only re-arm when that window rolls over (epoch changes) β€” so the user isn't + * pinged every 5 minutes while sitting above the line. + */ +object Notifier { + + private const val CHANNEL_ID = "usage_alerts" + private const val SESSION_NOTIF_ID = 2001 + private const val WEEKLY_NOTIF_ID = 2002 + + fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) { + if (!prefs.isNotifyEnabled()) return + val mgr = NotificationManagerCompat.from(context) + if (!mgr.areNotificationsEnabled()) return // OS-level or runtime permission off + ensureChannel(context) + + val session = data.fiveHourUtilization + if (session >= 0f) { + maybeFire( + context, mgr, prefs, + key = "session", + util = session.toInt(), + threshold = prefs.getSessionThreshold(), + resetEpoch = data.effectiveResetEpoch, + notifId = SESSION_NOTIF_ID, + title = "Session usage at ${session.toInt()}%", + body = "Your current 5-hour window is nearly used up." + ) + } + + val weekly = data.weeklyUtilization + if (weekly >= 0f) { + maybeFire( + context, mgr, prefs, + key = "weekly", + util = weekly.toInt(), + threshold = prefs.getWeeklyThreshold(), + resetEpoch = data.weeklyResetAtEpoch, + notifId = WEEKLY_NOTIF_ID, + title = "Weekly usage at ${weekly.toInt()}%", + body = "You're approaching your weekly Claude limit." + ) + } + } + + private fun maybeFire( + context: Context, + mgr: NotificationManagerCompat, + prefs: PreferencesManager, + key: String, + util: Int, + threshold: Int, + resetEpoch: Long, + notifId: Int, + title: String, + body: String + ) { + if (util < threshold) return + // Already alerted for this exact window? Skip. (resetEpoch<=0 means "unknown window" β€” + // fall back to a coarse marker so we still alert once instead of never.) + val windowMarker = if (resetEpoch > 0) resetEpoch else 1L + if (prefs.getNotifiedResetEpoch(key) == windowMarker) return + + val notif = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_claude_burst) + .setContentTitle(title) + .setContentText(body) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setContentIntent(openAppIntent(context)) + .build() + + try { + mgr.notify(notifId, notif) + prefs.setNotifiedResetEpoch(key, windowMarker) + } catch (_: SecurityException) { + // Notifications revoked between the check and post β€” nothing to do. + } + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (mgr.getNotificationChannel(CHANNEL_ID) != null) return + mgr.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, "Usage alerts", NotificationManager.IMPORTANCE_DEFAULT + ).apply { description = "Alerts when you approach your Claude usage limits" } + ) + } + + private fun openAppIntent(context: Context): PendingIntent { + val intent = Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + return PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } +} diff --git a/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt b/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt index f73214a..4b30796 100644 --- a/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt +++ b/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt @@ -32,6 +32,8 @@ class UsageUpdateWorker( try { val data = UsageRepository(prefs).fetchUsage() prefs.saveUsageData(data) + prefs.recordHistory(data) + Notifier.checkAndNotify(context, prefs, data) } catch (_: Exception) {} animJob.cancel() animJob.join() 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 89ba7b5..8559b0c 100644 --- a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt +++ b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import java.util.Calendar class PreferencesManager(context: Context) { @@ -73,6 +74,61 @@ class PreferencesManager(context: Context) { fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank() + // ── Usage history (for the in-app chart) ───────────────────────────────── + + /** + * Append a history point if [data] carries a real utilization reading. + * De-duplicates rapid double-fires (manual refresh + background worker landing + * together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and + * prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS]. + */ + fun recordHistory(data: UsageData) { + if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return + val now = System.currentTimeMillis() + val history = getHistory().toMutableList() + if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) { + history.removeAt(history.size - 1) // collapse near-simultaneous readings + } + history.add( + UsageSnapshot( + epochMs = now, + sessionPct = data.fiveHourUtilization, + weeklyPct = data.weeklyUtilization + ) + ) + val cutoff = now - HISTORY_RETENTION_MS + val pruned = history.filter { it.epochMs >= cutoff } + .takeLast(MAX_HISTORY_POINTS) + prefs.edit().putString(KEY_HISTORY, gson.toJson(pruned)).apply() + } + + fun getHistory(): List { + val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: emptyList() + } catch (e: Exception) { emptyList() } + } + + // ── Notification settings ──────────────────────────────────────────────── + + fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true) + fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply() + + fun getSessionThreshold(): Int = prefs.getInt(KEY_NOTIFY_SESSION_PCT, 90) + fun setSessionThreshold(pct: Int) = prefs.edit().putInt(KEY_NOTIFY_SESSION_PCT, pct).apply() + + fun getWeeklyThreshold(): Int = prefs.getInt(KEY_NOTIFY_WEEKLY_PCT, 85) + fun setWeeklyThreshold(pct: Int) = prefs.edit().putInt(KEY_NOTIFY_WEEKLY_PCT, pct).apply() + + /** + * Tracks the reset-epoch a metric was last notified for, so we alert at most once + * per limit window. When the window rolls over (reset epoch changes), it re-arms. + */ + fun getNotifiedResetEpoch(key: String): Long = prefs.getLong("notified_$key", 0L) + fun setNotifiedResetEpoch(key: String, epoch: Long) = + prefs.edit().putLong("notified_$key", epoch).apply() + companion object { private const val KEY_COOKIES = "session_cookies" private const val KEY_ORG_ID = "org_id" @@ -80,6 +136,14 @@ class PreferencesManager(context: Context) { private const val KEY_USAGE_DATA = "usage_data" private const val KEY_ACTIVE_WEEK = "active_week" private const val KEY_ACTIVE_MASK = "active_mask" + private const val KEY_HISTORY = "usage_history" + 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 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 MAX_HISTORY_POINTS = 600 fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences { return try { diff --git a/app/src/main/java/me/khodak/claudeusage/data/UsageSnapshot.kt b/app/src/main/java/me/khodak/claudeusage/data/UsageSnapshot.kt new file mode 100644 index 0000000..739b319 --- /dev/null +++ b/app/src/main/java/me/khodak/claudeusage/data/UsageSnapshot.kt @@ -0,0 +1,11 @@ +package me.khodak.claudeusage.data + +/** + * A single point-in-time reading of usage, stored for the in-app history chart. + * Percentages are 0-100; -1 means "no reading for this metric at this time". + */ +data class UsageSnapshot( + val epochMs: Long = 0L, + val sessionPct: Float = -1f, + val weeklyPct: Float = -1f +) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 03e26dd..7d95cc8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -229,6 +229,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +