58e3a0fcd7
Build APK / build (push) Successful in 1m52s
recordHistory() only ever stored fiveHourUtilization/weeklyUtilization, but fetchUsage() returned early with message-count data — before calling the /usage utilization endpoint — whenever the org JSON carried a message limit. So utilization was never populated and the history chart stayed stuck on 'Collecting history…'. The prior chart fix only corrected the throttle. - UsageRepository: always attempt /usage (preferred signal that drives the weekly bar + history); fall back to org message-count data only when utilization is unavailable. - UsageData.sessionReadingPct: utilization preferred, message-count % fallback, -1f when no reading — so message-only accounts also build history. - PreferencesManager.recordHistory: record the session line from sessionReadingPct instead of utilization-only. - UsageDataTest: cover sessionReadingPct.
217 lines
9.7 KiB
Kotlin
217 lines
9.7 KiB
Kotlin
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 com.google.gson.reflect.TypeToken
|
|
import java.util.Calendar
|
|
|
|
class PreferencesManager(context: Context) {
|
|
|
|
private val gson = Gson()
|
|
|
|
private var usingFallbackPrefs = false
|
|
private val securePrefs = createSecurePrefs(context, onFallback = { usingFallbackPrefs = true })
|
|
|
|
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
|
|
|
|
fun saveCookies(cookies: String) {
|
|
// Never store cookies in plain-text fallback prefs
|
|
if (usingFallbackPrefs) return
|
|
try {
|
|
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
|
|
} catch (_: Exception) {}
|
|
}
|
|
|
|
fun getCookies(): String? {
|
|
// Cookies are never written in fallback (plaintext) mode — make that invariant explicit on
|
|
// the read side too, so any future write that bypasses the guard still can't surface here.
|
|
if (usingFallbackPrefs) return null
|
|
return try {
|
|
securePrefs.getString(KEY_COOKIES, null)
|
|
} catch (_: Exception) { null }
|
|
}
|
|
|
|
fun clearSession() {
|
|
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
|
|
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).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()
|
|
|
|
// Consecutive 401/403 counter — we only clear the session after several in a row, so a
|
|
// single transient auth failure (e.g. a Cloudflare challenge) doesn't log the user out.
|
|
fun getAuthFailCount(): Int = prefs.getInt(KEY_AUTH_FAILS, 0)
|
|
fun incAuthFailCount(): Int {
|
|
val n = getAuthFailCount() + 1
|
|
prefs.edit().putInt(KEY_AUTH_FAILS, n).apply()
|
|
return n
|
|
}
|
|
fun resetAuthFailCount() {
|
|
if (getAuthFailCount() != 0) prefs.edit().putInt(KEY_AUTH_FAILS, 0).apply()
|
|
}
|
|
|
|
// ── Usage history (for the in-app chart) ─────────────────────────────────
|
|
|
|
/**
|
|
* Append a history point if [data] carries a real reading.
|
|
* The session line uses [UsageData.sessionReadingPct] (utilization preferred, message-count
|
|
* progress as fallback) so accounts that only expose message counts still build history.
|
|
* De-duplicates rapid double-fires (manual refresh + background worker landing
|
|
* together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and
|
|
* prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS].
|
|
*/
|
|
fun recordHistory(data: UsageData) {
|
|
val sessionPct = data.sessionReadingPct
|
|
if (sessionPct < 0f && data.weeklyUtilization < 0f) return
|
|
val now = System.currentTimeMillis()
|
|
val history = getHistory().toMutableList()
|
|
// Throttle to at most one point per MIN_HISTORY_GAP_MS by SKIPPING a too-soon reading.
|
|
// (Previously we deleted the last point and re-added in place — but the foreground loop
|
|
// refreshes every 30s, well inside this 2-min window, so history could never grow past a
|
|
// single point while the app was open and the chart stayed on "Collecting history…".)
|
|
if (!shouldRecordHistory(history.lastOrNull()?.epochMs, now, MIN_HISTORY_GAP_MS)) return
|
|
history.add(
|
|
UsageSnapshot(
|
|
epochMs = now,
|
|
sessionPct = sessionPct,
|
|
weeklyPct = data.weeklyUtilization
|
|
)
|
|
)
|
|
val cutoff = now - HISTORY_RETENTION_MS
|
|
val pruned = history.filter { it.epochMs >= cutoff }
|
|
.takeLast(MAX_HISTORY_POINTS)
|
|
prefs.edit().putString(KEY_HISTORY, gson.toJson(pruned)).apply()
|
|
}
|
|
|
|
fun getHistory(): List<UsageSnapshot> {
|
|
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
|
|
return try {
|
|
val type = object : TypeToken<List<UsageSnapshot>>() {}.type
|
|
gson.fromJson<List<UsageSnapshot>>(json, type) ?: emptyList()
|
|
} catch (e: Exception) { emptyList() }
|
|
}
|
|
|
|
// ── Notification settings ────────────────────────────────────────────────
|
|
|
|
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
|
|
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
|
|
|
|
/**
|
|
* Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
|
|
* cleared when usage drops back below it — so each level fires once per window.
|
|
*/
|
|
fun wasNotified(key: String): Boolean = prefs.getBoolean("notified_$key", false)
|
|
fun setNotified(key: String, v: Boolean) =
|
|
prefs.edit().putBoolean("notified_$key", v).apply()
|
|
|
|
companion object {
|
|
private const val KEY_COOKIES = "session_cookies"
|
|
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"
|
|
private const val KEY_HISTORY = "usage_history"
|
|
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
|
|
private const val KEY_AUTH_FAILS = "auth_fail_count"
|
|
|
|
internal const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
|
|
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
|
|
private const val MAX_HISTORY_POINTS = 600
|
|
|
|
/**
|
|
* Pure throttle decision for [recordHistory]: should a new point be appended?
|
|
* Returns false only when a previous point exists ([lastEpochMs] != null) AND the gap to
|
|
* [now] is below [minGapMs]; true otherwise (including the first-ever point, lastEpochMs == null).
|
|
* No Android dependencies — kept separate so the throttle rule is unit-testable.
|
|
*/
|
|
internal fun shouldRecordHistory(lastEpochMs: Long?, now: Long, minGapMs: Long): Boolean {
|
|
if (lastEpochMs == null) return true
|
|
return now - lastEpochMs >= minGapMs
|
|
}
|
|
|
|
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
|
return try {
|
|
buildEncryptedPrefs(context)
|
|
} catch (e: Exception) {
|
|
if (isKeyPermanentlyInvalidated(e)) {
|
|
// Key permanently gone (biometric/PIN changed) — must wipe; user must re-login.
|
|
try {
|
|
context.deleteSharedPreferences("claude_secure")
|
|
buildEncryptedPrefs(context)
|
|
} catch (_: Exception) {
|
|
onFallback?.invoke()
|
|
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
|
|
}
|
|
} else {
|
|
// Transient failure (Keystore busy, cold boot, screen locked during BG work).
|
|
// Do NOT delete the encrypted file — it will be readable next session.
|
|
onFallback?.invoke()
|
|
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
|
|
}
|
|
}
|
|
}
|