From a43fa5be9270044a503ad02178f8d05e573df623 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Thu, 4 Jun 2026 15:43:14 +0000 Subject: [PATCH] v1.16: simplify usage alerts to fixed 90% and 100% (less aggressive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 4 +- .../me/khodak/claudeusage/MainActivity.kt | 31 +----- .../java/me/khodak/claudeusage/Notifier.kt | 99 ++++++++++--------- .../claudeusage/data/PreferencesManager.kt | 18 +--- app/src/main/res/layout/activity_main.xml | 37 +------ 5 files changed, 64 insertions(+), 125 deletions(-) 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" />