7 Commits
Author SHA1 Message Date
amir 350cad21ab release: v1.21 (versionCode 22) — WebView hardening
Build APK / build (push) Successful in 1m52s
2026-07-10 10:00:55 +00:00
amir 451f1c8af8 security: harden login WebView — block file/content access and geolocation
The WebView holds the claude.ai session cookie; lock down every path a loaded
page could use to reach local files. No effect on the login/SSO flow.
2026-07-10 09:32:20 +00:00
amir 8ae6183901 feat: Material You colors, settings backup/restore, add LICENSE
Build APK / build (push) Successful in 1m33s
- Material You toggle: bars/rings use wallpaper-derived system accents on
  Android 12+ (ThemeColors.sessionFill/weeklyFill), Claude brand colors otherwise
- Settings backup/restore via SAF (export/import JSON); session cookie never
  included so backups are safe to store anywhere
- Add source-available LICENSE (mirrors SyncFlow's), with Anthropic trademark note

Built + installed on emulator, launches clean.
2026-06-21 03:24:16 +00:00
amir 7ca0a1db4d feat: notify user when claude.ai session expires (tap to re-auth)
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.
2026-06-21 02:52:22 +00:00
amir 206f572369 docs: add screenshots section + app screenshot
Build APK / build (push) Successful in 1m39s
2026-06-19 09:07:57 +00:00
amir 602c9e2cf5 release: publish v1.20 (ring widget style) as the official latest APK
Build APK / build (push) Successful in 1m30s
2026-06-12 11:01:01 +00:00
amir 6dd87a30d3 Merge pull request 'Ring widget style (Phase 1: selectable bars/rings)' (#1) from feature/rings-and-profiles into master
Build APK / build (push) Successful in 1m31s
2026-06-12 10:55:39 +00:00
14 changed files with 647 additions and 6 deletions
+45
View File
@@ -0,0 +1,45 @@
Claude Usage Widget Source-Available License
Copyright (c) 2026 Amir Khodak. All rights reserved.
This is NOT an OSI-approved open-source license. The source code is made
publicly viewable ("source-available"), but the rights granted are limited as
described below. Where this license is silent, all rights are reserved.
1. DEFINITIONS
"Software" means the Claude Usage Widget source code, assets, and
documentation in this repository. "You" means anyone other than the
copyright holder.
2. WHAT YOU MAY DO
a. View, read, and study the Software.
b. Clone or fork the repository for your own private, personal,
non-commercial use and experimentation.
c. Build the Software from source and run it on devices you personally own.
d. Submit contributions (pull requests) back to this repository; by doing so
you license your contribution to the copyright holder under these terms.
3. WHAT YOU MAY NOT DO (without the copyright holder's prior written permission)
a. Redistribute, publish, or make available the Software or any derivative
work — in source or binary/APK form — to any third party or app store
(including but not limited to Google Play, F-Droid, Amazon Appstore,
Gitea/GitHub releases, or any website).
b. Use the Software, in whole or in part, for any commercial purpose.
c. Sell, sublicense, rent, or offer the Software as a service.
d. Use the names, app identity ("Claude Usage Widget"), package identifier
("me.khodak.claudeusage"), logos, or signing keys of the original work.
e. Remove or alter this license or the copyright notice.
4. RESERVED RIGHTS
All publishing and distribution rights are reserved exclusively to the
copyright holder. Only the copyright holder may publish official builds.
5. TRADEMARK NOTE
"Claude" is a trademark of Anthropic. This is an unofficial, independent
utility and is not affiliated with or endorsed by Anthropic.
6. NO WARRANTY
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
DAMAGES, OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
To request permission for anything in section 3, contact the copyright holder.
+6
View File
@@ -23,6 +23,12 @@ Android home screen widget that shows your Claude Pro usage at a glance.
- 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
## Screenshots
<p align="center">
<img src="docs/screenshots/signin.png" width="240" alt="Sign in">
</p>
## Install ## Install
1. Download `claude-usage-widget.apk` from the [latest release](../../releases/latest) 1. Download `claude-usage-widget.apk` from the [latest release](../../releases/latest)
+2 -2
View File
@@ -24,8 +24,8 @@ android {
applicationId = "me.khodak.claudeusage" applicationId = "me.khodak.claudeusage"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 21 versionCode = 22
versionName = "1.20" versionName = "1.21"
} }
val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword") val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword")
@@ -102,6 +102,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
} }
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews { private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
val v = RemoteViews(context.packageName, R.layout.widget_layout_small) val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
applyPeak(v, showText = false) applyPeak(v, showText = false)
if (!prefs.isLoggedIn()) { if (!prefs.isLoggedIn()) {
@@ -174,6 +176,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
} }
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews { private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
val v = RemoteViews(context.packageName, R.layout.widget_layout) val v = RemoteViews(context.packageName, R.layout.widget_layout)
applyPeak(v, showText = true) applyPeak(v, showText = true)
@@ -264,6 +268,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
* circular [RingRenderer] gauge (percentage drawn in the center) instead of a bar. * circular [RingRenderer] gauge (percentage drawn in the center) instead of a bar.
*/ */
private fun buildRingViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews { private fun buildRingViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
val v = RemoteViews(context.packageName, R.layout.widget_layout_rings) val v = RemoteViews(context.packageName, R.layout.widget_layout_rings)
applyPeak(v, showText = true) applyPeak(v, showText = true)
@@ -75,6 +75,15 @@ class LoginActivity : AppCompatActivity() {
databaseEnabled = false databaseEnabled = false
javaScriptCanOpenWindowsAutomatically = false javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(false) setSupportMultipleWindows(false)
// Hardening: this WebView holds the claude.ai session, so lock down every path a
// page could use to reach the device. None of these affect the login flow.
allowFileAccess = false
allowContentAccess = false
@Suppress("DEPRECATION")
allowFileAccessFromFileURLs = false
@Suppress("DEPRECATION")
allowUniversalAccessFromFileURLs = false
setGeolocationEnabled(false)
// Standard Android Chrome UA — less suspicious than desktop // Standard Android Chrome UA — less suspicious than desktop
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " + userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " + "AppleWebKit/537.36 (KHTML, like Gecko) " +
@@ -30,6 +30,33 @@ class MainActivity : AppCompatActivity() {
private val notifPermLauncher = private val notifPermLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ } registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
/** SAF: pick a destination file and write the current settings JSON to it. */
private val exportLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
if (uri == null) return@registerForActivityResult
try {
contentResolver.openOutputStream(uri)?.use { it.write(prefs.exportSettings().toByteArray()) }
toast("Settings exported")
} catch (_: Exception) { toast("Export failed") }
}
/** SAF: pick a backup file and apply it. */
private val importLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@registerForActivityResult
try {
val json = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: ""
if (prefs.importSettings(json)) {
toast("Settings restored")
// Reflect restored prefs immediately: re-sync switches and redraw any widgets.
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
ClaudeUsageWidget.notifyDataChanged(this)
} else toast("Not a valid settings file")
} catch (_: Exception) { toast("Import failed") }
}
/** Live refresh loop that runs only while the app is in the foreground. */ /** Live refresh loop that runs only while the app is in the foreground. */
private var autoRefreshJob: Job? = null private var autoRefreshJob: Job? = null
@@ -67,6 +94,8 @@ class MainActivity : AppCompatActivity() {
setupNotificationSettings() setupNotificationSettings()
setupWidgetStyleSetting() setupWidgetStyleSetting()
setupMaterialYouSetting()
setupBackupRestore()
binding.btnDebug.setOnClickListener { binding.btnDebug.setOnClickListener {
if (binding.tvDebugInfo.visibility == android.view.View.GONE) { if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
@@ -122,6 +151,28 @@ class MainActivity : AppCompatActivity() {
} }
} }
/** Material You toggle — switches the bars/rings to wallpaper-derived accents (Android 12+). */
private fun setupMaterialYouSetting() {
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
binding.switchMaterialYou.setOnCheckedChangeListener { _, checked ->
prefs.setMaterialYou(checked)
ClaudeUsageWidget.notifyDataChanged(this)
}
}
/** Export/Import settings via the Storage Access Framework (no extra permissions needed). */
private fun setupBackupRestore() {
binding.btnExportSettings.setOnClickListener {
exportLauncher.launch("claude-usage-settings.json")
}
binding.btnImportSettings.setOnClickListener {
importLauncher.launch(arrayOf("application/json", "text/*", "*/*"))
}
}
private fun toast(msg: String) =
android.widget.Toast.makeText(this, msg, android.widget.Toast.LENGTH_SHORT).show()
private fun requestNotificationPermission() { private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
@@ -0,0 +1,310 @@
package me.khodak.claudeusage
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
import me.khodak.claudeusage.databinding.ActivityMainBinding
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var prefs: PreferencesManager
private lateinit var repo: UsageRepository
private val notifPermLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
/** SAF: pick a destination file and write the current settings JSON to it. */
private val exportLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
if (uri == null) return@registerForActivityResult
try {
contentResolver.openOutputStream(uri)?.use { it.write(prefs.exportSettings().toByteArray()) }
toast("Settings exported")
} catch (_: Exception) { toast("Export failed") }
}
/** SAF: pick a backup file and apply it. */
private val importLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@registerForActivityResult
try {
val json = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: ""
if (prefs.importSettings(json)) {
toast("Settings restored")
// Reflect restored prefs immediately: re-sync switches and redraw any widgets.
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
ClaudeUsageWidget.notifyDataChanged(this)
} else toast("Not a valid settings file")
} catch (_: Exception) { toast("Import failed") }
}
/** Live refresh loop that runs only while the app is in the foreground. */
private var autoRefreshJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = PreferencesManager(this)
repo = UsageRepository(prefs)
binding.btnLogin.setOnClickListener {
startActivity(Intent(this, LoginActivity::class.java))
}
// If already logged in, go straight to logged-in state without re-opening login
if (prefs.isLoggedIn()) {
updateUI(prefs.getUsageData())
}
binding.btnLogout.setOnClickListener {
prefs.clearSession()
updateUI(null)
}
binding.btnRefresh.setOnClickListener {
refreshUsage()
}
binding.tvWidgetHint.setOnClickListener {
startActivity(Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
})
}
setupNotificationSettings()
setupWidgetStyleSetting()
setupMaterialYouSetting()
setupBackupRestore()
binding.btnDebug.setOnClickListener {
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
binding.tvDebugInfo.visibility = android.view.View.VISIBLE
binding.btnDebug.text = "Hide API Debug"
} else {
binding.tvDebugInfo.visibility = android.view.View.GONE
binding.btnDebug.text = "Show API Debug"
}
}
}
override fun onResume() {
super.onResume()
updateUI(prefs.getUsageData()) // show cached instantly — never a blank screen
if (prefs.isLoggedIn()) startAutoRefresh()
}
override fun onPause() {
super.onPause()
autoRefreshJob?.cancel()
}
/** Refresh immediately on open, then every [REFRESH_INTERVAL_MS] while foregrounded. */
private fun startAutoRefresh() {
autoRefreshJob?.cancel()
autoRefreshJob = lifecycleScope.launch {
while (isActive) {
doRefresh(silent = true)
delay(REFRESH_INTERVAL_MS)
}
}
}
private fun setupNotificationSettings() {
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
binding.switchNotify.setOnCheckedChangeListener { _, checked ->
prefs.setNotifyEnabled(checked)
if (checked) requestNotificationPermission()
}
// 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()
}
/** Bars ⇄ Rings switch for the home-screen widget; redraws any placed widgets immediately. */
private fun setupWidgetStyleSetting() {
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
binding.switchRingStyle.setOnCheckedChangeListener { _, checked ->
prefs.setWidgetStyle(if (checked) PreferencesManager.STYLE_RINGS else PreferencesManager.STYLE_BARS)
ClaudeUsageWidget.notifyDataChanged(this)
}
}
/** Material You toggle — redraws placed widgets so the new accent applies immediately. */
private fun setupMaterialYouSetting() {
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
binding.switchMaterialYou.setOnCheckedChangeListener { _, checked ->
prefs.setMaterialYou(checked)
ClaudeUsageWidget.notifyDataChanged(this)
updateUI(prefs.getUsageData()) // refresh the in-app bars too
}
}
private fun setupBackupRestore() {
binding.btnExportSettings.setOnClickListener { exportLauncher.launch("claude-usage-settings.json") }
binding.btnImportSettings.setOnClickListener { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }
}
private fun toast(msg: String) =
android.widget.Toast.makeText(this, msg, android.widget.Toast.LENGTH_SHORT).show()
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)
}
}
/** Manual "Refresh Now" button — shows the spinner. */
private fun refreshUsage() {
lifecycleScope.launch { doRefresh(silent = false) }
}
private suspend fun doRefresh(silent: Boolean) {
if (!silent) {
binding.btnRefresh.isEnabled = false
binding.progressIndicator.visibility = View.VISIBLE
}
val fresh = try {
repo.fetchUsage()
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
UsageData(errorMessage = "Network error")
}
// Preserve last-good data so a failed/partial fetch never blanks the UI or widget.
val merged = fresh.mergedWith(prefs.getUsageData())
prefs.saveUsageData(merged)
prefs.recordHistory(fresh)
// Note: alerts fire only from the background worker, not here — no point pinging you
// with a notification while you're already looking at the app.
updateUI(merged)
ClaudeUsageWidget.notifyDataChanged(this) // opening the app refreshes the widget too
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
binding.tvDebugInfo.text = repo.lastDebugInfo
}
if (!silent) {
binding.btnRefresh.isEnabled = true
binding.progressIndicator.visibility = View.GONE
}
}
private fun updateUI(data: UsageData?) {
val loggedIn = prefs.isLoggedIn()
binding.layoutLoggedOut.visibility = if (loggedIn) View.GONE else View.VISIBLE
binding.layoutLoggedIn.visibility = if (loggedIn) View.VISIBLE else View.GONE
if (!loggedIn || data == null) return
// ── Peak-hours row ───────────────────────────────────────────────────
val peak = PeakHours.state()
binding.imgPeak.setColorFilter(if (peak.active) PEAK_ON else PEAK_OFF)
binding.tvPeak.setTextColor(if (peak.active) PEAK_ON else 0xFFAAAAAA.toInt())
binding.tvPeak.text = if (peak.active)
"🔥 Peak hours — ${peak.endsInLabel} · ${peak.windowLabel}"
else
"Off-peak · ${peak.windowLabel}"
// ── Session (5-hour) bar — no pace marker ────────────────────────────
binding.barSession.setImageBitmap(
BarRenderer.render(data.progressPercent, null, SESSION_FILL, null)
)
// ── Weekly (7-day) bar — single-color pace marker ────────────────────
if (data.weeklyUtilization >= 0f) {
val wPct = data.weeklyUtilization.toInt()
val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
binding.barWeekly.setImageBitmap(
BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, if (weeklyPace != null) MARKER_COLOR else null)
)
binding.tvWeeklyUsage.text = "$wPct% this week"
} else {
binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null))
binding.tvWeeklyUsage.text = "—"
}
// Pace text removed per design — bars carry the signal.
binding.tvSessionPace.visibility = View.GONE
binding.tvWeeklyPace.visibility = View.GONE
binding.tvUsage.text = when {
data.fiveHourUtilization >= 0f -> {
val pct = data.fiveHourUtilization.toInt()
"$pct% of limit used"
}
data.messagesLimit > 0 && data.effectiveUsed >= 0 ->
"${data.effectiveUsed} of ${data.messagesLimit} messages used"
data.effectiveRemaining >= 0 && data.messagesLimit > 0 ->
"${data.effectiveRemaining} of ${data.messagesLimit} remaining"
data.effectiveRemaining >= 0 ->
"${data.effectiveRemaining} messages remaining"
else -> "Usage data unavailable"
}
binding.tvReset.text = formatReset(data.effectiveResetEpoch)
binding.tvWeeklyReset.text = formatResetDay(data.weeklyResetAtEpoch)
binding.tvUpdated.text = if (data.lastUpdated > 0)
"Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}"
else ""
binding.tvError.text = data.errorMessage
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
binding.historyChart.setData(prefs.getHistory())
}
private fun formatReset(epochMs: Long): String {
if (epochMs <= 0) return ""
val now = System.currentTimeMillis()
if (epochMs <= now) return "Resets soon"
val diffH = TimeUnit.MILLISECONDS.toHours(epochMs - now)
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
return when {
diffH < 24 -> "Resets at $timeStr"
diffH < 48 -> "Resets tomorrow $timeStr"
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
}
}
/** Weekly reset shown with the weekday name ("Resets Friday 3:00 PM"), never "tomorrow". */
private fun formatResetDay(epochMs: Long): String {
if (epochMs <= 0) return ""
if (epochMs <= System.currentTimeMillis()) return "Resets soon"
val day = SimpleDateFormat("EEEE", Locale.US).format(Date(epochMs))
val date = SimpleDateFormat("MMM d", Locale.US).format(Date(epochMs))
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
return "Resets $day, $date · $timeStr"
}
companion object {
private const val REFRESH_INTERVAL_MS = 30_000L // live refresh cadence while app is open
private const val SESSION_FILL = 0xFFCC785C.toInt()
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color weekly pace marker
private const val PEAK_ON = 0xFFCC785C.toInt()
private const val PEAK_OFF = 0xFF666666.toInt()
}
}
@@ -23,8 +23,42 @@ import me.khodak.claudeusage.data.UsageData
object Notifier { object Notifier {
private const val CHANNEL_ID = "usage_alerts" 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) 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) { fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) {
if (!prefs.isNotifyEnabled()) return if (!prefs.isNotifyEnabled()) return
val mgr = NotificationManagerCompat.from(context) val mgr = NotificationManagerCompat.from(context)
@@ -94,6 +128,17 @@ object Notifier {
private fun notifId(metric: String, levelIndex: Int) = private fun notifId(metric: String, levelIndex: Int) =
2000 + (if (metric == "weekly") 10 else 0) + levelIndex 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) { private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -0,0 +1,30 @@
package me.khodak.claudeusage
import android.content.Context
import android.os.Build
import me.khodak.claudeusage.data.PreferencesManager
/**
* Central source of the two accent colors used by the bars/rings (session + weekly).
*
* Default = the Claude brand palette. When the user enables Material You *and* the device is
* Android 12+ (where the wallpaper-derived `system_accent*` palette exists), the fills switch to
* the dynamic system accents so the widget matches the rest of the home screen. On older devices
* the toggle is a no-op and the brand colors are always used.
*/
object ThemeColors {
const val SESSION_DEFAULT = 0xFFCC785C.toInt() // Claude orange
const val WEEKLY_DEFAULT = 0xFF7B8FCC.toInt() // Claude periwinkle
private fun dynamicAvailable(prefs: PreferencesManager): Boolean =
prefs.isMaterialYou() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
fun sessionFill(context: Context, prefs: PreferencesManager): Int =
if (dynamicAvailable(prefs)) context.getColor(android.R.color.system_accent1_300)
else SESSION_DEFAULT
fun weeklyFill(context: Context, prefs: PreferencesManager): Int =
if (dynamicAvailable(prefs)) context.getColor(android.R.color.system_accent2_300)
else WEEKLY_DEFAULT
}
@@ -31,10 +31,16 @@ class UsageUpdateWorker(
val animJob = launch { rotateRefreshIcon() } val animJob = launch { rotateRefreshIcon() }
try { try {
val data = UsageRepository(prefs).fetchUsage() val data = UsageRepository(prefs).fetchUsage()
// 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. // Preserve last-good data so a failed/partial fetch never blanks the widget.
prefs.saveUsageData(data.mergedWith(prefs.getUsageData())) prefs.saveUsageData(data.mergedWith(prefs.getUsageData()))
prefs.recordHistory(data) // history records only fresh readings prefs.recordHistory(data) // history records only fresh readings
Notifier.checkAndNotify(context, prefs, data) Notifier.checkAndNotify(context, prefs, data)
}
} catch (_: Exception) {} } catch (_: Exception) {}
animJob.cancel() animJob.cancel()
animJob.join() animJob.join()
@@ -143,6 +143,42 @@ class PreferencesManager(context: Context) {
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true) fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply() fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
// ── Material You (dynamic color) ─────────────────────────────────────────
/** When on (and Android 12+), bars/rings use the wallpaper-derived system accents. */
fun isMaterialYou(): Boolean = prefs.getBoolean(KEY_MATERIAL_YOU, false)
fun setMaterialYou(v: Boolean) = prefs.edit().putBoolean(KEY_MATERIAL_YOU, v).apply()
// ── Settings backup / restore ────────────────────────────────────────────
/**
* Serialize the user-tunable settings (NOT the session cookie or usage data) to JSON, so a
* reinstall doesn't lose preferences. Cookies are deliberately excluded — they're sensitive and
* device-bound, so a backup file stays safe to store anywhere.
*/
fun exportSettings(): String = gson.toJson(
mapOf(
"version" to 1,
KEY_NOTIFY_ENABLED to isNotifyEnabled(),
KEY_WIDGET_STYLE to getWidgetStyle(),
KEY_MATERIAL_YOU to isMaterialYou()
)
)
/** Apply a settings JSON produced by [exportSettings]. Returns true if it parsed and applied. */
fun importSettings(json: String): Boolean = try {
@Suppress("UNCHECKED_CAST")
val map = gson.fromJson(json, Map::class.java) as Map<String, Any?>
val e = prefs.edit()
(map[KEY_NOTIFY_ENABLED] as? Boolean)?.let { e.putBoolean(KEY_NOTIFY_ENABLED, it) }
(map[KEY_MATERIAL_YOU] as? Boolean)?.let { e.putBoolean(KEY_MATERIAL_YOU, it) }
(map[KEY_WIDGET_STYLE] as? String)?.let {
if (it == STYLE_BARS || it == STYLE_RINGS) e.putString(KEY_WIDGET_STYLE, it)
}
e.apply()
true
} catch (_: Exception) { false }
/** /**
* Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed, * Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
* cleared when usage drops back below it — so each level fires once per window. * cleared when usage drops back below it — so each level fires once per window.
@@ -162,6 +198,7 @@ class PreferencesManager(context: Context) {
private const val KEY_NOTIFY_ENABLED = "notify_enabled" private const val KEY_NOTIFY_ENABLED = "notify_enabled"
private const val KEY_AUTH_FAILS = "auth_fail_count" private const val KEY_AUTH_FAILS = "auth_fail_count"
private const val KEY_WIDGET_STYLE = "widget_style" private const val KEY_WIDGET_STYLE = "widget_style"
private const val KEY_MATERIAL_YOU = "material_you"
const val STYLE_BARS = "bars" const val STYLE_BARS = "bars"
const val STYLE_RINGS = "rings" const val STYLE_RINGS = "rings"
+96
View File
@@ -333,6 +333,48 @@
</LinearLayout> </LinearLayout>
<!-- Material You 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="MATERIAL YOU COLORS"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchMaterialYou"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Use your wallpaper-derived system accent colors for the bars/rings (Android 12+). Off = Claude brand colors."
android:textColor="#AAAAAA"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
</LinearLayout>
<!-- Notifications card --> <!-- Notifications card -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -375,6 +417,60 @@
</LinearLayout> </LinearLayout>
<!-- Backup & Restore 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="match_parent"
android:layout_height="wrap_content"
android:text="BACKUP &amp; RESTORE"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Save your settings (widget style, Material You, alerts) to a file so a reinstall doesn't lose them. The Claude session cookie is never included."
android:textColor="#AAAAAA"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="14dp">
<Button
android:id="@+id/btnExportSettings"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:text="Export"
style="?attr/materialButtonOutlinedStyle" />
<Button
android:id="@+id/btnImportSettings"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="Import"
style="?attr/materialButtonOutlinedStyle" />
</LinearLayout>
</LinearLayout>
<Button <Button
android:id="@+id/btnRefresh" android:id="@+id/btnRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.