3 Commits

Author SHA1 Message Date
amir 5a5f6ed1e4 v1.17: updated tank launcher icon (close-up, navy #16222B)
Build APK / build (push) Successful in 1m55s
Swap in the closer-up tank art. Source padded to square (no distortion),
adaptive foreground inset ~10% so edges don't clip on round masks, bg
color updated to #16222B. Regenerated all densities + repo icon.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:31:02 +00:00
amir a43fa5be92 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>
2026-06-04 15:43:14 +00:00
amir 1d4356c1d7 v1.15: new pixel-art tank launcher icon
Build APK / build (push) Successful in 1m29s
Replace the hexagon "C" launcher icon with the tank-crushing-electronics
art. Adaptive icon (anydpi-v26): full art as foreground over teal
#284950 background, so it masks cleanly to any launcher shape; legacy
PNG bitmaps generated for all densities. Refreshes repo-root icon.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:34:17 +00:00
34 changed files with 75 additions and 225 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "me.khodak.claudeusage"
minSdk = 26
targetSdk = 34
versionCode = 15
versionName = "1.14"
versionCode = 18
versionName = "1.17"
}
signingConfigs {
@@ -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) {
@@ -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" }
)
}
@@ -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
+5 -32
View File
@@ -323,40 +323,13 @@
</LinearLayout>
<TextView
android:id="@+id/tvSessionThreshLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="Session alert at 90%"
android:textColor="#FFFFFF"
android:textSize="14sp" />
<com.google.android.material.slider.Slider
android:id="@+id/sliderSession"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="50"
android:valueTo="100"
android:stepSize="5"
android:value="90" />
<TextView
android:id="@+id/tvWeeklyThreshLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Weekly alert at 85%"
android:textColor="#FFFFFF"
android:textSize="14sp" />
<com.google.android.material.slider.Slider
android:id="@+id/sliderWeekly"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:valueFrom="50"
android:valueTo="100"
android:stepSize="5"
android:value="85" />
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" />
</LinearLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/claude_orange" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_fg"
android:inset="25%" />
</foreground>
</adaptive-icon>
+1
View File
@@ -5,4 +5,5 @@
<color name="surface_dark">#1E1E1E</color>
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#888888</color>
<color name="ic_launcher_bg">#16222B</color>
</resources>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 76 KiB