13 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
amir 4470a6f7ba Show weekday + date on weekly reset labels
Build APK / build (push) Successful in 2m7s
Weekly reset now reads "Resets Friday, Jun 6 · 3:00 PM" in the app and
full widget; small widget uses a compact "Fri, Jun 6" via a new
formatResetShort(). (Amir's local UI polish, folded in on top of v1.14.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:27:24 +00:00
amir 07f26e4487 ci: drop non-functional cache steps (act_runner cache server unreachable)
Build APK / build (push) Successful in 1m31s
The internal cache server times out (reserveCache/getCacheEntry Request
timeout), so caching never hit and the Post Cache step hung ~2 min
tarring the SDK for nothing. Install the SDK fresh each run instead —
slower but reliable and no post-step hang.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:31:13 +00:00
amir 41a3cea2dc ci: fix SDK license accept (yes| SIGPIPE under pipefail → exit 141)
Build APK / build (push) Successful in 7m13s
Use process substitution (< <(yes)) instead of a pipe so yes getting
SIGPIPE when sdkmanager stops reading isn't propagated by pipefail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:23:24 +00:00
amir 55676d998f ci: Gitea Actions workflow to build APK (debug on push, signed on tag)
Build APK / build (push) Failing after 2m27s
Adds .gitea/workflows/build.yml. On push/PR to master it builds a debug
APK as a smoke test (no secrets). On a v* tag it decodes the signing
keystore from the KEYSTORE_BASE64 secret, builds a signed release APK,
and attaches it to the Gitea release for that tag via the API.

Runs on a self-hosted act_runner (label ubuntu-latest →
catthehacker/ubuntu:act-22.04); Android SDK 34 + build-tools 34.0.0 are
installed and cached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:18:13 +00:00
amir 1b5c764ee8 Fix widget/app showing stale or no data; add live in-app refresh
Three reliability bugs made data inconsistent:

1. Empty-overwrite: a failed or partial fetch returned an empty
   UsageData that the worker/app saved unconditionally, wiping the last
   good reading and blanking the widget. Added UsageData.mergedWith()
   so a fetch that returns nothing usable keeps the previous snapshot,
   and a partial fetch falls back per-metric. Never blank again.

2. No in-app auto-refresh: onResume only refreshed when the cache was
   >5 min old and there was no live timer. Replaced with a foreground
   lifecycle loop that refreshes on open and every 30s while visible,
   always painting cached data first. Manual button keeps the spinner;
   the loop is silent. App refresh now also pushes the widget update.

3. Spurious logout: a single transient 401/403 (e.g. a Cloudflare
   challenge) called clearSession() immediately, logging the user out
   and showing "Not signed in". Now clears only after 3 consecutive
   auth failures; the counter resets on any successful read.

Battery-friendly: no foreground service. Background widget refresh
stays on the existing alarm + 15-min WorkManager, but with the merge
fix the widget always shows the last data it pulled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:00:07 +00:00
amir 0520f0dc5e v1.14: usage history chart + threshold notifications
Add an in-app 7-day history chart and opt-in usage alerts, the two
features requested from the macOS Claude-Usage-Tracker that map cleanly
to an Android widget app.

History:
- UsageSnapshot model; PreferencesManager records session/weekly
  utilization on every refresh (7-day retention, <=600 points, collapses
  readings under 2 min apart to avoid worker+manual double-logging).
- HistoryChartView: dependency-free Canvas line chart (session/weekly,
  0/50/100% gridlines), breaks the line across >35-min gaps.
- New HISTORY card with chart + legend.

Notifications:
- Notifier posts when session/weekly crosses a user threshold, at most
  once per limit window (keyed on reset-epoch, re-arms on rollover).
- USAGE ALERTS card: enable switch + session/weekly sliders (50-100%,
  defaults 90/85). POST_NOTIFICATIONS permission + runtime request.
- Wired into the existing 5-min background worker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:49:47 +00:00
amir ae0f466f50 releases/latest: add v1.13 source zip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:53:50 +00:00
amir 1d89b2631c v1.13: drop session marker, single-color weekly marker, weekday reset
- Remove the pace marker from the 5-hour (session) bar entirely.
- Weekly bar marker is now a single color (white), no green→purple tiers.
- Marker is a clean rounded tick; removed the white-halo/tier styling.
- Remove the '% over/under pace' text everywhere (widget + app).
- Weekly reset label now shows the weekday ('Resets Friday 3:00 PM'),
  never 'tomorrow'.

versionCode 14 / versionName 1.13. Includes rebuilt signed release APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:53:50 +00:00
amir b15dcf16d7 releases/latest: add v1.12 source zip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:42:45 +00:00
amir d6d7daa30f v1.12: restore reset-time labels, bolder pace tick
Fix v1.11 regression where the pace tag overwrote the reset-time label
(gone entirely on the small widget). The widget reset lines now show the
actual reset time again; pace is conveyed by the bar tick.

Make the pace tick more prominent: wider core + white halo so it stands
out against any fill color.

versionCode 13 / versionName 1.12. Includes rebuilt signed release APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:42:45 +00:00
47 changed files with 744 additions and 179 deletions
+103
View File
@@ -0,0 +1,103 @@
name: Build APK
# Push to master / open a PR → builds a DEBUG apk (smoke test, no secrets needed).
# Push a tag like v1.14 → builds a SIGNED RELEASE apk and attaches it to the
# Gitea release for that tag.
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Install Android SDK
run: |
set -e
SDK="$GITHUB_WORKSPACE/android-sdk"
mkdir -p "$SDK/cmdline-tools"
curl -sSL -o /tmp/cmdtools.zip \
https://dl.google.com/android/repository/commandlinetools-linux-9862592_latest.zip
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
# Feed "y" via process substitution, not a pipe: `yes |` triggers SIGPIPE (exit 141)
# once sdkmanager stops reading, and the step shell runs with `-eo pipefail`.
"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" --licenses >/dev/null < <(yes)
"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" \
"platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null
- name: Point Gradle at the SDK
run: echo "sdk.dir=$GITHUB_WORKSPACE/android-sdk" > local.properties
# ── Debug build: every push/PR that is NOT a tag ───────────────────────
- name: Build debug APK
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
run: ./gradlew :app:assembleDebug --no-daemon
# ── Release build: tags only (needs the KEYSTORE_BASE64 secret) ────────
- name: Decode signing keystore
if: startsWith(github.ref, 'refs/tags/')
run: |
if [ -z "${{ secrets.KEYSTORE_BASE64 }}" ]; then
echo "::error::KEYSTORE_BASE64 secret is not set — cannot build a signed release."
exit 1
fi
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore
- name: Build release APK
if: startsWith(github.ref, 'refs/tags/')
run: ./gradlew :app:assembleRelease --no-daemon
# ── Stage whichever APK was produced ───────────────────────────────────
- name: Stage APK
run: |
mkdir -p out
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
cp app/build/outputs/apk/release/app-release.apk out/claude-usage-widget.apk
else
cp app/build/outputs/apk/debug/app-debug.apk out/claude-usage-widget-debug.apk
fi
ls -la out/
- name: Upload APK artifact
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: apk
path: out/*.apk
# ── Attach the signed APK to the Gitea release on tag ──────────────────
- name: Publish APK to Gitea release
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
TAG="${GITHUB_REF#refs/tags/}"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
# Create the release if it doesn't exist yet (ignore "already exists").
curl -s -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated build of $TAG\"}" >/dev/null || true
RID=$(curl -s "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
# Replace any existing asset of the same name, then upload.
curl -s "$API/releases/$RID/assets" -H "Authorization: token $TOKEN" \
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='claude-usage-widget.apk']" \
| while read AID; do curl -s -X DELETE "$API/releases/$RID/assets/$AID" -H "Authorization: token $TOKEN"; done
curl -s -X POST "$API/releases/$RID/assets?name=claude-usage-widget.apk" \
-H "Authorization: token $TOKEN" \
-F "attachment=@out/claude-usage-widget.apk" >/dev/null
echo "Attached claude-usage-widget.apk to release $TAG"
+4
View File
@@ -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. 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 - **Peak-hours indicator** — a Claude burst icon that lights up 🔥 during Anthropic's peak window
(511 AM Pacific, MonFri), when tokens burn faster, with a countdown to the window close. (511 AM Pacific, MonFri), 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, 50100%). 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 - Tap the widget to open the app; tap ⟳ to force-refresh
- Responsive: works as 4×1 (compact) or 4×2 (full) - Responsive: works as 4×1 (compact) or 4×2 (full)
- Auto-refreshes every 5 minutes in the background - Auto-refreshes every 5 minutes in the background
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "me.khodak.claudeusage" applicationId = "me.khodak.claudeusage"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 12 versionCode = 18
versionName = "1.11" versionName = "1.17"
} }
signingConfigs { signingConfigs {
+1
View File
@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="false" android:allowBackup="false"
@@ -19,7 +19,7 @@ object BarRenderer {
usedPct: Int, usedPct: Int,
markerPct: Int?, markerPct: Int?,
fillColor: Int, fillColor: Int,
tierColor: Int?, markerColor: Int?,
wPx: Int = 500, wPx: Int = 500,
hPx: Int = 14, hPx: Int = 14,
cornerPx: Float = 7f cornerPx: Float = 7f
@@ -42,14 +42,16 @@ object BarRenderer {
canvas.drawRoundRect(fill, cornerPx, cornerPx, paint) canvas.drawRoundRect(fill, cornerPx, cornerPx, paint)
} }
// Pace tick — "where you should be right now" // Pace marker — a single clean tick showing "where you should be right now".
if (markerPct != null && tierColor != null) { // One color (no tiers); rounded ends to match the bar.
if (markerPct != null && markerColor != null) {
val m = markerPct.coerceIn(0, 100) val m = markerPct.coerceIn(0, 100)
val tickW = (wPx * 0.012f).coerceIn(3f, 7f) val tickW = (wPx * 0.016f).coerceIn(6f, 10f)
var x = wPx * m / 100f var x = wPx * m / 100f
x = x.coerceIn(tickW / 2f, wPx - tickW / 2f) x = x.coerceIn(tickW / 2f, wPx - tickW / 2f)
paint.color = tierColor paint.color = markerColor
canvas.drawRect(x - tickW / 2f, 0f, x + tickW / 2f, hPx.toFloat(), paint) val tick = RectF(x - tickW / 2f, 0f, x + tickW / 2f, hPx.toFloat())
canvas.drawRoundRect(tick, tickW / 2f, tickW / 2f, paint)
} }
return bmp return bmp
@@ -42,6 +42,15 @@ class ClaudeUsageWidget : AppWidgetProvider() {
@Volatile internal var isRefreshing = false @Volatile internal var isRefreshing = false
@Volatile internal var currentRotation = 0f @Volatile internal var currentRotation = 0f
/** Redraw every placed widget from the current cached data (call after a refresh). */
fun notifyDataChanged(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
android.content.ComponentName(context, ClaudeUsageWidget::class.java)
)
ids.forEach { updateWidget(context, manager, it) }
}
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) { fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
val prefs = PreferencesManager(context) val prefs = PreferencesManager(context)
val apiData = prefs.getUsageData() val apiData = prefs.getUsageData()
@@ -104,11 +113,9 @@ class ClaudeUsageWidget : AppWidgetProvider() {
when { when {
hasUtilization -> { hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt() val pct = apiData!!.fiveHourUtilization.toInt()
val pace = PaceCalc.compute(apiData.fiveHourUtilization, apiData.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
v.setTextViewText(R.id.tv_session_value, "$pct%") v.setTextViewText(R.id.tv_session_value, "$pct%")
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, pace?.markerPct, SESSION_FILL, pace?.tierColor)) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
if (pace != null) PaceCalc.shortTag(apiData.fiveHourUtilization, pace) else formatReset(apiData.utilizationResetAtEpoch))
} }
hasApiMessages -> { hasApiMessages -> {
val rem = apiData!!.effectiveRemaining val rem = apiData!!.effectiveRemaining
@@ -136,9 +143,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
val wPct = apiData!!.weeklyUtilization.toInt() val wPct = apiData!!.weeklyUtilization.toInt()
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS) val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
v.setTextViewText(R.id.tv_weekly_value, "$wPct%") v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, pace?.tierColor)) v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null))
v.setTextViewText(R.id.tv_weekly_label, v.setTextViewText(R.id.tv_weekly_label, formatResetShort(apiData.weeklyResetAtEpoch))
if (pace != null) PaceCalc.shortTag(apiData.weeklyUtilization, pace) else formatReset(apiData.weeklyResetAtEpoch))
} else { } else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask()) val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d") v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
@@ -184,10 +190,9 @@ class ClaudeUsageWidget : AppWidgetProvider() {
when { when {
hasUtilization -> { hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt() val pct = apiData!!.fiveHourUtilization.toInt()
val pace = PaceCalc.compute(apiData.fiveHourUtilization, apiData.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
v.setTextViewText(R.id.tv_session_value, "$pct%") v.setTextViewText(R.id.tv_session_value, "$pct%")
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, pace?.markerPct, SESSION_FILL, pace?.tierColor)) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, resetWithPace(apiData.utilizationResetAtEpoch, apiData.fiveHourUtilization, pace)) v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
} }
hasApiMessages -> { hasApiMessages -> {
val rem = apiData!!.effectiveRemaining val rem = apiData!!.effectiveRemaining
@@ -218,8 +223,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
val wPct = apiData!!.weeklyUtilization.toInt() val wPct = apiData!!.weeklyUtilization.toInt()
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS) val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
v.setTextViewText(R.id.tv_weekly_value, "$wPct%") v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, pace?.tierColor)) v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null))
v.setTextViewText(R.id.tv_weekly_label, resetWithPace(apiData.weeklyResetAtEpoch, apiData.weeklyUtilization, pace)) v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch))
} else { } else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask()) val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d") v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
@@ -249,6 +254,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
private const val SESSION_FILL = 0xFFCC785C.toInt() private const val SESSION_FILL = 0xFFCC785C.toInt()
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt() private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color pace marker (weekly only)
/** Tints the header burst icon and (optionally) the PEAK text by current peak state. */ /** Tints the header burst icon and (optionally) the PEAK text by current peak state. */
private fun applyPeak(v: RemoteViews, showText: Boolean) { private fun applyPeak(v: RemoteViews, showText: Boolean) {
@@ -260,14 +266,6 @@ class ClaudeUsageWidget : AppWidgetProvider() {
} }
} }
/** "Resets at 3:00 PM · 8% under pace" — reset line with the pace tag appended. */
private fun resetWithPace(resetEpoch: Long, usedPct: Float, pace: PaceCalc.Pace?): CharSequence {
val reset = formatReset(resetEpoch)
if (pace == null) return reset
val tag = PaceCalc.shortTag(usedPct, pace)
return if (reset.isBlank()) tag else "$reset · $tag"
}
private fun formatReset(epochMs: Long): String { private fun formatReset(epochMs: Long): String {
if (epochMs <= 0) return "" if (epochMs <= 0) return ""
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -281,6 +279,23 @@ class ClaudeUsageWidget : AppWidgetProvider() {
} }
} }
/** Weekly reset with weekday + date ("Resets Friday, Jun 6 · 3:00 PM"), never "tomorrow". */
private fun formatResetDay(epochMs: Long): String {
if (epochMs <= 0) return ""
if (epochMs <= System.currentTimeMillis()) return "Resets soon"
val day = SimpleDateFormat("EEEE", Locale.US).format(Date(epochMs))
val date = SimpleDateFormat("MMM d", Locale.US).format(Date(epochMs))
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
return "Resets $day, $date · $timeStr"
}
/** Compact weekly reset for the space-tight small widget: "Fri, Jun 6". */
private fun formatResetShort(epochMs: Long): String {
if (epochMs <= 0) return ""
if (epochMs <= System.currentTimeMillis()) return "soon"
return SimpleDateFormat("EEE, MMM d", Locale.US).format(Date(epochMs))
}
private fun formatTime(ms: Long) = private fun formatTime(ms: Long) =
SimpleDateFormat("h:mm a", Locale.US).format(Date(ms)) SimpleDateFormat("h:mm a", Locale.US).format(Date(ms))
} }
@@ -0,0 +1,131 @@
package me.khodak.claudeusage
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
import me.khodak.claudeusage.data.UsageSnapshot
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Lightweight line chart for usage history, hand-drawn on Canvas to stay dependency-free
* and consistent with [BarRenderer]. Plots session (orange) and weekly (blue) utilization
* 0-100% over time. Gaps longer than [GAP_MS] are not connected, so an offline stretch
* shows as a break rather than a misleading straight line.
*/
class HistoryChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : View(context, attrs, defStyle) {
private var points: List<UsageSnapshot> = 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<UsageSnapshot>) {
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<UsageSnapshot>,
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
}
}
@@ -1,10 +1,18 @@
package me.khodak.claudeusage package me.khodak.claudeusage
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData import me.khodak.claudeusage.data.UsageData
@@ -19,6 +27,12 @@ class MainActivity : AppCompatActivity() {
private lateinit var prefs: PreferencesManager private lateinit var prefs: PreferencesManager
private lateinit var repo: UsageRepository private lateinit var repo: UsageRepository
private val notifPermLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
/** Live refresh loop that runs only while the app is in the foreground. */
private var autoRefreshJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
@@ -51,6 +65,8 @@ class MainActivity : AppCompatActivity() {
}) })
} }
setupNotificationSettings()
binding.btnDebug.setOnClickListener { binding.btnDebug.setOnClickListener {
if (binding.tvDebugInfo.visibility == android.view.View.GONE) { if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" } binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
@@ -65,32 +81,74 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val cached = prefs.getUsageData() updateUI(prefs.getUsageData()) // show cached instantly — never a blank screen
updateUI(cached) if (prefs.isLoggedIn()) startAutoRefresh()
if (prefs.isLoggedIn()) { }
val staleMs = 5 * 60 * 1000L
if ((cached?.lastUpdated ?: 0L) < System.currentTimeMillis() - staleMs) { override fun onPause() {
refreshUsage() super.onPause()
autoRefreshJob?.cancel()
}
/** Refresh immediately on open, then every [REFRESH_INTERVAL_MS] while foregrounded. */
private fun startAutoRefresh() {
autoRefreshJob?.cancel()
autoRefreshJob = lifecycleScope.launch {
while (isActive) {
doRefresh(silent = true)
delay(REFRESH_INTERVAL_MS)
} }
} }
} }
private fun setupNotificationSettings() {
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
binding.switchNotify.setOnCheckedChangeListener { _, checked ->
prefs.setNotifyEnabled(checked)
if (checked) requestNotificationPermission()
}
// 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 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)
}
}
/** Manual "Refresh Now" button — shows the spinner. */
private fun refreshUsage() { private fun refreshUsage() {
binding.btnRefresh.isEnabled = false lifecycleScope.launch { doRefresh(silent = false) }
binding.progressIndicator.visibility = View.VISIBLE }
lifecycleScope.launch {
val data = try { private suspend fun doRefresh(silent: Boolean) {
repo.fetchUsage() if (!silent) {
} catch (e: Exception) { binding.btnRefresh.isEnabled = false
if (e is kotlinx.coroutines.CancellationException) throw e binding.progressIndicator.visibility = View.VISIBLE
prefs.getUsageData()?.copy(errorMessage = "Network error") }
?: UsageData(errorMessage = "Network error") val fresh = try {
} repo.fetchUsage()
prefs.saveUsageData(data) } catch (e: Exception) {
updateUI(data) if (e is kotlinx.coroutines.CancellationException) throw e
if (binding.tvDebugInfo.visibility == View.VISIBLE) { UsageData(errorMessage = "Network error")
binding.tvDebugInfo.text = repo.lastDebugInfo }
} // Preserve last-good data so a failed/partial fetch never blanks the UI or widget.
val merged = fresh.mergedWith(prefs.getUsageData())
prefs.saveUsageData(merged)
prefs.recordHistory(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) {
binding.tvDebugInfo.text = repo.lastDebugInfo
}
if (!silent) {
binding.btnRefresh.isEnabled = true binding.btnRefresh.isEnabled = true
binding.progressIndicator.visibility = View.GONE binding.progressIndicator.visibility = View.GONE
} }
@@ -113,31 +171,28 @@ class MainActivity : AppCompatActivity() {
else else
"Off-peak · ${peak.windowLabel}" "Off-peak · ${peak.windowLabel}"
// ── Session (5-hour) bar + pace ────────────────────────────────────── // ── Session (5-hour) bar — no pace marker ────────────────────────────
val sessionPct = data.progressPercent
val sessionPace = if (data.fiveHourUtilization >= 0f)
PaceCalc.compute(data.fiveHourUtilization, data.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
else null
binding.barSession.setImageBitmap( binding.barSession.setImageBitmap(
BarRenderer.render(sessionPct, sessionPace?.markerPct, SESSION_FILL, sessionPace?.tierColor) BarRenderer.render(data.progressPercent, null, SESSION_FILL, null)
) )
binding.tvSessionPace.text = paceSentence(data.fiveHourUtilization, sessionPace)
// ── Weekly (7-day) bar + pace ──────────────────────────────────────── // ── Weekly (7-day) bar — single-color pace marker ────────────────────
if (data.weeklyUtilization >= 0f) { if (data.weeklyUtilization >= 0f) {
val wPct = data.weeklyUtilization.toInt() val wPct = data.weeklyUtilization.toInt()
val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS) val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
binding.barWeekly.setImageBitmap( binding.barWeekly.setImageBitmap(
BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, weeklyPace?.tierColor) BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, if (weeklyPace != null) MARKER_COLOR else null)
) )
binding.tvWeeklyUsage.text = "$wPct% this week" binding.tvWeeklyUsage.text = "$wPct% this week"
binding.tvWeeklyPace.text = paceSentence(data.weeklyUtilization, weeklyPace)
} else { } else {
binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null)) binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null))
binding.tvWeeklyUsage.text = "" binding.tvWeeklyUsage.text = ""
binding.tvWeeklyPace.text = ""
} }
// Pace text removed per design — bars carry the signal.
binding.tvSessionPace.visibility = View.GONE
binding.tvWeeklyPace.visibility = View.GONE
binding.tvUsage.text = when { binding.tvUsage.text = when {
data.fiveHourUtilization >= 0f -> { data.fiveHourUtilization >= 0f -> {
val pct = data.fiveHourUtilization.toInt() val pct = data.fiveHourUtilization.toInt()
@@ -153,19 +208,15 @@ class MainActivity : AppCompatActivity() {
} }
binding.tvReset.text = formatReset(data.effectiveResetEpoch) binding.tvReset.text = formatReset(data.effectiveResetEpoch)
binding.tvWeeklyReset.text = formatReset(data.weeklyResetAtEpoch) binding.tvWeeklyReset.text = formatResetDay(data.weeklyResetAtEpoch)
binding.tvUpdated.text = if (data.lastUpdated > 0) binding.tvUpdated.text = if (data.lastUpdated > 0)
"Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}" "Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}"
else "" else ""
binding.tvError.text = data.errorMessage binding.tvError.text = data.errorMessage
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
}
/** "20% over pace · will likely hit limit" — empty when no projection is available yet. */ binding.historyChart.setData(prefs.getHistory())
private fun paceSentence(usedPct: Float, pace: PaceCalc.Pace?): String {
if (pace == null) return ""
return "${PaceCalc.shortTag(usedPct, pace)} · ${pace.label}"
} }
private fun formatReset(epochMs: Long): String { private fun formatReset(epochMs: Long): String {
@@ -181,9 +232,21 @@ class MainActivity : AppCompatActivity() {
} }
} }
/** Weekly reset shown with the weekday name ("Resets Friday 3:00 PM"), never "tomorrow". */
private fun formatResetDay(epochMs: Long): String {
if (epochMs <= 0) return ""
if (epochMs <= System.currentTimeMillis()) return "Resets soon"
val day = SimpleDateFormat("EEEE", Locale.US).format(Date(epochMs))
val date = SimpleDateFormat("MMM d", Locale.US).format(Date(epochMs))
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
return "Resets $day, $date · $timeStr"
}
companion object { companion object {
private const val REFRESH_INTERVAL_MS = 30_000L // live refresh cadence while app is open
private const val SESSION_FILL = 0xFFCC785C.toInt() private const val SESSION_FILL = 0xFFCC785C.toInt()
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt() private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color weekly pace marker
private const val PEAK_ON = 0xFFCC785C.toInt() private const val PEAK_ON = 0xFFCC785C.toInt()
private const val PEAK_OFF = 0xFF666666.toInt() private const val PEAK_OFF = 0xFF666666.toInt()
} }
@@ -0,0 +1,116 @@
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
/**
* 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 val LEVELS = intArrayOf(90, 100)
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)
evaluate(context, mgr, prefs, "session", data.fiveHourUtilization, "5-hour session")
evaluate(context, mgr, prefs, "weekly", data.weeklyUtilization, "weekly")
}
private fun evaluate(
context: Context,
mgr: NotificationManagerCompat,
prefs: PreferencesManager,
metric: String,
utilization: Float,
label: String
) {
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)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(openAppIntent(context))
.build()
try {
mgr.notify(notifId, notif)
} 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
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID, "Usage alerts", NotificationManager.IMPORTANCE_DEFAULT
).apply { description = "Alerts at 90% and 100% of 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
)
}
}
@@ -62,14 +62,4 @@ object PaceCalc {
} }
return Pace(markerPct, projected, color, label) return Pace(markerPct, projected, color, label)
} }
/** Short tag for the widget reset line, e.g. "8% under pace" / "20% over pace" / "on pace". */
fun shortTag(usedPct: Float, pace: Pace): String {
val delta = usedPct.toInt() - pace.markerPct
return when {
delta >= 2 -> "$delta% over pace"
delta <= -2 -> "${-delta}% under pace"
else -> "on pace"
}
}
} }
@@ -55,6 +55,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai") if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
if (orgUsageData?.hasRateLimitData == true) { if (orgUsageData?.hasRateLimitData == true) {
prefs.resetAuthFailCount()
return@withContext base.copy( return@withContext base.copy(
messagesUsed = orgUsageData.messagesUsed, messagesUsed = orgUsageData.messagesUsed,
messagesLimit = orgUsageData.messagesLimit, messagesLimit = orgUsageData.messagesLimit,
@@ -77,6 +78,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n") if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n")
val utilData = tryParseUtilizationBody(body) val utilData = tryParseUtilizationBody(body)
if (utilData != null) { if (utilData != null) {
prefs.resetAuthFailCount()
return@withContext base.copy( return@withContext base.copy(
fiveHourUtilization = utilData.fiveHourUtilization, fiveHourUtilization = utilData.fiveHourUtilization,
weeklyUtilization = utilData.weeklyUtilization, weeklyUtilization = utilData.weeklyUtilization,
@@ -102,8 +104,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code") if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code")
if (code == 401 || code == 403) { if (code == 401 || code == 403) {
prefs.clearSession() if (prefs.incAuthFailCount() >= AUTH_FAIL_LIMIT) {
return@withContext UsageData(errorMessage = "Session expired — please sign in again") prefs.clearSession()
prefs.resetAuthFailCount()
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
}
// Transient auth failure — keep showing last-good data instead of logging out.
return@withContext base
} }
val rateLimitData = extractRateLimitHeaders(resp.headers) val rateLimitData = extractRateLimitHeaders(resp.headers)
@@ -341,5 +348,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
companion object { companion object {
private const val TAG = "UsageRepo" private const val TAG = "UsageRepo"
// Clear the session only after this many consecutive 401/403s, so one transient
// auth failure (Cloudflare challenge, brief edge hiccup) doesn't sign the user out.
private const val AUTH_FAIL_LIMIT = 3
} }
} }
@@ -31,7 +31,10 @@ class UsageUpdateWorker(
val animJob = launch { rotateRefreshIcon() } val animJob = launch { rotateRefreshIcon() }
try { try {
val data = UsageRepository(prefs).fetchUsage() val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data) // Preserve last-good data so a failed/partial fetch never blanks the widget.
prefs.saveUsageData(data.mergedWith(prefs.getUsageData()))
prefs.recordHistory(data) // history records only fresh readings
Notifier.checkAndNotify(context, prefs, data)
} catch (_: Exception) {} } catch (_: Exception) {}
animJob.cancel() animJob.cancel()
animJob.join() animJob.join()
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.Calendar import java.util.Calendar
class PreferencesManager(context: Context) { class PreferencesManager(context: Context) {
@@ -73,6 +74,67 @@ class PreferencesManager(context: Context) {
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank() fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
// Consecutive 401/403 counter — we only clear the session after several in a row, so a
// single transient auth failure (e.g. a Cloudflare challenge) doesn't log the user out.
fun getAuthFailCount(): Int = prefs.getInt(KEY_AUTH_FAILS, 0)
fun incAuthFailCount(): Int {
val n = getAuthFailCount() + 1
prefs.edit().putInt(KEY_AUTH_FAILS, n).apply()
return n
}
fun resetAuthFailCount() {
if (getAuthFailCount() != 0) prefs.edit().putInt(KEY_AUTH_FAILS, 0).apply()
}
// ── 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<UsageSnapshot> {
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<UsageSnapshot>>() {}.type
gson.fromJson<List<UsageSnapshot>>(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()
/**
* 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 wasNotified(key: String): Boolean = prefs.getBoolean("notified_$key", false)
fun setNotified(key: String, v: Boolean) =
prefs.edit().putBoolean("notified_$key", v).apply()
companion object { companion object {
private const val KEY_COOKIES = "session_cookies" private const val KEY_COOKIES = "session_cookies"
private const val KEY_ORG_ID = "org_id" private const val KEY_ORG_ID = "org_id"
@@ -80,6 +142,13 @@ class PreferencesManager(context: Context) {
private const val KEY_USAGE_DATA = "usage_data" private const val KEY_USAGE_DATA = "usage_data"
private const val KEY_ACTIVE_WEEK = "active_week" private const val KEY_ACTIVE_WEEK = "active_week"
private const val KEY_ACTIVE_MASK = "active_mask" 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_AUTH_FAILS = "auth_fail_count"
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 { fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
return try { return try {
@@ -49,4 +49,36 @@ data class UsageData(
resetAtEpoch > 0 -> resetAtEpoch resetAtEpoch > 0 -> resetAtEpoch
else -> -1L else -> -1L
} }
/** True if this fetch produced any usable usage reading at all. */
val hasAnyReading: Boolean get() =
fiveHourUtilization >= 0f || weeklyUtilization >= 0f || hasRateLimitData
/**
* Merge a fresh fetch over the last cached reading so a failed or partial refresh never
* blanks the widget. If this fetch got nothing usable, the whole previous snapshot is kept
* (with its original timestamp, so the footer shows the data's true age). Otherwise each
* metric this fetch didn't return falls back to the previous value.
*/
fun mergedWith(previous: UsageData?): UsageData {
if (previous == null || !previous.hasAnyReading) return this
if (!hasAnyReading) {
// Keep last-good data; only carry this attempt's login/session context forward.
return previous.copy(
isLoggedIn = isLoggedIn,
sessionStartEpoch = if (sessionStartEpoch > 0) sessionStartEpoch else previous.sessionStartEpoch,
weeklyActiveDaysMask = if (weeklyActiveDaysMask != 0) weeklyActiveDaysMask else previous.weeklyActiveDaysMask
)
}
return copy(
fiveHourUtilization = if (fiveHourUtilization >= 0f) fiveHourUtilization else previous.fiveHourUtilization,
utilizationResetAtEpoch = if (fiveHourUtilization >= 0f) utilizationResetAtEpoch else previous.utilizationResetAtEpoch,
weeklyUtilization = if (weeklyUtilization >= 0f) weeklyUtilization else previous.weeklyUtilization,
weeklyResetAtEpoch = if (weeklyUtilization >= 0f) weeklyResetAtEpoch else previous.weeklyResetAtEpoch,
messagesLimit = if (messagesLimit > 0) messagesLimit else previous.messagesLimit,
messagesUsed = if (messagesUsed >= 0) messagesUsed else previous.messagesUsed,
messagesRemaining = if (messagesRemaining >= 0) messagesRemaining else previous.messagesRemaining,
resetAtEpoch = if (resetAtEpoch > 0) resetAtEpoch else previous.resetAtEpoch
)
}
} }
@@ -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
)
+104
View File
@@ -229,6 +229,110 @@
</LinearLayout> </LinearLayout>
<!-- History card -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/widget_background"
android:padding="20dp"
android:layout_marginTop="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HISTORY"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<me.khodak.claudeusage.HistoryChartView
android:id="@+id/historyChart"
android:layout_width="match_parent"
android:layout_height="140dp"
android:layout_marginTop="12dp" />
<!-- Legend -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="10dp"
android:gravity="center_vertical">
<View
android:layout_width="12dp"
android:layout_height="3dp"
android:background="#CC785C" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="16dp"
android:text="Session"
android:textColor="#AAAAAA"
android:textSize="12sp" />
<View
android:layout_width="12dp"
android:layout_height="3dp"
android:background="#7B8FCC" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="Weekly"
android:textColor="#AAAAAA"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<!-- Notifications card -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/widget_background"
android:padding="20dp"
android:layout_marginTop="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="USAGE ALERTS"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchNotify"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
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>
<Button <Button
android:id="@+id/btnRefresh" android:id="@+id/btnRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -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="surface_dark">#1E1E1E</color>
<color name="text_primary">#FFFFFF</color> <color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#888888</color> <color name="text_secondary">#888888</color>
<color name="ic_launcher_bg">#16222B</color>
</resources> </resources>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.
Binary file not shown.