v1.16: simplify usage alerts to fixed 90% and 100% (less aggressive)
Build APK / build (push) Successful in 1m50s

Replace the configurable threshold sliders with two fixed alert levels —
90% and 100% — per metric. Anti-spam now uses hysteresis instead of the
API reset-epoch (which could drift and re-fire): each level fires once
when crossed and re-arms only after usage drops back below it. Alerts are
posted only by the background worker, never the in-app refresh loop, so
you're not pinged while looking at the app. UI drops the sliders for a
one-line description; settings keep just the on/off switch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 15:43:14 +00:00
co-authored by Claude Opus 4.8
parent 1d4356c1d7
commit a43fa5be92
5 changed files with 64 additions and 125 deletions
@@ -12,16 +12,18 @@ 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.
* Fires exactly two alerts per metric — one at 90% and one at 100% — and no more.
*
* Uses hysteresis, not the API's reset timestamp: each level fires once when usage first
* crosses it, and only re-arms after usage drops back below that level (i.e. a new window /
* usage reset). That keeps it quiet even if the reset time drifts between fetches. Only the
* background worker calls this — never the in-app refresh loop — so you're not pinged while
* you're already looking at the app.
*/
object Notifier {
private const val CHANNEL_ID = "usage_alerts"
private const val SESSION_NOTIF_ID = 2001
private const val WEEKLY_NOTIF_ID = 2002
private val LEVELS = intArrayOf(90, 100)
fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) {
if (!prefs.isNotifyEnabled()) return
@@ -29,53 +31,50 @@ object Notifier {
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."
)
}
evaluate(context, mgr, prefs, "session", data.fiveHourUtilization, "5-hour session")
evaluate(context, mgr, prefs, "weekly", data.weeklyUtilization, "weekly")
}
private fun maybeFire(
private fun evaluate(
context: Context,
mgr: NotificationManagerCompat,
prefs: PreferencesManager,
key: String,
util: Int,
threshold: Int,
resetEpoch: Long,
notifId: Int,
title: String,
body: String
metric: String,
utilization: Float,
label: 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
if (utilization < 0f) return
val util = utilization.toInt()
for ((i, level) in LEVELS.withIndex()) {
val key = "${metric}_$level"
if (util >= level) {
if (!prefs.wasNotified(key)) {
fire(context, mgr, notifId(metric, i), level, label, util)
prefs.setNotified(key, true)
}
} else if (prefs.wasNotified(key)) {
prefs.setNotified(key, false) // dropped below the line → re-arm for next window
}
}
}
private fun fire(
context: Context,
mgr: NotificationManagerCompat,
notifId: Int,
level: Int,
label: String,
util: Int
) {
val title: String
val body: String
if (level >= 100) {
title = "${label.replaceFirstChar { it.uppercase() }} limit reached"
body = "You've hit 100% of your $label limit."
} else {
title = "${label.replaceFirstChar { it.uppercase() }} at $util%"
body = "You're at $level% of your $label limit."
}
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_claude_burst)
.setContentTitle(title)
@@ -84,15 +83,17 @@ object Notifier {
.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.
}
}
// Stable id per (metric, level) so re-posts replace rather than stack.
private fun notifId(metric: String, levelIndex: Int) =
2000 + (if (metric == "weekly") 10 else 0) + levelIndex
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -100,7 +101,7 @@ object Notifier {
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID, "Usage alerts", NotificationManager.IMPORTANCE_DEFAULT
).apply { description = "Alerts when you approach your Claude usage limits" }
).apply { description = "Alerts at 90% and 100% of your Claude usage limits" }
)
}