Initial release: Claude Pro usage widget for Android

This commit is contained in:
2026-05-22 15:11:56 +00:00
commit 33ac02ead4
639 changed files with 52708 additions and 0 deletions
@@ -0,0 +1,12 @@
package me.khodak.claudeusage
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
UsageUpdateWorker.triggerImmediateRefresh(context)
UsageUpdateWorker.schedulePeriodicRefresh(context) // chain next 5-min alarm
}
}
@@ -0,0 +1,249 @@
package me.khodak.claudeusage
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.SizeF
import android.widget.RemoteViews
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
class ClaudeUsageWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
ids.forEach { updateWidget(context, manager, it) }
if (PreferencesManager(context).isLoggedIn()) {
UsageUpdateWorker.schedulePeriodicRefresh(context)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_REFRESH) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
android.content.ComponentName(context, ClaudeUsageWidget::class.java)
)
isRefreshing = true
ids.forEach { updateWidget(context, manager, it) }
UsageUpdateWorker.triggerImmediateRefresh(context)
}
}
companion object {
const val ACTION_REFRESH = "me.khodak.claudeusage.ACTION_REFRESH"
@Volatile internal var isRefreshing = false
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
val prefs = PreferencesManager(context)
val apiData = prefs.getUsageData()
val views = responsiveViews(context, prefs, apiData, widgetId)
manager.updateAppWidget(widgetId, views)
}
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int, data: UsageData?) {
val prefs = PreferencesManager(context)
val views = responsiveViews(context, prefs, data, widgetId)
manager.updateAppWidget(widgetId, views)
}
private fun responsiveViews(
context: Context, prefs: PreferencesManager, apiData: UsageData?, widgetId: Int
): RemoteViews {
val refreshPi = PendingIntent.getBroadcast(
context, widgetId,
Intent(context, ClaudeUsageWidget::class.java).apply { action = ACTION_REFRESH },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val openPi = PendingIntent.getActivity(
context, widgetId + 1000,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
fun attach(v: RemoteViews): RemoteViews {
v.setOnClickPendingIntent(R.id.btn_refresh, refreshPi)
v.setOnClickPendingIntent(R.id.widget_root, openPi)
return v
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
RemoteViews(mapOf(
SizeF(50f, 50f) to attach(buildSmallViews(context, prefs, apiData)),
SizeF(50f, 120f) to attach(buildViews(context, prefs, apiData))
))
} else {
attach(buildViews(context, prefs, apiData))
}
}
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
if (!prefs.isLoggedIn()) {
v.setTextViewText(R.id.tv_session_value, "")
v.setTextViewText(R.id.tv_session_label, "Not signed in")
v.setTextViewText(R.id.tv_weekly_value, "")
v.setTextViewText(R.id.tv_weekly_label, "")
v.setTextViewText(R.id.tv_status, "")
v.setProgressBar(R.id.progress_bar, 100, 0, false)
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
return v
}
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
val hasApiMessages = apiData != null && apiData.effectiveRemaining >= 0 && apiData.messagesLimit > 0
val sessionStart = prefs.getSessionStart()
when {
hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt()
v.setTextViewText(R.id.tv_session_value, "$pct%")
v.setProgressBar(R.id.progress_bar, 100, pct, false)
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
}
hasApiMessages -> {
val rem = apiData!!.effectiveRemaining
val lim = apiData.messagesLimit
v.setTextViewText(R.id.tv_session_value, "$rem/$lim")
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
}
sessionStart > 0 -> {
val elapsedMs = System.currentTimeMillis() - sessionStart
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
v.setTextViewText(R.id.tv_session_value, if (h > 0) "${h}h${m}m" else "${m}m")
v.setProgressBar(R.id.progress_bar, 100, 0, false)
v.setTextViewText(R.id.tv_session_label, "active")
}
else -> {
v.setTextViewText(R.id.tv_session_value, "")
v.setProgressBar(R.id.progress_bar, 100, 0, false)
v.setTextViewText(R.id.tv_session_label, "")
}
}
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
if (hasWeekly) {
val wPct = apiData!!.weeklyUtilization.toInt()
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
} else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
v.setTextViewText(R.id.tv_weekly_label, "")
}
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
val status = when {
isRefreshing -> "Refreshing…"
apiData?.isRateLimited == true -> "Rate limited · ${formatReset(resetEpoch)}"
else -> ""
}
val updatedMs = apiData?.lastUpdated ?: 0L
v.setTextViewText(R.id.tv_status,
if (status.isNotBlank()) status else if (updatedMs > 0) formatTime(updatedMs) else "")
return v
}
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val v = RemoteViews(context.packageName, R.layout.widget_layout)
// ── Not logged in ────────────────────────────────────────────────
if (!prefs.isLoggedIn()) {
v.setTextViewText(R.id.tv_session_value, "")
v.setTextViewText(R.id.tv_session_label, "Not signed in")
v.setTextViewText(R.id.tv_weekly_value, "")
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
v.setTextViewText(R.id.tv_status, "")
v.setProgressBar(R.id.progress_bar, 100, 0, false)
return v
}
// ── 5-hour window bar ────────────────────────────────────────────
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
val hasApiMessages = apiData != null && apiData.effectiveRemaining >= 0 && apiData.messagesLimit > 0
val sessionStart = prefs.getSessionStart()
when {
hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt()
v.setTextViewText(R.id.tv_session_value, "$pct%")
v.setProgressBar(R.id.progress_bar, 100, pct, false)
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
}
hasApiMessages -> {
val rem = apiData!!.effectiveRemaining
val lim = apiData.messagesLimit
v.setTextViewText(R.id.tv_session_value, "$rem / $lim")
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
}
sessionStart > 0 -> {
val elapsedMs = System.currentTimeMillis() - sessionStart
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
val display = if (h > 0) "${h}h ${m}m" else if (m > 0) "${m}m" else "Just started"
v.setTextViewText(R.id.tv_session_value, display)
v.setProgressBar(R.id.progress_bar, 100, 0, false)
v.setTextViewText(R.id.tv_session_label, "session active")
}
else -> {
v.setTextViewText(R.id.tv_session_value, "")
v.setProgressBar(R.id.progress_bar, 100, 0, false)
v.setTextViewText(R.id.tv_session_label, "")
}
}
// ── 7-day usage bar ──────────────────────────────────────────────
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
if (hasWeekly) {
val wPct = apiData!!.weeklyUtilization.toInt()
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
} else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
v.setTextViewText(R.id.tv_weekly_label, "active this week")
}
// ── Footer ───────────────────────────────────────────────────────
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
val status = when {
isRefreshing -> "Refreshing…"
apiData?.isRateLimited == true -> "Rate limited · ${formatReset(resetEpoch)}"
else -> ""
}
val updatedMs = apiData?.lastUpdated ?: 0L
v.setTextViewText(R.id.tv_status,
if (status.isNotBlank()) status
else if (updatedMs > 0) formatTime(updatedMs)
else ""
)
return v
}
private fun formatReset(epochMs: Long): String {
if (epochMs <= 0) return ""
val now = System.currentTimeMillis()
if (epochMs <= now) return "Resets soon"
val diffH = TimeUnit.MILLISECONDS.toHours(epochMs - now)
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
return when {
diffH < 24 -> "Resets at $timeStr"
diffH < 48 -> "Resets tomorrow $timeStr"
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
}
}
private fun formatTime(ms: Long) =
SimpleDateFormat("h:mm a", Locale.US).format(Date(ms))
}
}
@@ -0,0 +1,172 @@
package me.khodak.claudeusage
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.os.Bundle
import android.view.View
import android.webkit.*
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.databinding.ActivityLoginBinding
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private lateinit var prefs: PreferencesManager
private var loginHandled = false
// JS injected before page scripts run — hides WebView fingerprints
private val antiDetectionJs = """
(function() {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = { runtime: {} };
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3] });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US','en'] });
})();
""".trimIndent()
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = PreferencesManager(this)
setupTabs()
setupWebView()
setupCookiePanel()
}
private fun setupTabs() {
binding.tabBrowser.setOnClickListener {
binding.panelBrowser.visibility = View.VISIBLE
binding.panelCookie.visibility = View.GONE
binding.tabBrowser.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFFCC785C.toInt())
binding.tabBrowser.setTextColor(0xFFFFFFFF.toInt())
binding.tabCookie.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFF2A2A2A.toInt())
binding.tabCookie.setTextColor(0xFF888888.toInt())
}
binding.tabCookie.setOnClickListener {
binding.panelBrowser.visibility = View.GONE
binding.panelCookie.visibility = View.VISIBLE
binding.tabCookie.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFFCC785C.toInt())
binding.tabCookie.setTextColor(0xFFFFFFFF.toInt())
binding.tabBrowser.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFF2A2A2A.toInt())
binding.tabBrowser.setTextColor(0xFF888888.toInt())
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
binding.btnDone.setOnClickListener { attemptCookieCapture(force = true) }
with(binding.webView) {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
javaScriptCanOpenWindowsAutomatically = true
setSupportMultipleWindows(false)
// Standard Android Chrome UA — less suspicious than desktop
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/120.0.6099.230 Mobile Safari/537.36"
}
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
if (url.startsWith("market://") || url.startsWith("intent://")) return true
return false
}
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
// Inject before page scripts
view.evaluateJavascript(antiDetectionJs, null)
}
override fun onPageFinished(view: WebView, url: String) {
// Re-inject after page load
view.evaluateJavascript(antiDetectionJs, null)
if (url.contains("claude.ai")) {
binding.btnDone.visibility = View.VISIBLE
}
if (loginHandled) return
val onMain = url.startsWith("https://claude.ai") &&
!url.contains("/login") && !url.contains("/auth") &&
!url.contains("/sign") && !url.contains("/verify")
if (onMain) attemptCookieCapture(force = false)
}
}
loadUrl("https://claude.ai/login")
}
}
private fun setupCookiePanel() {
binding.btnSaveCookie.setOnClickListener {
val cookie = binding.etCookie.text.toString().trim()
if (cookie.length < 10) {
Toast.makeText(this, "Cookie too short — paste the full string", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
handleLoginSuccess(cookie)
}
}
private fun attemptCookieCapture(force: Boolean) {
if (loginHandled) return
CookieManager.getInstance().flush()
val cookies = CookieManager.getInstance().getCookie("https://claude.ai") ?: ""
if (cookies.length > 10) {
loginHandled = true
handleLoginSuccess(cookies)
} else if (force) {
Toast.makeText(this, "No session found — finish logging in first, or use the Paste Cookie tab", Toast.LENGTH_LONG).show()
}
}
private fun handleLoginSuccess(cookies: String) {
prefs.saveCookies(cookies)
prefs.saveSessionStart(System.currentTimeMillis())
prefs.markTodayActive()
Toast.makeText(this, "Signed in — loading usage…", Toast.LENGTH_SHORT).show()
UsageUpdateWorker.triggerImmediateRefresh(this)
UsageUpdateWorker.schedulePeriodicRefresh(this)
CoroutineScope(Dispatchers.IO).launch {
try {
val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data)
val mgr = AppWidgetManager.getInstance(this@LoginActivity)
val ids = mgr.getAppWidgetIds(ComponentName(this@LoginActivity, ClaudeUsageWidget::class.java))
ids.forEach { id -> ClaudeUsageWidget.updateWidget(this@LoginActivity, mgr, id, data) }
} catch (_: Exception) {}
}
setResult(RESULT_OK)
finish()
}
override fun onBackPressed() {
if (binding.panelBrowser.visibility == View.VISIBLE && binding.webView.canGoBack())
binding.webView.goBack()
else super.onBackPressed()
}
override fun onDestroy() {
binding.webView.destroy()
super.onDestroy()
}
}
@@ -0,0 +1,150 @@
package me.khodak.claudeusage
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
import me.khodak.claudeusage.databinding.ActivityMainBinding
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var prefs: PreferencesManager
private lateinit var repo: UsageRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = PreferencesManager(this)
repo = UsageRepository(prefs)
binding.btnLogin.setOnClickListener {
startActivity(Intent(this, LoginActivity::class.java))
}
// If already logged in, go straight to logged-in state without re-opening login
if (prefs.isLoggedIn()) {
updateUI(prefs.getUsageData())
}
binding.btnLogout.setOnClickListener {
prefs.clearSession()
updateUI(null)
}
binding.btnRefresh.setOnClickListener {
refreshUsage()
}
binding.tvWidgetHint.setOnClickListener {
startActivity(Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
})
}
binding.btnDebug.setOnClickListener {
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
binding.tvDebugInfo.visibility = android.view.View.VISIBLE
binding.btnDebug.text = "Hide API Debug"
} else {
binding.tvDebugInfo.visibility = android.view.View.GONE
binding.btnDebug.text = "Show API Debug"
}
}
}
override fun onResume() {
super.onResume()
val cached = prefs.getUsageData()
updateUI(cached)
if (prefs.isLoggedIn()) {
refreshUsage()
}
}
private fun refreshUsage() {
binding.btnRefresh.isEnabled = false
binding.progressIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val data = try {
repo.fetchUsage()
} catch (e: Exception) {
prefs.getUsageData()?.copy(errorMessage = "Network error")
?: UsageData(errorMessage = "Network error")
}
prefs.saveUsageData(data)
updateUI(data)
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
binding.tvDebugInfo.text = repo.lastDebugInfo
}
binding.btnRefresh.isEnabled = true
binding.progressIndicator.visibility = View.GONE
}
}
private fun updateUI(data: UsageData?) {
val loggedIn = prefs.isLoggedIn()
binding.layoutLoggedOut.visibility = if (loggedIn) View.GONE else View.VISIBLE
binding.layoutLoggedIn.visibility = if (loggedIn) View.VISIBLE else View.GONE
if (!loggedIn || data == null) return
binding.progressBar.progress = data.progressPercent
if (data.weeklyUtilization >= 0f) {
val wPct = data.weeklyUtilization.toInt()
binding.progressBarWeekly.progress = wPct
binding.tvWeeklyUsage.text = "$wPct% this week"
} else {
binding.progressBarWeekly.progress = 0
binding.tvWeeklyUsage.text = ""
}
binding.tvUsage.text = when {
data.fiveHourUtilization >= 0f -> {
val pct = data.fiveHourUtilization.toInt()
"$pct% of limit used"
}
data.messagesLimit > 0 && data.effectiveUsed >= 0 ->
"${data.effectiveUsed} of ${data.messagesLimit} messages used"
data.effectiveRemaining >= 0 && data.messagesLimit > 0 ->
"${data.effectiveRemaining} of ${data.messagesLimit} remaining"
data.effectiveRemaining >= 0 ->
"${data.effectiveRemaining} messages remaining"
else -> "Usage data unavailable"
}
binding.tvReset.text = formatReset(data.effectiveResetEpoch)
binding.tvWeeklyReset.text = formatReset(data.weeklyResetAtEpoch)
binding.tvUpdated.text = if (data.lastUpdated > 0)
"Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}"
else ""
binding.tvError.text = data.errorMessage
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
}
private fun formatReset(epochMs: Long): String {
if (epochMs <= 0) return ""
val now = System.currentTimeMillis()
if (epochMs <= now) return "Resets soon"
val diffH = TimeUnit.MILLISECONDS.toHours(epochMs - now)
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
return when {
diffH < 24 -> "Resets at $timeStr"
diffH < 48 -> "Resets tomorrow $timeStr"
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
}
}
}
@@ -0,0 +1,342 @@
package me.khodak.claudeusage
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
class UsageRepository(private val prefs: PreferencesManager) {
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.followRedirects(false)
.build()
private val desktopUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private val debugBuf = StringBuilder()
val lastDebugInfo: String get() = debugBuf.toString()
suspend fun fetchUsage(): UsageData = withContext(Dispatchers.IO) {
debugBuf.clear()
val cookies = prefs.getCookies()
?: return@withContext UsageData(errorMessage = "Not logged in")
prefs.markTodayActive()
val base = UsageData(
isLoggedIn = true,
sessionStartEpoch = prefs.getSessionStart(),
weeklyActiveDaysMask = prefs.getWeeklyMask(),
lastUpdated = System.currentTimeMillis()
)
// Step 1: get org ID
var orgId = prefs.getOrgId()
var orgUsageData: UsageData? = null
val (fetchedId, fetchedUsage) = fetchOrgInfo(cookies)
if (orgId == null && fetchedId != null) {
orgId = fetchedId
prefs.saveOrgId(orgId)
}
orgUsageData = fetchedUsage
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
if (orgUsageData?.hasRateLimitData == true) {
return@withContext base.copy(
messagesUsed = orgUsageData.messagesUsed,
messagesLimit = orgUsageData.messagesLimit,
messagesRemaining = orgUsageData.messagesRemaining,
resetAtEpoch = orgUsageData.resetAtEpoch
)
}
// Step 2: /usage endpoint — returns utilization percentages
try {
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
val code = resp.code
Log.d(TAG, "GET $usageUrl$code")
if (code == 401 || code == 403) {
prefs.clearSession()
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
}
val body = resp.body?.string() ?: ""
debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n")
val utilData = tryParseUtilizationBody(body)
if (utilData != null) {
return@withContext base.copy(
fiveHourUtilization = utilData.fiveHourUtilization,
weeklyUtilization = utilData.weeklyUtilization,
utilizationResetAtEpoch = utilData.utilizationResetAtEpoch,
weeklyResetAtEpoch = utilData.weeklyResetAtEpoch
)
}
} catch (e: Exception) {
Log.w(TAG, "/usage failed: ${e.message}")
}
// Step 3: fallback endpoints (message-count style)
val endpoints = listOf(
"https://claude.ai/api/organizations/$orgId/rate_limit_status",
"https://claude.ai/api/organizations/$orgId"
)
for (url in endpoints) {
try {
val req = buildRequest(url, cookies)
val resp = client.newCall(req).execute()
val code = resp.code
Log.d(TAG, "GET $url$code")
if (code == 401 || code == 403) {
prefs.clearSession()
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
}
val rateLimitData = extractRateLimitHeaders(resp.headers)
val body = resp.body?.string() ?: ""
debugBuf.append("$url\n$code: ${body.take(300)}\n\n")
Log.d(TAG, "Body: ${body.take(300)}")
val parsed = tryParseUsageBody(body, rateLimitData)
if (parsed.hasRateLimitData) {
return@withContext base.copy(
messagesUsed = parsed.messagesUsed,
messagesLimit = parsed.messagesLimit,
messagesRemaining = parsed.messagesRemaining,
resetAtEpoch = parsed.resetAtEpoch,
isRateLimited = parsed.isRateLimited
)
}
} catch (e: Exception) {
Log.w(TAG, "Endpoint $url failed: ${e.message}")
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
}
}
// Step 3: try /api/me endpoint
try {
val req = buildRequest("https://claude.ai/api/me", cookies)
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
debugBuf.append("https://claude.ai/api/me\n${resp.code}: ${body.take(400)}\n\n")
if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) {
val json = JSONObject(body)
val parsed = tryParseOrgForUsage(json)
if (parsed?.hasRateLimitData == true) {
return@withContext base.copy(
messagesUsed = parsed.messagesUsed,
messagesLimit = parsed.messagesLimit,
messagesRemaining = parsed.messagesRemaining,
resetAtEpoch = parsed.resetAtEpoch
)
}
}
} catch (e: Exception) {
debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
}
// Step 4: try page HTML for __NEXT_DATA__
val htmlData = fetchFromPageHtml(cookies)
if (htmlData?.hasRateLimitData == true) {
return@withContext base.copy(
messagesUsed = htmlData.messagesUsed,
messagesLimit = htmlData.messagesLimit,
messagesRemaining = htmlData.messagesRemaining,
resetAtEpoch = htmlData.resetAtEpoch
)
}
base
}
private fun fetchFromPageHtml(cookies: String): UsageData? {
return try {
val resp = client.newCall(buildRequest("https://claude.ai/", cookies)).execute()
val html = resp.body?.string() ?: return null
val marker = """<script id="__NEXT_DATA__" type="application/json">"""
val start = html.indexOf(marker)
if (start < 0) {
debugBuf.append("HTML: no __NEXT_DATA__ found\n")
return null
}
val jsonStart = start + marker.length
val jsonEnd = html.indexOf("</script>", jsonStart)
if (jsonEnd < 0) return null
val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
val topKeys = nextData.keys().asSequence().toList()
debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
tryExtractFromNextData(nextData)
} catch (e: Exception) {
debugBuf.append("HTML scrape error: ${e.message}\n")
null
}
}
private fun tryExtractFromNextData(json: JSONObject): UsageData? {
// Walk common Next.js page prop paths looking for usage data
val paths = listOf(
listOf("props", "pageProps", "rateLimits"),
listOf("props", "pageProps", "usage"),
listOf("props", "pageProps", "account"),
listOf("props", "pageProps", "organization"),
listOf("props", "pageProps"),
)
for (path in paths) {
var obj: Any? = json
for (key in path) {
obj = (obj as? JSONObject)?.opt(key)
}
val o = obj as? JSONObject ?: continue
debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
if (usage.hasRateLimitData) return usage
}
return null
}
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
return try {
val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute()
Log.d(TAG, "Orgs → ${resp.code}")
val body = resp.body?.string() ?: return Pair(null, null)
debugBuf.append("https://claude.ai/api/organizations\n${resp.code}: ${body.take(400)}\n\n")
if (body.isBlank() || body.startsWith("<")) return Pair(null, null)
val arr = JSONArray(body)
val org = arr.optJSONObject(0) ?: return Pair(null, null)
val orgId = org.optString("uuid").ifBlank { null }
val usageData = tryParseOrgForUsage(org)
Pair(orgId, usageData)
} catch (e: Exception) {
Log.w(TAG, "fetchOrgInfo: ${e.message}")
Pair(null, null)
}
}
private fun tryParseOrgForUsage(org: JSONObject): UsageData? {
val limit = org.optInt("message_limit", -1).takeIf { it > 0 }
?: org.optJSONObject("capabilities")?.optInt("message_limit", -1)?.takeIf { it > 0 }
?: org.optJSONObject("usage")?.optInt("limit", -1)?.takeIf { it > 0 }
?: org.optJSONObject("rate_limit")?.optInt("limit", -1)?.takeIf { it > 0 }
?: org.optInt("limit", -1).takeIf { it > 0 }
val used = org.optInt("messages_sent", -1).takeIf { it >= 0 }
?: org.optJSONObject("usage")?.optInt("messages_sent", -1)?.takeIf { it >= 0 }
?: org.optJSONObject("rate_limit")?.optInt("used", -1)?.takeIf { it >= 0 }
?: org.optInt("used", -1).takeIf { it >= 0 }
val remaining = org.optInt("messages_remaining", -1).takeIf { it >= 0 }
?: org.optJSONObject("usage")?.optInt("remaining", -1)?.takeIf { it >= 0 }
?: org.optJSONObject("rate_limit")?.optInt("remaining", -1)?.takeIf { it >= 0 }
?: org.optInt("remaining", -1).takeIf { it >= 0 }
val resetStr = org.optString("usage_reset_at", "")
.ifBlank { org.optJSONObject("usage")?.optString("reset_at", "") ?: "" }
.ifBlank { org.optJSONObject("rate_limit")?.optString("reset_at", "") ?: "" }
if (limit == null && used == null && remaining == null) return null
return UsageData(
messagesLimit = limit ?: -1,
messagesUsed = used ?: -1,
messagesRemaining = remaining ?: -1,
resetAtEpoch = if (resetStr.isNotBlank()) parseResetTime(resetStr) else -1L
)
}
// Parses the /usage endpoint response: {"five_hour":{"utilization":75.0,"resets_at":"..."},...}
private fun tryParseUtilizationBody(body: String): UsageData? {
if (body.isBlank() || body.startsWith("<")) return null
return try {
val json = JSONObject(body)
val fiveHour = json.optJSONObject("five_hour") ?: return null
val utilization = fiveHour.optDouble("utilization", -1.0).toFloat()
if (utilization < 0f) return null
val resetsAt = fiveHour.optString("resets_at", "")
val sevenDay = json.optJSONObject("seven_day")
val weeklyUtil = sevenDay?.optDouble("utilization", -1.0)?.toFloat() ?: -1f
val weeklyResetsAt = sevenDay?.optString("resets_at", "") ?: ""
UsageData(
fiveHourUtilization = utilization,
weeklyUtilization = weeklyUtil,
utilizationResetAtEpoch = if (resetsAt.isNotBlank()) parseResetTime(resetsAt) else -1L,
weeklyResetAtEpoch = if (weeklyResetsAt.isNotBlank()) parseResetTime(weeklyResetsAt) else -1L
)
} catch (e: Exception) { null }
}
private fun extractRateLimitHeaders(headers: okhttp3.Headers): UsageData {
val remaining = headers["anthropic-ratelimit-requests-remaining"]?.toIntOrNull()
val limit = headers["anthropic-ratelimit-requests-limit"]?.toIntOrNull()
val reset = headers["anthropic-ratelimit-requests-reset"]
return UsageData(
messagesRemaining = remaining ?: -1,
messagesLimit = limit ?: -1,
resetAtEpoch = parseResetTime(reset)
)
}
private fun tryParseUsageBody(body: String, base: UsageData): UsageData {
if (body.isBlank() || body.startsWith("<")) return base
return try {
val json = JSONObject(body)
val used = json.optInt("messages_sent", -1).takeIf { it >= 0 }
?: json.optInt("message_count", -1).takeIf { it >= 0 }
?: json.optInt("used", -1).takeIf { it >= 0 }
?: -1
val limit = json.optInt("messages_limit", -1).takeIf { it >= 0 }
?: json.optInt("limit", -1).takeIf { it >= 0 }
?: base.messagesLimit
val remaining = json.optInt("messages_remaining", -1).takeIf { it >= 0 }
?: json.optInt("remaining", -1).takeIf { it >= 0 }
?: base.messagesRemaining
val reset = json.optString("reset_at", "").ifBlank { json.optString("resets_at", "") }
base.copy(
messagesUsed = used,
messagesLimit = limit,
messagesRemaining = remaining,
resetAtEpoch = if (reset.isNotBlank()) parseResetTime(reset) else base.resetAtEpoch
)
} catch (e: Exception) { base }
}
private fun parseResetTime(value: String?): Long {
if (value.isNullOrBlank()) return -1L
// Normalize: truncate sub-second digits beyond 3 (e.g. microseconds → milliseconds)
val normalized = value.replace(Regex("(\\.\\d{3})\\d+"), "$1")
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'"
)
for (fmt in formats) {
try {
val sdf = SimpleDateFormat(fmt, Locale.US).also { it.timeZone = TimeZone.getTimeZone("UTC") }
return sdf.parse(normalized)?.time ?: continue
} catch (_: Exception) {}
}
return value.toLongOrNull()?.let { System.currentTimeMillis() + it * 1000 } ?: -1L
}
private fun buildRequest(url: String, cookies: String) = Request.Builder()
.url(url)
.header("User-Agent", desktopUA)
.header("Accept", "application/json, */*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Referer", "https://claude.ai/")
.header("Cookie", cookies)
.get()
.build()
companion object {
private const val TAG = "UsageRepo"
}
}
@@ -0,0 +1,77 @@
package me.khodak.claudeusage
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import androidx.work.*
import me.khodak.claudeusage.data.PreferencesManager
import java.util.concurrent.TimeUnit
class UsageUpdateWorker(
private val context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
val prefs = PreferencesManager(context)
if (!prefs.isLoggedIn()) return Result.success()
prefs.markTodayActive()
// Try API — save result (even on failure, local data is preserved)
try {
val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data)
} catch (_: Exception) {
// API failed — don't save, keep existing cached data
}
// Always push widget update using latest prefs + cached api data
pushWidgetUpdate()
return Result.success()
}
private fun pushWidgetUpdate() {
ClaudeUsageWidget.isRefreshing = false
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(ComponentName(context, ClaudeUsageWidget::class.java))
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
}
companion object {
private const val WORK_ONE_SHOT = "claude_oneshot"
private const val ALARM_CODE = 1001
private const val INTERVAL_MS = 5 * 60 * 1000L
fun schedulePeriodicRefresh(context: Context) {
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
am.setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + INTERVAL_MS,
alarmIntent(context)
)
}
fun cancelPeriodicRefresh(context: Context) =
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
.cancel(alarmIntent(context))
fun triggerImmediateRefresh(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_ONE_SHOT,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<UsageUpdateWorker>().build()
)
}
private fun alarmIntent(context: Context) = PendingIntent.getBroadcast(
context, ALARM_CODE,
Intent(context, AlarmReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}
@@ -0,0 +1,94 @@
package me.khodak.claudeusage.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.google.gson.Gson
import java.util.Calendar
class PreferencesManager(context: Context) {
private val gson = Gson()
private val securePrefs = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context, "claude_secure", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
}
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
fun saveCookies(cookies: String) {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
// Plaintext backup — survives EncryptedSharedPreferences key rotation on reinstall
prefs.edit().putString(KEY_COOKIES_BACKUP, cookies).apply()
}
fun getCookies(): String? =
securePrefs.getString(KEY_COOKIES, null)
?: prefs.getString(KEY_COOKIES_BACKUP, null)
fun clearSession() {
securePrefs.edit().clear().apply()
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START)
.remove(KEY_COOKIES_BACKUP).apply()
}
fun saveOrgId(id: String) = prefs.edit().putString(KEY_ORG_ID, id).apply()
fun getOrgId(): String? = prefs.getString(KEY_ORG_ID, null)
fun saveSessionStart(epochMs: Long) = prefs.edit().putLong(KEY_SESSION_START, epochMs).apply()
fun getSessionStart(): Long = prefs.getLong(KEY_SESSION_START, 0L)
fun markTodayActive() {
val cal = Calendar.getInstance()
val dayBit = 1 shl cal.get(Calendar.DAY_OF_WEEK) - 1
val weekKey = getWeekKey(cal)
// Reset mask if it's a new week
val storedWeek = prefs.getString(KEY_ACTIVE_WEEK, "")
val currentMask = if (storedWeek == weekKey) prefs.getInt(KEY_ACTIVE_MASK, 0) else 0
prefs.edit()
.putString(KEY_ACTIVE_WEEK, weekKey)
.putInt(KEY_ACTIVE_MASK, currentMask or dayBit)
.apply()
}
fun getWeeklyMask(): Int {
val cal = Calendar.getInstance()
val weekKey = getWeekKey(cal)
val storedWeek = prefs.getString(KEY_ACTIVE_WEEK, "")
return if (storedWeek == weekKey) prefs.getInt(KEY_ACTIVE_MASK, 0) else 0
}
private fun getWeekKey(cal: Calendar): String {
val year = cal.get(Calendar.YEAR)
val week = cal.get(Calendar.WEEK_OF_YEAR)
return "$year-$week"
}
fun saveUsageData(data: UsageData) =
prefs.edit().putString(KEY_USAGE_DATA, gson.toJson(data)).apply()
fun getUsageData(): UsageData? = try {
prefs.getString(KEY_USAGE_DATA, null)?.let { gson.fromJson(it, UsageData::class.java) }
} catch (e: Exception) { null }
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
companion object {
private const val KEY_COOKIES = "session_cookies"
private const val KEY_COOKIES_BACKUP = "session_cookies_backup"
private const val KEY_ORG_ID = "org_id"
private const val KEY_SESSION_START = "session_start"
private const val KEY_USAGE_DATA = "usage_data"
private const val KEY_ACTIVE_WEEK = "active_week"
private const val KEY_ACTIVE_MASK = "active_mask"
}
}
@@ -0,0 +1,52 @@
package me.khodak.claudeusage.data
data class UsageData(
val messagesUsed: Int = -1,
val messagesLimit: Int = -1,
val messagesRemaining: Int = -1,
val resetAtEpoch: Long = -1L,
val isRateLimited: Boolean = false,
val lastUpdated: Long = 0L,
val errorMessage: String = "",
val isLoggedIn: Boolean = false,
val sessionStartEpoch: Long = 0L,
val weeklyActiveDaysMask: Int = 0, // bitmask: bit0=Sun, bit1=Mon ... bit6=Sat
// From /api/organizations/{id}/usage — utilization as percentage 0-100
val fiveHourUtilization: Float = -1f,
val weeklyUtilization: Float = -1f,
val utilizationResetAtEpoch: Long = -1L,
val weeklyResetAtEpoch: Long = -1L
) {
val hasRateLimitData: Boolean get() =
messagesLimit > 0 || messagesRemaining >= 0 || fiveHourUtilization >= 0f
val hasSessionData: Boolean get() = sessionStartEpoch > 0
val progressPercent: Int get() = when {
messagesLimit > 0 && effectiveUsed >= 0 ->
((effectiveUsed.toFloat() / messagesLimit.toFloat()) * 100).toInt().coerceIn(0, 100)
fiveHourUtilization >= 0f -> fiveHourUtilization.toInt().coerceIn(0, 100)
else -> 0
}
val effectiveUsed: Int get() = when {
messagesUsed >= 0 -> messagesUsed
messagesRemaining >= 0 && messagesLimit > 0 -> messagesLimit - messagesRemaining
else -> -1
}
val effectiveRemaining: Int get() = when {
messagesRemaining >= 0 -> messagesRemaining
messagesUsed >= 0 && messagesLimit > 0 -> messagesLimit - messagesUsed
else -> -1
}
val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask)
// The best reset time we have (utilization endpoint is preferred)
val effectiveResetEpoch: Long get() = when {
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
resetAtEpoch > 0 -> resetAtEpoch
else -> -1L
}
}