From 6ee19c15ba62bd5434d291711559f1ab199f97fc Mon Sep 17 00:00:00 2001 From: Youngbin Kim <64558592+youngbinkim0@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:19:49 -0500 Subject: [PATCH 1/4] Add Sonnet quota tracking core logic - Parse seven_day_sonnet quota from Claude API responses - Persist quota data in SessionManager (sonnetUtilization, sonnetResetsAt) - Add notification layout with Sonnet progress bar - Add Sonnet progress drawable (amber color #FFD97706) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../example/claudecounter/ClaudeApiService.kt | 12 ++++++----- .../example/claudecounter/SessionManager.kt | 20 ++++++++++++++++--- .../claudecounter/UsagePollingService.kt | 12 +++++++++++ .../res/drawable/notif_progress_sonnet.xml | 17 ++++++++++++++++ .../main/res/layout/notification_usage.xml | 12 +++++++++++ 5 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/notif_progress_sonnet.xml diff --git a/app/src/main/java/com/example/claudecounter/ClaudeApiService.kt b/app/src/main/java/com/example/claudecounter/ClaudeApiService.kt index 6644027..fe19492 100644 --- a/app/src/main/java/com/example/claudecounter/ClaudeApiService.kt +++ b/app/src/main/java/com/example/claudecounter/ClaudeApiService.kt @@ -24,7 +24,8 @@ object ClaudeApiService { data class UsageData( val fiveHour: UsageWindow?, - val sevenDay: UsageWindow? + val sevenDay: UsageWindow?, + val sevenDaySonnet: UsageWindow? ) data class UsageResult( @@ -80,14 +81,15 @@ object ClaudeApiService { * Parses the raw JSON from the usage endpoint into [UsageData]. * Handles various possible JSON structures from Claude's API. */ - private fun parseUsageData(json: JSONObject): UsageData { + internal fun parseUsageData(json: JSONObject): UsageData { // API returns "five_hour" and "seven_day" objects val fiveHour = parseWindow(json, "five_hour") val sevenDay = parseWindow(json, "seven_day") - return UsageData(fiveHour = fiveHour, sevenDay = sevenDay) + val sevenDaySonnet = parseWindow(json, "seven_day_sonnet") + return UsageData(fiveHour = fiveHour, sevenDay = sevenDay, sevenDaySonnet = sevenDaySonnet) } - private fun parseWindow(json: JSONObject, key: String): UsageWindow? { + internal fun parseWindow(json: JSONObject, key: String): UsageWindow? { val obj = json.optJSONObject(key) ?: return null // "utilization" might be a direct percentage field val utilization = obj.optDouble("utilization", -1.0).takeIf { it >= 0 } @@ -98,7 +100,7 @@ object ClaudeApiService { if (limit > 0) (used / limit * 100.0) else 0.0 } val resetsAt = obj.optString("resets_at", "") - return if (resetsAt.isNotBlank() || utilization >= 0) { + return if (resetsAt.isNotBlank() || utilization > 0.0) { UsageWindow(utilization = utilization, resetsAt = resetsAt) } else null } diff --git a/app/src/main/java/com/example/claudecounter/SessionManager.kt b/app/src/main/java/com/example/claudecounter/SessionManager.kt index 3c02323..b6b3d94 100644 --- a/app/src/main/java/com/example/claudecounter/SessionManager.kt +++ b/app/src/main/java/com/example/claudecounter/SessionManager.kt @@ -30,6 +30,9 @@ class SessionManager private constructor(context: Context) { // 7-day window val weeklyUtilization: Double = 0.0, val weeklyResetsAt: Long = 0L, + // 7-day sonnet window + val sonnetUtilization: Double = 0.0, + val sonnetResetsAt: Long = 0L, val lastFetchTime: Long = 0L ) { val isLoggedIn: Boolean get() = orgId != null && sessionCookie != null @@ -72,6 +75,8 @@ class SessionManager private constructor(context: Context) { private set var lastWeeklyResetsAt: Long = prefs.getLong(KEY_WEEKLY_RESETS_AT, 0L) private set + var lastSonnetResetsAt: Long = prefs.getLong(KEY_SONNET_RESETS_AT, 0L) + private set fun updateUsage(data: ClaudeApiService.UsageData) { val sessionUtil = data.fiveHour?.utilization ?: _usageState.value.sessionUtilization @@ -80,25 +85,30 @@ class SessionManager private constructor(context: Context) { val weeklyUtil = data.sevenDay?.utilization ?: _usageState.value.weeklyUtilization val weeklyResets = data.sevenDay?.resetsAt?.let { parseIso8601(it) } ?: _usageState.value.weeklyResetsAt + val sonnetUtil = data.sevenDaySonnet?.utilization ?: _usageState.value.sonnetUtilization + val sonnetResets = data.sevenDaySonnet?.resetsAt?.let { parseIso8601(it) } + ?: _usageState.value.sonnetResetsAt val now = System.currentTimeMillis() - // Remember previous for rollover detection lastSessionResetsAt = _usageState.value.sessionResetsAt lastWeeklyResetsAt = _usageState.value.weeklyResetsAt - + lastSonnetResetsAt = _usageState.value.sonnetResetsAt prefs.edit() .putFloat(KEY_SESSION_UTIL, sessionUtil.toFloat()) .putLong(KEY_SESSION_RESETS_AT, sessionResets) .putFloat(KEY_WEEKLY_UTIL, weeklyUtil.toFloat()) .putLong(KEY_WEEKLY_RESETS_AT, weeklyResets) + .putFloat(KEY_SONNET_UTIL, sonnetUtil.toFloat()) + .putLong(KEY_SONNET_RESETS_AT, sonnetResets) .putLong(KEY_LAST_FETCH_TIME, now) .apply() - _usageState.value = _usageState.value.copy( sessionUtilization = sessionUtil, sessionResetsAt = sessionResets, weeklyUtilization = weeklyUtil, weeklyResetsAt = weeklyResets, + sonnetUtilization = sonnetUtil, + sonnetResetsAt = sonnetResets, lastFetchTime = now ) } @@ -112,6 +122,8 @@ class SessionManager private constructor(context: Context) { sessionResetsAt = prefs.getLong(KEY_SESSION_RESETS_AT, 0L), weeklyUtilization = prefs.getFloat(KEY_WEEKLY_UTIL, 0f).toDouble(), weeklyResetsAt = prefs.getLong(KEY_WEEKLY_RESETS_AT, 0L), + sonnetUtilization = prefs.getFloat(KEY_SONNET_UTIL, 0f).toDouble(), + sonnetResetsAt = prefs.getLong(KEY_SONNET_RESETS_AT, 0L), lastFetchTime = prefs.getLong(KEY_LAST_FETCH_TIME, 0L) ) @@ -124,6 +136,8 @@ class SessionManager private constructor(context: Context) { private const val KEY_WEEKLY_UTIL = "weekly_util" private const val KEY_WEEKLY_RESETS_AT = "weekly_resets_at" private const val KEY_LAST_FETCH_TIME = "last_fetch_time" + private const val KEY_SONNET_UTIL = "sonnet_util" + private const val KEY_SONNET_RESETS_AT = "sonnet_resets_at" fun parseIso8601(text: String): Long = try { java.time.OffsetDateTime.parse(text).toInstant().toEpochMilli() diff --git a/app/src/main/java/com/example/claudecounter/UsagePollingService.kt b/app/src/main/java/com/example/claudecounter/UsagePollingService.kt index a308f72..7e1271e 100644 --- a/app/src/main/java/com/example/claudecounter/UsagePollingService.kt +++ b/app/src/main/java/com/example/claudecounter/UsagePollingService.kt @@ -128,6 +128,18 @@ class UsagePollingService : Service() { val weeklyTime = data.sevenDay?.resetsAt?.let { formatCountdown(it) } ?: "" customView.setTextViewText(R.id.weekly_time, weeklyTime) + // Sonnet row + val hasSonnet = data.sevenDaySonnet != null && (data.sevenDaySonnet.resetsAt.isNotBlank() || data.sevenDaySonnet.utilization > 0.0) + if (hasSonnet) { + customView.setViewVisibility(R.id.sonnet_row, android.view.View.VISIBLE) + val sonnetPct = data.sevenDaySonnet!!.utilization + customView.setProgressBar(R.id.sonnet_progress, 1000, (sonnetPct * 10).toInt(), false) + customView.setTextViewText(R.id.sonnet_pct, "${"%.0f".format(sonnetPct)}%") + val sonnetTime = data.sevenDaySonnet.resetsAt.let { formatCountdown(it) } + customView.setTextViewText(R.id.sonnet_time, sonnetTime) + } else { + customView.setViewVisibility(R.id.sonnet_row, android.view.View.GONE) + } builder.setCustomContentView(customView) .setCustomBigContentView(customView) .setStyle(NotificationCompat.DecoratedCustomViewStyle()) diff --git a/app/src/main/res/drawable/notif_progress_sonnet.xml b/app/src/main/res/drawable/notif_progress_sonnet.xml new file mode 100644 index 0000000..575a996 --- /dev/null +++ b/app/src/main/res/drawable/notif_progress_sonnet.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/notification_usage.xml b/app/src/main/res/layout/notification_usage.xml index 4091a99..969a58a 100644 --- a/app/src/main/res/layout/notification_usage.xml +++ b/app/src/main/res/layout/notification_usage.xml @@ -24,4 +24,16 @@ + + + + + + + + + + + + From c69b852c8d9f1e1f4f699e64357a0f3448d426c7 Mon Sep 17 00:00:00 2001 From: Youngbin Kim <64558592+youngbinkim0@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:20:00 -0500 Subject: [PATCH 2/4] Add Sonnet usage card in main activity - Display Sonnet quota card in MainActivity using Compose - Show card only when Sonnet data is available (sonnetResetsAt > 0L or sonnetUtilization > 0.0) - Use amber color theme (#FFD97706) for Sonnet indicator Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../java/com/example/claudecounter/MainActivity.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/java/com/example/claudecounter/MainActivity.kt b/app/src/main/java/com/example/claudecounter/MainActivity.kt index b3c5a96..e4564a8 100644 --- a/app/src/main/java/com/example/claudecounter/MainActivity.kt +++ b/app/src/main/java/com/example/claudecounter/MainActivity.kt @@ -99,6 +99,7 @@ private val AccentPurple = Color(0xFF7C5CFC) private val AccentBlue = Color(0xFF4A90D9) private val SessionColor = Color(0xFF7C5CFC) private val WeeklyColor = Color(0xFF4A90D9) +private val SonnetColor = Color(0xFFD97706) private val WarningColor = Color(0xFFFF6B6B) private val TextPrimary = Color(0xFFF0EEF8) private val TextSecondary = Color(0xFF9B99AE) @@ -242,6 +243,18 @@ private fun MainScreen( accentColor = WeeklyColor ) + // Sonnet Card + if (usageState.sonnetResetsAt > 0L || usageState.sonnetUtilization > 0.0) { + UsageCard( + title = "Sonnet", + subtitle = "7-day window", + utilization = usageState.sonnetUtilization, + resetsAtMs = usageState.sonnetResetsAt, + now = now, + accentColor = SonnetColor + ) + } + Spacer(Modifier.weight(1f)) // Last updated From 585cc3c9f49d9817ef34efcf0497aa729c828e64 Mon Sep 17 00:00:00 2001 From: Youngbin Kim <64558592+youngbinkim0@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:20:09 -0500 Subject: [PATCH 3/4] Add Sonnet quota parsing tests and dependencies - Add ClaudeApiServiceParsingTest.kt with 4 JUnit4 parsing tests * Tests empty response handling * Tests seven_day_sonnet parsing * Tests null handling for missing quota objects - Add org.json:json as testImplementation for JSON parsing on JVM - Update app/build.gradle.kts with test dependency Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/build.gradle.kts | 3 + .../ClaudeApiServiceParsingTest.kt | 69 +++++++++++++++++++ gradle/libs.versions.toml | 4 ++ 3 files changed, 76 insertions(+) create mode 100644 app/src/test/java/com/example/claudecounter/ClaudeApiServiceParsingTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62ce6ef..0750cc7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,6 +37,7 @@ android { buildFeatures { compose = true } + } dependencies { @@ -52,6 +53,8 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) testImplementation(libs.junit) + testImplementation(libs.json) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/test/java/com/example/claudecounter/ClaudeApiServiceParsingTest.kt b/app/src/test/java/com/example/claudecounter/ClaudeApiServiceParsingTest.kt new file mode 100644 index 0000000..3c46977 --- /dev/null +++ b/app/src/test/java/com/example/claudecounter/ClaudeApiServiceParsingTest.kt @@ -0,0 +1,69 @@ +package com.example.claudecounter + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +class ClaudeApiServiceParsingTest { + + @Test + fun testParseAllWindowsPresent() { + val jsonString = """{"five_hour": {"utilization": 25.5, "resets_at": "2026-02-25T12:00:00Z"}, "seven_day": {"utilization": 45.0, "resets_at": "2026-03-04T00:00:00Z"}, "seven_day_sonnet": {"utilization": 10.0, "resets_at": "2026-03-04T00:00:00Z"}}""" + val json = JSONObject(jsonString) + + val result = ClaudeApiService.parseUsageData(json) + + assertNotNull("five_hour should be parsed", result.fiveHour) + assertEquals("five_hour utilization", 25.5, result.fiveHour!!.utilization, 0.01) + assertEquals("five_hour resets_at", "2026-02-25T12:00:00Z", result.fiveHour!!.resetsAt) + + assertNotNull("seven_day should be parsed", result.sevenDay) + assertEquals("seven_day utilization", 45.0, result.sevenDay!!.utilization, 0.01) + assertEquals("seven_day resets_at", "2026-03-04T00:00:00Z", result.sevenDay!!.resetsAt) + + assertNotNull("seven_day_sonnet should be parsed", result.sevenDaySonnet) + assertEquals("seven_day_sonnet utilization", 10.0, result.sevenDaySonnet!!.utilization, 0.01) + assertEquals("seven_day_sonnet resets_at", "2026-03-04T00:00:00Z", result.sevenDaySonnet!!.resetsAt) + } + + @Test + fun testParseMissingSonnetKey() { + val jsonString = """{"five_hour": {"utilization": 20.0, "resets_at": "2026-02-25T12:00:00Z"}, "seven_day": {"utilization": 55.0, "resets_at": "2026-03-04T00:00:00Z"}}""" + val json = JSONObject(jsonString) + + val result = ClaudeApiService.parseUsageData(json) + + assertNotNull("five_hour should be parsed", result.fiveHour) + assertEquals("five_hour utilization", 20.0, result.fiveHour!!.utilization, 0.01) + + assertNotNull("seven_day should be parsed", result.sevenDay) + assertEquals("seven_day utilization", 55.0, result.sevenDay!!.utilization, 0.01) + + assertNull("seven_day_sonnet should be null when missing", result.sevenDaySonnet) + } + + @Test + fun testParseUsedLimitFallback() { + val jsonString = """{"five_hour": {"used": 25.0, "limit": 100.0, "resets_at": "2026-02-25T12:00:00Z"}}""" + val json = JSONObject(jsonString) + + val result = ClaudeApiService.parseUsageData(json) + + assertNotNull("five_hour should be parsed with used/limit fallback", result.fiveHour) + assertEquals("five_hour utilization from used/limit", 25.0, result.fiveHour!!.utilization, 0.01) + assertEquals("five_hour resets_at", "2026-02-25T12:00:00Z", result.fiveHour!!.resetsAt) + } + + @Test + fun testParseMalformedEmptyObject() { + val jsonString = """{"seven_day_sonnet": {}, "five_hour": {"utilization": 10.0, "resets_at": "2026-02-25T12:00:00Z"}}""" + val json = JSONObject(jsonString) + + val result = ClaudeApiService.parseUsageData(json) + + assertNull("seven_day_sonnet with empty object should be null", result.sevenDaySonnet) + + assertNotNull("five_hour should still be parsed", result.fiveHour) + assertEquals("five_hour utilization", 10.0, result.fiveHour!!.utilization, 0.01) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa7730e..982ed0a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.7.3" kotlin = "2.0.21" coreKtx = "1.15.0" junit = "4.13.2" +json = "20240303" junitVersion = "1.2.1" espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.8.7" @@ -11,6 +12,7 @@ composeBom = "2024.12.01" navigationCompose = "2.8.5" material3 = "1.3.1" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -31,6 +33,8 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version = "4.12.0" } gson = { group = "com.google.code.gson", name = "gson", version = "2.10.1" } +json = { group = "org.json", name = "json", version.ref = "json" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From c995f2d33bdd9248b172d7845b426e4e0048326f Mon Sep 17 00:00:00 2001 From: Youngbin Kim <64558592+youngbinkim0@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:20:18 -0500 Subject: [PATCH 4/4] Update README to mention Sonnet quota tracking - Added note about Sonnet weekly quota tracking in documentation - Feature now tracks seven_day_sonnet usage window alongside other quotas Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index def06d7..7703b10 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # Claude-Counter-Android -Android app to track Claude usage limits in real-time (Session & Weekly) 📊 +Android app to track Claude usage limits in real-time (Session, Weekly & Sonnet) 📊