2 Commits

Author SHA1 Message Date
amir 6801a60183 bump versionCode to 10 / versionName to 1.9, enable BuildConfig generation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 03:30:59 +00:00
amir ee68b11ad0 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>
2026-05-25 03:15:44 +00:00
8 changed files with 93 additions and 55 deletions
+3 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "me.khodak.claudeusage" applicationId = "me.khodak.claudeusage"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 9 versionCode = 10
versionName = "1.8" versionName = "1.9"
} }
signingConfigs { signingConfigs {
@@ -44,6 +44,7 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true
} }
} }
@@ -8,7 +8,7 @@ import android.view.View
import android.webkit.* import android.webkit.*
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.CoroutineScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager import me.khodak.claudeusage.data.PreferencesManager
@@ -145,7 +145,7 @@ class LoginActivity : AppCompatActivity() {
UsageUpdateWorker.triggerImmediateRefresh(this) UsageUpdateWorker.triggerImmediateRefresh(this)
UsageUpdateWorker.schedulePeriodicRefresh(this) UsageUpdateWorker.schedulePeriodicRefresh(this)
CoroutineScope(Dispatchers.IO).launch { lifecycleScope.launch(Dispatchers.IO) {
try { try {
val data = UsageRepository(prefs).fetchUsage() val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data) prefs.saveUsageData(data)
@@ -3,6 +3,7 @@ package me.khodak.claudeusage
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.khodak.claudeusage.BuildConfig
import me.khodak.claudeusage.data.PreferencesManager import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData import me.khodak.claudeusage.data.UsageData
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -67,13 +68,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage" val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute() val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
val code = resp.code val code = resp.code
Log.d(TAG, "GET $usageUrl$code") if (BuildConfig.DEBUG) Log.d(TAG, "GET $usageUrl$code")
if (code == 401 || code == 403) { if (code == 401 || code == 403) {
prefs.clearSession() prefs.clearSession()
return@withContext UsageData(errorMessage = "Session expired — please sign in again") return@withContext UsageData(errorMessage = "Session expired — please sign in again")
} }
val body = resp.body?.string() ?: "" 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) val utilData = tryParseUtilizationBody(body)
if (utilData != null) { if (utilData != null) {
return@withContext base.copy( return@withContext base.copy(
@@ -98,7 +99,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
val req = buildRequest(url, cookies) val req = buildRequest(url, cookies)
val resp = client.newCall(req).execute() val resp = client.newCall(req).execute()
val code = resp.code val code = resp.code
Log.d(TAG, "GET $url$code") if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code")
if (code == 401 || code == 403) { if (code == 401 || code == 403) {
prefs.clearSession() prefs.clearSession()
@@ -107,8 +108,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
val rateLimitData = extractRateLimitHeaders(resp.headers) val rateLimitData = extractRateLimitHeaders(resp.headers)
val body = resp.body?.string() ?: "" val body = resp.body?.string() ?: ""
debugBuf.append("$url\n$code: ${body.take(300)}\n\n") if (BuildConfig.DEBUG) {
Log.d(TAG, "Body: ${body.take(300)}") debugBuf.append("$url\n$code: ${body.take(300)}\n\n")
Log.d(TAG, "Body: ${body.take(300)}")
}
val parsed = tryParseUsageBody(body, rateLimitData) val parsed = tryParseUsageBody(body, rateLimitData)
if (parsed.hasRateLimitData) { if (parsed.hasRateLimitData) {
@@ -121,8 +124,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Endpoint $url failed: ${e.message}") if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n") 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 req = buildRequest("https://claude.ai/api/me", cookies)
val resp = client.newCall(req).execute() val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: "" 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("<")) { if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) {
val json = JSONObject(body) val json = JSONObject(body)
val parsed = tryParseOrgForUsage(json) val parsed = tryParseOrgForUsage(json)
@@ -145,7 +148,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
} }
} }
} catch (e: Exception) { } 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__ // 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 marker = """<script id="__NEXT_DATA__" type="application/json">"""
val start = html.indexOf(marker) val start = html.indexOf(marker)
if (start < 0) { if (start < 0) {
debugBuf.append("HTML: no __NEXT_DATA__ found\n") if (BuildConfig.DEBUG) debugBuf.append("HTML: no __NEXT_DATA__ found\n")
return null return null
} }
val jsonStart = start + marker.length val jsonStart = start + marker.length
@@ -177,10 +180,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (jsonEnd < 0) return null if (jsonEnd < 0) return null
val nextData = JSONObject(html.substring(jsonStart, jsonEnd)) val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
val topKeys = nextData.keys().asSequence().toList() 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) tryExtractFromNextData(nextData)
} catch (e: Exception) { } catch (e: Exception) {
debugBuf.append("HTML scrape error: ${e.message}\n") if (BuildConfig.DEBUG) debugBuf.append("HTML scrape error: ${e.message}\n")
null null
} }
} }
@@ -200,7 +203,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
obj = (obj as? JSONObject)?.opt(key) obj = (obj as? JSONObject)?.opt(key)
} }
val o = obj as? JSONObject ?: continue 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()) val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
if (usage.hasRateLimitData) return usage if (usage.hasRateLimitData) return usage
} }
@@ -210,9 +213,9 @@ class UsageRepository(private val prefs: PreferencesManager) {
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> { private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
return try { return try {
val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute() 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) 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) if (body.isBlank() || body.startsWith("<")) return Pair(null, null)
val arr = JSONArray(body) val arr = JSONArray(body)
val org = arr.optJSONObject(0) ?: return Pair(null, null) 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", "application/json, */*")
.header("Accept-Language", "en-US,en;q=0.9") .header("Accept-Language", "en-US,en;q=0.9")
.header("Referer", "https://claude.ai/") .header("Referer", "https://claude.ai/")
.header("Cookie", cookies) .header("Cookie", cookies.replace("\r", "").replace("\n", ""))
.get() .get()
.build() .build()
@@ -14,6 +14,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.khodak.claudeusage.data.PreferencesManager import me.khodak.claudeusage.data.PreferencesManager
import java.util.concurrent.TimeUnit
class UsageUpdateWorker( class UsageUpdateWorker(
private val context: Context, private val context: Context,
@@ -77,21 +78,39 @@ class UsageUpdateWorker(
companion object { companion object {
private const val WORK_ONE_SHOT = "claude_oneshot" private const val WORK_ONE_SHOT = "claude_oneshot"
private const val WORK_PERIODIC = "claude_periodic"
private const val ALARM_CODE = 1001 private const val ALARM_CODE = 1001
private const val INTERVAL_MS = 5 * 60 * 1000L private const val INTERVAL_MS = 5 * 60 * 1000L
fun schedulePeriodicRefresh(context: Context) { fun schedulePeriodicRefresh(context: Context) {
// 5-min alarm for fast updates when the device is active/awake
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
am.setAndAllowWhileIdle( am.setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP, AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + INTERVAL_MS, SystemClock.elapsedRealtime() + INTERVAL_MS,
alarmIntent(context) alarmIntent(context)
) )
// WorkManager periodic as a Doze/background backup (Android 16 reliability).
// WorkManager uses JobScheduler which survives Doze; AlarmManager may be batched
// up to 75 min in Doze mode. KEEP = don't reset the 15-min timer on every alarm.
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_PERIODIC,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<UsageUpdateWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
)
} }
fun cancelPeriodicRefresh(context: Context) = fun cancelPeriodicRefresh(context: Context) {
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager) (context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
.cancel(alarmIntent(context)) .cancel(alarmIntent(context))
WorkManager.getInstance(context).cancelUniqueWork(WORK_PERIODIC)
}
fun triggerImmediateRefresh(context: Context) { fun triggerImmediateRefresh(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork( WorkManager.getInstance(context).enqueueUniqueWork(
@@ -10,11 +10,14 @@ class PreferencesManager(context: Context) {
private val gson = Gson() private val gson = Gson()
private val securePrefs = createSecurePrefs(context) private var usingFallbackPrefs = false
private val securePrefs = createSecurePrefs(context, onFallback = { usingFallbackPrefs = true })
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
fun saveCookies(cookies: String) { fun saveCookies(cookies: String) {
// Never store cookies in plain-text fallback prefs
if (usingFallbackPrefs) return
try { try {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply() securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
} catch (_: Exception) {} } catch (_: Exception) {}
@@ -78,34 +81,46 @@ class PreferencesManager(context: Context) {
private const val KEY_ACTIVE_WEEK = "active_week" private const val KEY_ACTIVE_WEEK = "active_week"
private const val KEY_ACTIVE_MASK = "active_mask" private const val KEY_ACTIVE_MASK = "active_mask"
private fun createSecurePrefs(context: Context): android.content.SharedPreferences { fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
// First attempt: normal open
return try { return try {
val masterKey = MasterKey.Builder(context) buildEncryptedPrefs(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) } catch (e: Exception) {
.build() if (isKeyPermanentlyInvalidated(e)) {
EncryptedSharedPreferences.create( // Key permanently gone (biometric/PIN changed) — must wipe; user must re-login.
context, "claude_secure", masterKey, try {
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, context.deleteSharedPreferences("claude_secure")
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM buildEncryptedPrefs(context)
) } catch (_: Exception) {
} catch (_: Exception) { onFallback?.invoke()
// Keystore key was invalidated (e.g., user added/changed biometrics or screen lock). context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
// Delete the stale encrypted file and recreate — user will need to re-login. }
try { } else {
context.deleteSharedPreferences("claude_secure") // Transient failure (Keystore busy, cold boot, screen locked during BG work).
val masterKey = MasterKey.Builder(context) // Do NOT delete the encrypted file — it will be readable next session.
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) onFallback?.invoke()
.build()
EncryptedSharedPreferences.create(
context, "claude_secure", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (_: Exception) {
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE) context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
} }
} }
} }
private fun buildEncryptedPrefs(context: Context): android.content.SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context, "claude_secure", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
private fun isKeyPermanentlyInvalidated(e: Exception): Boolean {
var t: Throwable? = e
while (t != null) {
if (t is android.security.keystore.KeyPermanentlyInvalidatedException) return true
t = t.cause
}
return false
}
} }
} }
+4 -4
View File
@@ -25,8 +25,8 @@
<ImageButton <ImageButton
android:id="@+id/btn_refresh" android:id="@+id/btn_refresh"
android:layout_width="24dp" android:layout_width="32dp"
android:layout_height="24dp" android:layout_height="32dp"
android:src="@android:drawable/ic_menu_rotate" android:src="@android:drawable/ic_menu_rotate"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:tint="#999999" android:tint="#999999"
@@ -87,7 +87,7 @@
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:text="" android:text=""
android:textColor="#666666" android:textColor="#666666"
android:textSize="9sp" /> android:textSize="11sp" />
<!-- 7-day window bar --> <!-- 7-day window bar -->
<LinearLayout <LinearLayout
@@ -135,7 +135,7 @@
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:text="" android:text=""
android:textColor="#666666" android:textColor="#666666"
android:textSize="9sp" /> android:textSize="11sp" />
<!-- Footer --> <!-- Footer -->
<LinearLayout <LinearLayout
@@ -25,8 +25,8 @@
<ImageButton <ImageButton
android:id="@+id/btn_refresh" android:id="@+id/btn_refresh"
android:layout_width="20dp" android:layout_width="28dp"
android:layout_height="20dp" android:layout_height="28dp"
android:src="@android:drawable/ic_menu_rotate" android:src="@android:drawable/ic_menu_rotate"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:contentDescription="Refresh" /> android:contentDescription="Refresh" />
@@ -66,7 +66,7 @@
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:text="" android:text=""
android:textColor="#555555" android:textColor="#555555"
android:textSize="8sp" /> android:textSize="10sp" />
</LinearLayout> </LinearLayout>
@@ -114,7 +114,7 @@
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:text="" android:text=""
android:textColor="#555555" android:textColor="#555555"
android:textSize="8sp" /> android:textSize="10sp" />
</LinearLayout> </LinearLayout>
+1 -1
View File
@@ -11,6 +11,6 @@
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000" android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_layout" android:initialLayout="@layout/widget_layout"
android:widgetCategory="home_screen|keyguard" android:widgetCategory="home_screen"
android:description="@string/widget_description" android:description="@string/widget_description"
android:previewLayout="@layout/widget_layout" /> android:previewLayout="@layout/widget_layout" />