0520f0dc5e
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>
116 lines
4.4 KiB
Kotlin
116 lines
4.4 KiB
Kotlin
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
|
|
)
|
|
}
|
|
}
|