v1.9: fix Android 16 status loss, bigger widget icons/fonts, security fixes

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>
This commit is contained in:
2026-05-25 03:15:44 +00:00
parent 695c54f03c
commit ee68b11ad0
7 changed files with 90 additions and 53 deletions
@@ -3,6 +3,7 @@ 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
@@ -67,13 +68,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
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 (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() ?: ""
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)
if (utilData != null) {
return@withContext base.copy(
@@ -98,7 +99,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
val req = buildRequest(url, cookies)
val resp = client.newCall(req).execute()
val code = resp.code
Log.d(TAG, "GET $url$code")
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code")
if (code == 401 || code == 403) {
prefs.clearSession()
@@ -107,8 +108,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
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)}")
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) {
@@ -121,8 +124,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
)
}
} catch (e: Exception) {
Log.w(TAG, "Endpoint $url failed: ${e.message}")
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
if (BuildConfig.DEBUG) debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
}
}
@@ -131,7 +134,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
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 (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)
@@ -145,7 +148,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
}
}
} catch (e: Exception) {
debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
}
// Step 4: try page HTML for __NEXT_DATA__
@@ -169,7 +172,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
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")
if (BuildConfig.DEBUG) debugBuf.append("HTML: no __NEXT_DATA__ found\n")
return null
}
val jsonStart = start + marker.length
@@ -177,10 +180,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
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")
if (BuildConfig.DEBUG) debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
tryExtractFromNextData(nextData)
} catch (e: Exception) {
debugBuf.append("HTML scrape error: ${e.message}\n")
if (BuildConfig.DEBUG) debugBuf.append("HTML scrape error: ${e.message}\n")
null
}
}
@@ -200,7 +203,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
obj = (obj as? JSONObject)?.opt(key)
}
val o = obj as? JSONObject ?: continue
debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
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
}
@@ -210,9 +213,9 @@ class UsageRepository(private val prefs: PreferencesManager) {
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}")
if (BuildConfig.DEBUG) 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 (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)
@@ -332,7 +335,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
.header("Accept", "application/json, */*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Referer", "https://claude.ai/")
.header("Cookie", cookies)
.header("Cookie", cookies.replace("\r", "").replace("\n", ""))
.get()
.build()