From 7ca0a1db4dd9b3d612e9de0355d70e8715628e65 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Sun, 21 Jun 2026 02:52:22 +0000 Subject: [PATCH] feat: notify user when claude.ai session expires (tap to re-auth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../java/me/khodak/claudeusage/Notifier.kt | 45 +++++++++++++++++++ .../khodak/claudeusage/UsageUpdateWorker.kt | 14 ++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/me/khodak/claudeusage/Notifier.kt b/app/src/main/java/me/khodak/claudeusage/Notifier.kt index 40c3f76..0624ac3 100644 --- a/app/src/main/java/me/khodak/claudeusage/Notifier.kt +++ b/app/src/main/java/me/khodak/claudeusage/Notifier.kt @@ -23,8 +23,42 @@ import me.khodak.claudeusage.data.UsageData 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) @@ -94,6 +128,17 @@ object Notifier { 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 diff --git a/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt b/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt index de837eb..53de1da 100644 --- a/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt +++ b/app/src/main/java/me/khodak/claudeusage/UsageUpdateWorker.kt @@ -31,10 +31,16 @@ class UsageUpdateWorker( val animJob = launch { rotateRefreshIcon() } try { val data = UsageRepository(prefs).fetchUsage() - // Preserve last-good data so a failed/partial fetch never blanks the widget. - prefs.saveUsageData(data.mergedWith(prefs.getUsageData())) - prefs.recordHistory(data) // history records only fresh readings - Notifier.checkAndNotify(context, prefs, data) + // We passed the isLoggedIn() guard above, so if the session is gone now the + // cookie just expired this cycle — tell the user once (next cycle returns early). + if (!prefs.isLoggedIn()) { + Notifier.notifySessionExpired(context) + } else { + // Preserve last-good data so a failed/partial fetch never blanks the widget. + prefs.saveUsageData(data.mergedWith(prefs.getUsageData())) + prefs.recordHistory(data) // history records only fresh readings + Notifier.checkAndNotify(context, prefs, data) + } } catch (_: Exception) {} animJob.cancel() animJob.join()