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:
@@ -15,6 +15,10 @@ Android home screen widget that shows your Claude Pro usage at a glance.
|
|||||||
yellow → orange → red → purple (burning way too fast), with an "X% over/under pace" label.
|
yellow → orange → red → purple (burning way too fast), with an "X% over/under pace" label.
|
||||||
- **Peak-hours indicator** — a Claude burst icon that lights up 🔥 during Anthropic's peak window
|
- **Peak-hours indicator** — a Claude burst icon that lights up 🔥 during Anthropic's peak window
|
||||||
(5–11 AM Pacific, Mon–Fri), when tokens burn faster, with a countdown to the window close.
|
(5–11 AM Pacific, Mon–Fri), when tokens burn faster, with a countdown to the window close.
|
||||||
|
- **Usage history chart** — the app plots your session and weekly utilization over the past 7 days,
|
||||||
|
so you can see your consumption trend, not just the current snapshot.
|
||||||
|
- **Usage alerts** — opt-in notifications when session or weekly usage crosses a threshold you set
|
||||||
|
(sliders, 50–100%). Each alert fires at most once per limit window, so you're never spammed.
|
||||||
- Tap the widget to open the app; tap ⟳ to force-refresh
|
- Tap the widget to open the app; tap ⟳ to force-refresh
|
||||||
- Responsive: works as 4×1 (compact) or 4×2 (full)
|
- Responsive: works as 4×1 (compact) or 4×2 (full)
|
||||||
- Auto-refreshes every 5 minutes in the background
|
- Auto-refreshes every 5 minutes in the background
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "me.khodak.claudeusage"
|
applicationId = "me.khodak.claudeusage"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 14
|
versionCode = 15
|
||||||
versionName = "1.13"
|
versionName = "1.14"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import me.khodak.claudeusage.data.UsageSnapshot
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight line chart for usage history, hand-drawn on Canvas to stay dependency-free
|
||||||
|
* and consistent with [BarRenderer]. Plots session (orange) and weekly (blue) utilization
|
||||||
|
* 0-100% over time. Gaps longer than [GAP_MS] are not connected, so an offline stretch
|
||||||
|
* shows as a break rather than a misleading straight line.
|
||||||
|
*/
|
||||||
|
class HistoryChartView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyle: Int = 0
|
||||||
|
) : View(context, attrs, defStyle) {
|
||||||
|
|
||||||
|
private var points: List<UsageSnapshot> = emptyList()
|
||||||
|
|
||||||
|
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = 0xFF2A2A2A.toInt(); strokeWidth = dp(1f); style = Paint.Style.STROKE
|
||||||
|
}
|
||||||
|
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = 0xFF666666.toInt(); textSize = sp(10f)
|
||||||
|
}
|
||||||
|
private val sessionPaint = linePaint(0xFFCC785C.toInt())
|
||||||
|
private val weeklyPaint = linePaint(0xFF7B8FCC.toInt())
|
||||||
|
private val emptyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = 0xFF666666.toInt(); textSize = sp(13f); textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
|
||||||
|
private val padL = dp(28f)
|
||||||
|
private val padR = dp(8f)
|
||||||
|
private val padT = dp(10f)
|
||||||
|
private val padB = dp(18f)
|
||||||
|
|
||||||
|
fun setData(data: List<UsageSnapshot>) {
|
||||||
|
points = data
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
val left = padL
|
||||||
|
val top = padT
|
||||||
|
val right = width - padR
|
||||||
|
val bottom = height - padB
|
||||||
|
if (right <= left || bottom <= top) return
|
||||||
|
|
||||||
|
// Y gridlines + labels at 0 / 50 / 100%
|
||||||
|
for (pct in intArrayOf(0, 50, 100)) {
|
||||||
|
val y = bottom - (pct / 100f) * (bottom - top)
|
||||||
|
canvas.drawLine(left, y, right, y, gridPaint)
|
||||||
|
canvas.drawText("$pct", dp(4f), y + sp(3.5f), labelPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
val plottable = points.filter { it.sessionPct >= 0f || it.weeklyPct >= 0f }
|
||||||
|
if (plottable.size < 2) {
|
||||||
|
canvas.drawText(
|
||||||
|
"Collecting history… check back later",
|
||||||
|
(left + right) / 2f, (top + bottom) / 2f, emptyPaint
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val tMin = plottable.first().epochMs
|
||||||
|
val tMax = plottable.last().epochMs
|
||||||
|
val tSpan = (tMax - tMin).coerceAtLeast(1L)
|
||||||
|
|
||||||
|
fun x(ms: Long) = left + (ms - tMin).toFloat() / tSpan * (right - left)
|
||||||
|
fun y(pct: Float) = bottom - (pct / 100f) * (bottom - top)
|
||||||
|
|
||||||
|
drawSeries(canvas, plottable, sessionPaint, ::x, ::y) { it.sessionPct }
|
||||||
|
drawSeries(canvas, plottable, weeklyPaint, ::x, ::y) { it.weeklyPct }
|
||||||
|
|
||||||
|
// X axis time labels (start … end)
|
||||||
|
val fmt = SimpleDateFormat("MMM d, h a", Locale.US)
|
||||||
|
labelPaint.textAlign = Paint.Align.LEFT
|
||||||
|
canvas.drawText(fmt.format(Date(tMin)), left, height - dp(4f), labelPaint)
|
||||||
|
labelPaint.textAlign = Paint.Align.RIGHT
|
||||||
|
canvas.drawText(fmt.format(Date(tMax)), right, height - dp(4f), labelPaint)
|
||||||
|
labelPaint.textAlign = Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun drawSeries(
|
||||||
|
canvas: Canvas,
|
||||||
|
data: List<UsageSnapshot>,
|
||||||
|
paint: Paint,
|
||||||
|
x: (Long) -> Float,
|
||||||
|
y: (Float) -> Float,
|
||||||
|
value: (UsageSnapshot) -> Float
|
||||||
|
) {
|
||||||
|
val path = Path()
|
||||||
|
var penDown = false
|
||||||
|
var prevMs = 0L
|
||||||
|
for (p in data) {
|
||||||
|
val v = value(p)
|
||||||
|
if (v < 0f) { penDown = false; continue }
|
||||||
|
val px = x(p.epochMs); val py = y(v)
|
||||||
|
if (!penDown || p.epochMs - prevMs > GAP_MS) {
|
||||||
|
path.moveTo(px, py)
|
||||||
|
} else {
|
||||||
|
path.lineTo(px, py)
|
||||||
|
}
|
||||||
|
penDown = true
|
||||||
|
prevMs = p.epochMs
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linePaint(c: Int) = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = c; strokeWidth = dp(2f); style = Paint.Style.STROKE
|
||||||
|
strokeJoin = Paint.Join.ROUND; strokeCap = Paint.Cap.ROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dp(v: Float) = v * resources.displayMetrics.density
|
||||||
|
private fun sp(v: Float) = v * resources.displayMetrics.scaledDensity
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Don't connect points separated by more than ~35 min (a missed refresh cycle or two).
|
||||||
|
private const val GAP_MS = 35 * 60 * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package me.khodak.claudeusage
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.khodak.claudeusage.data.PreferencesManager
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
@@ -19,6 +24,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var prefs: PreferencesManager
|
private lateinit var prefs: PreferencesManager
|
||||||
private lateinit var repo: UsageRepository
|
private lateinit var repo: UsageRepository
|
||||||
|
|
||||||
|
private val notifPermLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
@@ -51,6 +59,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupNotificationSettings()
|
||||||
|
|
||||||
binding.btnDebug.setOnClickListener {
|
binding.btnDebug.setOnClickListener {
|
||||||
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
||||||
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
|
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
|
||||||
@@ -75,6 +85,54 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setupNotificationSettings() {
|
||||||
|
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
|
||||||
|
binding.sliderSession.value = prefs.getSessionThreshold().toFloat().coerceIn(50f, 100f)
|
||||||
|
binding.sliderWeekly.value = prefs.getWeeklyThreshold().toFloat().coerceIn(50f, 100f)
|
||||||
|
applyThresholdLabels()
|
||||||
|
applyNotifyControlsEnabled(prefs.isNotifyEnabled())
|
||||||
|
|
||||||
|
binding.switchNotify.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.setNotifyEnabled(checked)
|
||||||
|
applyNotifyControlsEnabled(checked)
|
||||||
|
if (checked) requestNotificationPermission()
|
||||||
|
}
|
||||||
|
binding.sliderSession.addOnChangeListener { _, value, _ ->
|
||||||
|
prefs.setSessionThreshold(value.toInt())
|
||||||
|
applyThresholdLabels()
|
||||||
|
}
|
||||||
|
binding.sliderWeekly.addOnChangeListener { _, value, _ ->
|
||||||
|
prefs.setWeeklyThreshold(value.toInt())
|
||||||
|
applyThresholdLabels()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alerts default on, so prompt for the runtime permission once on first launch
|
||||||
|
// (a user who never toggles the switch would otherwise never be asked).
|
||||||
|
if (prefs.isNotifyEnabled()) requestNotificationPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyThresholdLabels() {
|
||||||
|
binding.tvSessionThreshLabel.text = "Session alert at ${prefs.getSessionThreshold()}%"
|
||||||
|
binding.tvWeeklyThreshLabel.text = "Weekly alert at ${prefs.getWeeklyThreshold()}%"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyNotifyControlsEnabled(enabled: Boolean) {
|
||||||
|
binding.sliderSession.isEnabled = enabled
|
||||||
|
binding.sliderWeekly.isEnabled = enabled
|
||||||
|
val alpha = if (enabled) 1f else 0.4f
|
||||||
|
binding.tvSessionThreshLabel.alpha = alpha
|
||||||
|
binding.tvWeeklyThreshLabel.alpha = alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notifPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshUsage() {
|
private fun refreshUsage() {
|
||||||
binding.btnRefresh.isEnabled = false
|
binding.btnRefresh.isEnabled = false
|
||||||
binding.progressIndicator.visibility = View.VISIBLE
|
binding.progressIndicator.visibility = View.VISIBLE
|
||||||
@@ -87,6 +145,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
?: UsageData(errorMessage = "Network error")
|
?: UsageData(errorMessage = "Network error")
|
||||||
}
|
}
|
||||||
prefs.saveUsageData(data)
|
prefs.saveUsageData(data)
|
||||||
|
prefs.recordHistory(data)
|
||||||
|
Notifier.checkAndNotify(this@MainActivity, prefs, data)
|
||||||
updateUI(data)
|
updateUI(data)
|
||||||
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
|
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
|
||||||
binding.tvDebugInfo.text = repo.lastDebugInfo
|
binding.tvDebugInfo.text = repo.lastDebugInfo
|
||||||
@@ -157,6 +217,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
binding.tvError.text = data.errorMessage
|
binding.tvError.text = data.errorMessage
|
||||||
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
|
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.historyChart.setData(prefs.getHistory())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatReset(epochMs: Long): String {
|
private fun formatReset(epochMs: Long): String {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import me.khodak.claudeusage.data.UsageData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a notification when session or weekly utilization crosses the user's threshold.
|
||||||
|
* Each metric fires at most once per limit window: we remember the reset-epoch we alerted
|
||||||
|
* for, and only re-arm when that window rolls over (epoch changes) — so the user isn't
|
||||||
|
* pinged every 5 minutes while sitting above the line.
|
||||||
|
*/
|
||||||
|
object Notifier {
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "usage_alerts"
|
||||||
|
private const val SESSION_NOTIF_ID = 2001
|
||||||
|
private const val WEEKLY_NOTIF_ID = 2002
|
||||||
|
|
||||||
|
fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) {
|
||||||
|
if (!prefs.isNotifyEnabled()) return
|
||||||
|
val mgr = NotificationManagerCompat.from(context)
|
||||||
|
if (!mgr.areNotificationsEnabled()) return // OS-level or runtime permission off
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val session = data.fiveHourUtilization
|
||||||
|
if (session >= 0f) {
|
||||||
|
maybeFire(
|
||||||
|
context, mgr, prefs,
|
||||||
|
key = "session",
|
||||||
|
util = session.toInt(),
|
||||||
|
threshold = prefs.getSessionThreshold(),
|
||||||
|
resetEpoch = data.effectiveResetEpoch,
|
||||||
|
notifId = SESSION_NOTIF_ID,
|
||||||
|
title = "Session usage at ${session.toInt()}%",
|
||||||
|
body = "Your current 5-hour window is nearly used up."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val weekly = data.weeklyUtilization
|
||||||
|
if (weekly >= 0f) {
|
||||||
|
maybeFire(
|
||||||
|
context, mgr, prefs,
|
||||||
|
key = "weekly",
|
||||||
|
util = weekly.toInt(),
|
||||||
|
threshold = prefs.getWeeklyThreshold(),
|
||||||
|
resetEpoch = data.weeklyResetAtEpoch,
|
||||||
|
notifId = WEEKLY_NOTIF_ID,
|
||||||
|
title = "Weekly usage at ${weekly.toInt()}%",
|
||||||
|
body = "You're approaching your weekly Claude limit."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maybeFire(
|
||||||
|
context: Context,
|
||||||
|
mgr: NotificationManagerCompat,
|
||||||
|
prefs: PreferencesManager,
|
||||||
|
key: String,
|
||||||
|
util: Int,
|
||||||
|
threshold: Int,
|
||||||
|
resetEpoch: Long,
|
||||||
|
notifId: Int,
|
||||||
|
title: String,
|
||||||
|
body: String
|
||||||
|
) {
|
||||||
|
if (util < threshold) return
|
||||||
|
// Already alerted for this exact window? Skip. (resetEpoch<=0 means "unknown window" —
|
||||||
|
// fall back to a coarse marker so we still alert once instead of never.)
|
||||||
|
val windowMarker = if (resetEpoch > 0) resetEpoch else 1L
|
||||||
|
if (prefs.getNotifiedResetEpoch(key) == windowMarker) return
|
||||||
|
|
||||||
|
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_claude_burst)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(openAppIntent(context))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
mgr.notify(notifId, notif)
|
||||||
|
prefs.setNotifiedResetEpoch(key, windowMarker)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
// Notifications revoked between the check and post — nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
mgr.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID, "Usage alerts", NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply { description = "Alerts when you approach your Claude usage limits" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAppIntent(context: Context): PendingIntent {
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ class UsageUpdateWorker(
|
|||||||
try {
|
try {
|
||||||
val data = UsageRepository(prefs).fetchUsage()
|
val data = UsageRepository(prefs).fetchUsage()
|
||||||
prefs.saveUsageData(data)
|
prefs.saveUsageData(data)
|
||||||
|
prefs.recordHistory(data)
|
||||||
|
Notifier.checkAndNotify(context, prefs, data)
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
animJob.cancel()
|
animJob.cancel()
|
||||||
animJob.join()
|
animJob.join()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
class PreferencesManager(context: Context) {
|
class PreferencesManager(context: Context) {
|
||||||
@@ -73,6 +74,61 @@ class PreferencesManager(context: Context) {
|
|||||||
|
|
||||||
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
|
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 {
|
companion object {
|
||||||
private const val KEY_COOKIES = "session_cookies"
|
private const val KEY_COOKIES = "session_cookies"
|
||||||
private const val KEY_ORG_ID = "org_id"
|
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_USAGE_DATA = "usage_data"
|
||||||
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 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 {
|
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package me.khodak.claudeusage.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single point-in-time reading of usage, stored for the in-app history chart.
|
||||||
|
* Percentages are 0-100; -1 means "no reading for this metric at this time".
|
||||||
|
*/
|
||||||
|
data class UsageSnapshot(
|
||||||
|
val epochMs: Long = 0L,
|
||||||
|
val sessionPct: Float = -1f,
|
||||||
|
val weeklyPct: Float = -1f
|
||||||
|
)
|
||||||
@@ -229,6 +229,137 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- History card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="HISTORY"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<me.khodak.claudeusage.HistoryChartView
|
||||||
|
android:id="@+id/historyChart"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="140dp"
|
||||||
|
android:layout_marginTop="12dp" />
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="3dp"
|
||||||
|
android:background="#CC785C" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:text="Session"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="3dp"
|
||||||
|
android:background="#7B8FCC" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:text="Weekly"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Notifications card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="USAGE ALERTS"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchNotify"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSessionThreshLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="14dp"
|
||||||
|
android:text="Session alert at 90%"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.slider.Slider
|
||||||
|
android:id="@+id/sliderSession"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:valueFrom="50"
|
||||||
|
android:valueTo="100"
|
||||||
|
android:stepSize="5"
|
||||||
|
android:value="90" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWeeklyThreshLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Weekly alert at 85%"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.slider.Slider
|
||||||
|
android:id="@+id/sliderWeekly"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:valueFrom="50"
|
||||||
|
android:valueTo="100"
|
||||||
|
android:stepSize="5"
|
||||||
|
android:value="85" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btnRefresh"
|
android:id="@+id/btnRefresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
Reference in New Issue
Block a user