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 ) } }