v1.14: usage history chart + threshold notifications

Add an in-app 7-day history chart and opt-in usage alerts, the two
features requested from the macOS Claude-Usage-Tracker that map cleanly
to an Android widget app.

History:
- UsageSnapshot model; PreferencesManager records session/weekly
  utilization on every refresh (7-day retention, <=600 points, collapses
  readings under 2 min apart to avoid worker+manual double-logging).
- HistoryChartView: dependency-free Canvas line chart (session/weekly,
  0/50/100% gridlines), breaks the line across >35-min gaps.
- New HISTORY card with chart + legend.

Notifications:
- Notifier posts when session/weekly crosses a user threshold, at most
  once per limit window (keyed on reset-epoch, re-arms on rollover).
- USAGE ALERTS card: enable switch + session/weekly sliders (50-100%,
  defaults 90/85). POST_NOTIFICATIONS permission + runtime request.
- Wired into the existing 5-min background worker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:49:47 +00:00
parent ae0f466f50
commit 0520f0dc5e
10 changed files with 523 additions and 2 deletions
@@ -4,6 +4,7 @@ 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) {
@@ -73,6 +74,61 @@ class PreferencesManager(context: Context) {
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
// ── Usage history (for the in-app chart) ─────────────────────────────────
/**
* Append a history point if [data] carries a real utilization reading.
* 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) {
if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return
val now = System.currentTimeMillis()
val history = getHistory().toMutableList()
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) {
history.removeAt(history.size - 1) // collapse near-simultaneous readings
}
history.add(
UsageSnapshot(
epochMs = now,
sessionPct = data.fiveHourUtilization,
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()
fun getSessionThreshold(): Int = prefs.getInt(KEY_NOTIFY_SESSION_PCT, 90)
fun setSessionThreshold(pct: Int) = prefs.edit().putInt(KEY_NOTIFY_SESSION_PCT, pct).apply()
fun getWeeklyThreshold(): Int = prefs.getInt(KEY_NOTIFY_WEEKLY_PCT, 85)
fun setWeeklyThreshold(pct: Int) = prefs.edit().putInt(KEY_NOTIFY_WEEKLY_PCT, pct).apply()
/**
* Tracks the reset-epoch a metric was last notified for, so we alert at most once
* per limit window. When the window rolls over (reset epoch changes), it re-arms.
*/
fun getNotifiedResetEpoch(key: String): Long = prefs.getLong("notified_$key", 0L)
fun setNotifiedResetEpoch(key: String, epoch: Long) =
prefs.edit().putLong("notified_$key", epoch).apply()
companion object {
private const val KEY_COOKIES = "session_cookies"
private const val KEY_ORG_ID = "org_id"
@@ -80,6 +136,14 @@ class PreferencesManager(context: Context) {
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_NOTIFY_SESSION_PCT = "notify_session_pct"
private const val KEY_NOTIFY_WEEKLY_PCT = "notify_weekly_pct"
private 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
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
return try {