ee68b11ad0
Android 16 bug: EncryptedSharedPreferences threw on ANY exception (Keystore busy during screen-lock/BG wakeup) and the code deleted the encrypted prefs file on any failure, permanently erasing session cookies. Now only KeyPermanentlyInvalidatedException (biometric/PIN change) triggers delete; transient failures preserve the file for the next session. Also prevents saving cookies to plain-text fallback prefs if encrypted prefs are unavailable. WorkManager periodic (15 min, requires network) added alongside AlarmManager as a Doze-mode backup for Android 16, where inexact alarms can be batched up to 75 min. UI: sync icon 24→32dp (large widget), 20→28dp (small); reset-time font 9→11sp (large), 8→10sp (small). Security: - All Log.d response-body and URL-bearing logs gated behind BuildConfig.DEBUG - Cookie header value stripped of CRLF to prevent HTTP header injection - LoginActivity coroutine migrated from bare CoroutineScope to lifecycleScope - Widget removed from keyguard (lock-screen) category — usage data is sensitive Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
346 lines
15 KiB
Kotlin
346 lines
15 KiB
Kotlin
package me.khodak.claudeusage
|
|
|
|
import android.util.Log
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
import me.khodak.claudeusage.BuildConfig
|
|
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
|
|
if (BuildConfig.DEBUG) 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() ?: ""
|
|
if (BuildConfig.DEBUG) 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
|
|
if (BuildConfig.DEBUG) 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() ?: ""
|
|
if (BuildConfig.DEBUG) {
|
|
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) {
|
|
if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
|
|
if (BuildConfig.DEBUG) 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() ?: ""
|
|
if (BuildConfig.DEBUG) 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) {
|
|
if (BuildConfig.DEBUG) 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) {
|
|
if (BuildConfig.DEBUG) 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()
|
|
if (BuildConfig.DEBUG) debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
|
|
tryExtractFromNextData(nextData)
|
|
} catch (e: Exception) {
|
|
if (BuildConfig.DEBUG) 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
|
|
if (BuildConfig.DEBUG) 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()
|
|
if (BuildConfig.DEBUG) Log.d(TAG, "Orgs → ${resp.code}")
|
|
val body = resp.body?.string() ?: return Pair(null, null)
|
|
if (BuildConfig.DEBUG) 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.replace("\r", "").replace("\n", ""))
|
|
.get()
|
|
.build()
|
|
|
|
companion object {
|
|
private const val TAG = "UsageRepo"
|
|
}
|
|
}
|