Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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) 📊
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ android {
buildFeatures {
compose = true
}

}

dependencies {
Expand All @@ -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))
Expand Down
12 changes: 7 additions & 5 deletions app/src/main/java/com/example/claudecounter/ClaudeApiService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ object ClaudeApiService {

data class UsageData(
val fiveHour: UsageWindow?,
val sevenDay: UsageWindow?
val sevenDay: UsageWindow?,
val sevenDaySonnet: UsageWindow?
)

data class UsageResult(
Expand Down Expand Up @@ -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 }
Expand All @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/example/claudecounter/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions app/src/main/java/com/example/claudecounter/SessionManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
)
}
Expand All @@ -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)
)

Expand All @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/example/claudecounter/UsagePollingService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/res/drawable/notif_progress_sonnet.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<solid android:color="#33FFFFFF" />
<corners android:radius="4dp" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<solid android:color="#D97706" />
<corners android:radius="4dp" />
</shape>
</clip>
</item>
</layer-list>
12 changes: 12 additions & 0 deletions app/src/main/res/layout/notification_usage.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,16 @@

<TextView android:id="@+id/weekly_time" android:layout_width="60dp" android:layout_height="wrap_content" android:text="" android:textSize="11sp" android:textColor="#AAAAAA" android:gravity="end" android:layout_marginStart="4dp" />
</LinearLayout>

<!-- Sonnet row -->
<LinearLayout android:id="@+id/sonnet_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:visibility="gone">

<TextView android:id="@+id/sonnet_label" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="Sonnet" android:textSize="12sp" android:textColor="#FFFFFF" />

<ProgressBar android:id="@+id/sonnet_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="0dp" android:layout_height="12dp" android:layout_weight="2.5" android:layout_marginStart="8dp" android:layout_marginEnd="8dp" android:max="1000" android:progress="0" android:progressDrawable="@drawable/notif_progress_sonnet" />

<TextView android:id="@+id/sonnet_pct" android:layout_width="40dp" android:layout_height="wrap_content" android:text="0%" android:textSize="11sp" android:textColor="#FFFFFF" android:gravity="end" />

<TextView android:id="@+id/sonnet_time" android:layout_width="60dp" android:layout_height="wrap_content" android:text="" android:textSize="11sp" android:textColor="#AAAAAA" android:gravity="end" android:layout_marginStart="4dp" />
</LinearLayout>
</LinearLayout>
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Expand All @@ -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" }
Expand Down