Build APK / build (push) Successful in 1m35s
Detection already cleared the session after 3x 401/403 but did so silently — the widget would just go stale. Now fires a one-time HIGH-priority notification on its own 'Sign-in alerts' channel that opens LoginActivity for re-paste.
162 lines
6.7 KiB
Kotlin
162 lines
6.7 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
|
|
|
|
/**
|
|
* Fires exactly two alerts per metric — one at 90% and one at 100% — and no more.
|
|
*
|
|
* Uses hysteresis, not the API's reset timestamp: each level fires once when usage first
|
|
* crosses it, and only re-arms after usage drops back below that level (i.e. a new window /
|
|
* usage reset). That keeps it quiet even if the reset time drifts between fetches. Only the
|
|
* background worker calls this — never the in-app refresh loop — so you're not pinged while
|
|
* you're already looking at the app.
|
|
*/
|
|
object Notifier {
|
|
|
|
private const val CHANNEL_ID = "usage_alerts"
|
|
private const val AUTH_CHANNEL_ID = "session_alerts"
|
|
private const val AUTH_NOTIF_ID = 3000
|
|
private val LEVELS = intArrayOf(90, 100)
|
|
|
|
/**
|
|
* Fired once when the saved claude.ai session is cleared after repeated 401/403s — without
|
|
* this the widget would just silently go stale and the user would have to notice the dead
|
|
* data themselves. Tapping opens LoginActivity straight to the cookie re-paste flow. Posts on
|
|
* its own channel so it can be muted independently of usage alerts, and is not gated on the
|
|
* usage-alert toggle: a dead widget is a functional failure the user needs to know about.
|
|
*/
|
|
fun notifySessionExpired(context: Context) {
|
|
val mgr = NotificationManagerCompat.from(context)
|
|
if (!mgr.areNotificationsEnabled()) return
|
|
ensureAuthChannel(context)
|
|
val intent = Intent(context, LoginActivity::class.java)
|
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
val pi = PendingIntent.getActivity(
|
|
context, 1, intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
val notif = NotificationCompat.Builder(context, AUTH_CHANNEL_ID)
|
|
.setSmallIcon(R.drawable.ic_claude_burst)
|
|
.setContentTitle("Claude sign-in expired")
|
|
.setContentText("Tap to sign in again — the widget can't update until you do.")
|
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
.setAutoCancel(true)
|
|
.setContentIntent(pi)
|
|
.build()
|
|
try {
|
|
mgr.notify(AUTH_NOTIF_ID, notif)
|
|
} catch (_: SecurityException) {
|
|
// Notifications revoked between the check and post — nothing to do.
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
evaluate(context, mgr, prefs, "session", data.fiveHourUtilization, "5-hour session")
|
|
evaluate(context, mgr, prefs, "weekly", data.weeklyUtilization, "weekly")
|
|
}
|
|
|
|
private fun evaluate(
|
|
context: Context,
|
|
mgr: NotificationManagerCompat,
|
|
prefs: PreferencesManager,
|
|
metric: String,
|
|
utilization: Float,
|
|
label: String
|
|
) {
|
|
if (utilization < 0f) return
|
|
val util = utilization.toInt()
|
|
for ((i, level) in LEVELS.withIndex()) {
|
|
val key = "${metric}_$level"
|
|
if (util >= level) {
|
|
if (!prefs.wasNotified(key)) {
|
|
fire(context, mgr, notifId(metric, i), level, label, util)
|
|
prefs.setNotified(key, true)
|
|
}
|
|
} else if (prefs.wasNotified(key)) {
|
|
prefs.setNotified(key, false) // dropped below the line → re-arm for next window
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun fire(
|
|
context: Context,
|
|
mgr: NotificationManagerCompat,
|
|
notifId: Int,
|
|
level: Int,
|
|
label: String,
|
|
util: Int
|
|
) {
|
|
val title: String
|
|
val body: String
|
|
if (level >= 100) {
|
|
title = "${label.replaceFirstChar { it.uppercase() }} limit reached"
|
|
body = "You've hit 100% of your $label limit."
|
|
} else {
|
|
title = "${label.replaceFirstChar { it.uppercase() }} at $util%"
|
|
body = "You're at $level% of your $label limit."
|
|
}
|
|
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)
|
|
} catch (_: SecurityException) {
|
|
// Notifications revoked between the check and post — nothing to do.
|
|
}
|
|
}
|
|
|
|
// Stable id per (metric, level) so re-posts replace rather than stack.
|
|
private fun notifId(metric: String, levelIndex: Int) =
|
|
2000 + (if (metric == "weekly") 10 else 0) + levelIndex
|
|
|
|
private fun ensureAuthChannel(context: Context) {
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
if (mgr.getNotificationChannel(AUTH_CHANNEL_ID) != null) return
|
|
mgr.createNotificationChannel(
|
|
NotificationChannel(
|
|
AUTH_CHANNEL_ID, "Sign-in alerts", NotificationManager.IMPORTANCE_HIGH
|
|
).apply { description = "Tells you when your claude.ai session expires and needs re-auth" }
|
|
)
|
|
}
|
|
|
|
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 at 90% and 100% of 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
|
|
)
|
|
}
|
|
}
|