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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+