diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 813cd01..1baa734 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 = 16
- versionName = "1.15"
+ versionCode = 17
+ versionName = "1.16"
}
signingConfigs {
diff --git a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
index 272d3df..912329f 100644
--- a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
+++ b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
@@ -103,43 +103,15 @@ 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)
@@ -169,7 +141,8 @@ class MainActivity : AppCompatActivity() {
val merged = fresh.mergedWith(prefs.getUsageData())
prefs.saveUsageData(merged)
prefs.recordHistory(fresh)
- Notifier.checkAndNotify(this, prefs, fresh)
+ // Note: alerts fire only from the background worker, not here — no point pinging you
+ // with a notification while you're already looking at the app.
updateUI(merged)
ClaudeUsageWidget.notifyDataChanged(this) // opening the app refreshes the widget too
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
diff --git a/app/src/main/java/me/khodak/claudeusage/Notifier.kt b/app/src/main/java/me/khodak/claudeusage/Notifier.kt
index 48d7914..40c3f76 100644
--- a/app/src/main/java/me/khodak/claudeusage/Notifier.kt
+++ b/app/src/main/java/me/khodak/claudeusage/Notifier.kt
@@ -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" }
)
}
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 5f05df9..1b62aa8 100644
--- a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt
+++ b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt
@@ -127,19 +127,13 @@ class PreferencesManager(context: Context) {
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.
+ * Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
+ * cleared when usage drops back below it — so each level fires once per window.
*/
- fun getNotifiedResetEpoch(key: String): Long = prefs.getLong("notified_$key", 0L)
- fun setNotifiedResetEpoch(key: String, epoch: Long) =
- prefs.edit().putLong("notified_$key", epoch).apply()
+ fun wasNotified(key: String): Boolean = prefs.getBoolean("notified_$key", false)
+ fun setNotified(key: String, v: Boolean) =
+ prefs.edit().putBoolean("notified_$key", v).apply()
companion object {
private const val KEY_COOKIES = "session_cookies"
@@ -150,8 +144,6 @@ class PreferencesManager(context: Context) {
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 KEY_AUTH_FAILS = "auth_fail_count"
private const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 7d95cc8..ce99b8a 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -323,40 +323,13 @@
-
-
-
-
-
-
+ android:layout_marginTop="12dp"
+ android:text="One alert at 90% and one at 100%, for both your session and weekly limits. Each fires once until usage resets."
+ android:textColor="#AAAAAA"
+ android:textSize="13sp"
+ android:lineSpacingExtra="3dp" />