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 { val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList() return try { val type = object : TypeToken>() {}.type gson.fromJson>(json, type) ?: emptyList() } catch (e: Exception) { emptyList() } } // ── Widget style ───────────────────────────────────────────────────────── /** Home-screen widget visual style: [STYLE_BARS] (default) or [STYLE_RINGS]. */ fun getWidgetStyle(): String = prefs.getString(KEY_WIDGET_STYLE, STYLE_BARS) ?: STYLE_BARS fun setWidgetStyle(style: String) = prefs.edit().putString(KEY_WIDGET_STYLE, style).apply() // ── 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" private const val KEY_WIDGET_STYLE = "widget_style" const val STYLE_BARS = "bars" const val STYLE_RINGS = "rings" 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 } } }