Compare commits

..

11 Commits

Author SHA1 Message Date
amir b15dcf16d7 releases/latest: add v1.12 source zip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:42:45 +00:00
amir d6d7daa30f v1.12: restore reset-time labels, bolder pace tick
Fix v1.11 regression where the pace tag overwrote the reset-time label
(gone entirely on the small widget). The widget reset lines now show the
actual reset time again; pace is conveyed by the bar tick.

Make the pace tick more prominent: wider core + white halo so it stands
out against any fill color.

versionCode 13 / versionName 1.12. Includes rebuilt signed release APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:42:45 +00:00
amir 4f9edd5e63 releases/latest: add v1.11 source zip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:31:10 +00:00
amir 838b10f2fd v1.11: pace markers + peak-hours burst icon
Add a pace tick to each usage bar showing where you should be to finish
at 100% by reset, color-coded by projected tier, plus a Claude burst icon
that lights up during Anthropic peak hours (5-11 AM PT, Mon-Fri). Bars now
rendered as bitmaps so the same renderer drives both the widget and the app.

New: PaceCalc, PeakHours, BarRenderer, ic_claude_burst drawable.
versionCode 12 / versionName 1.11. Includes rebuilt signed release APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:31:10 +00:00
Amir f7444a06eb Add app icon to README header 2026-06-04 01:27:48 +00:00
amir 6f3c5e6ea1 v1.10: all widget text white and bold
Make every TextView in both widget layouts fully white (#FFFFFF) with
textStyle=bold — SESSION/WEEKLY labels, session/weekly sub-labels,
status line, and last-updated timestamp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:16:24 +00:00
amir 895a4ff3cd releases/latest: add v1.9 source zip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:43:57 +00:00
amir e2747597e2 releases/latest: add v1.9 APK
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:40:14 +00:00
amir 6934017519 security: restrict network to system CAs, tighten WebView capabilities; v1.9
- AndroidManifest: add networkSecurityConfig to explicitly trust only system
  CAs, preventing user-installed CA cert MITM attacks on claude.ai sessions
- LoginActivity: set javaScriptCanOpenWindowsAutomatically=false (not needed
  for claude.ai login) and databaseEnabled=false (deprecated WebSQL)
- build.gradle.kts: enable buildConfig generation (required for
  BuildConfig.DEBUG guards already used in UsageRepository)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:00:01 +00:00
amir ee68b11ad0 v1.9: fix Android 16 status loss, bigger widget icons/fonts, security fixes
Android 16 bug: EncryptedSharedPreferences threw on ANY exception (Keystore
busy during screen-lock/BG wakeup) and the code deleted the encrypted prefs
file on any failure, permanently erasing session cookies. Now only
KeyPermanentlyInvalidatedException (biometric/PIN change) triggers delete;
transient failures preserve the file for the next session.

Also prevents saving cookies to plain-text fallback prefs if encrypted prefs
are unavailable.

WorkManager periodic (15 min, requires network) added alongside AlarmManager
as a Doze-mode backup for Android 16, where inexact alarms can be batched up
to 75 min.

UI: sync icon 24→32dp (large widget), 20→28dp (small); reset-time font
9→11sp (large), 8→10sp (small).

Security:
- All Log.d response-body and URL-bearing logs gated behind BuildConfig.DEBUG
- Cookie header value stripped of CRLF to prevent HTTP header injection
- LoginActivity coroutine migrated from bare CoroutineScope to lifecycleScope
- Widget removed from keyguard (lock-screen) category — usage data is sensitive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 03:15:44 +00:00
amir 695c54f03c v1.8: fix black screen on resume and crash on sync
- onResume() no longer triggers a refresh every time — only fetches when
  data is >5 min stale, so returning to app shows cached data instantly
  without a loading spinner
- Fix CancellationException being swallowed by catch(Exception) in
  refreshUsage(), which caused updates to run on a destroyed activity
- EncryptedSharedPreferences key invalidation (caused by enabling/changing
  biometrics or screen lock) now deletes the stale encrypted file and
  recreates it cleanly, rather than silently using empty fallback prefs
- Wrap all securePrefs read/write ops in try-catch so a mid-session
  Keystore failure degrades gracefully instead of crashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:59:10 +00:00
21 changed files with 580 additions and 126 deletions
+9
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="icon.png" width="108" alt="Claude Usage Widget">
</p>
# Claude Usage Widget # Claude Usage Widget
Android home screen widget that shows your Claude Pro usage at a glance. Android home screen widget that shows your Claude Pro usage at a glance.
@@ -6,6 +10,11 @@ Android home screen widget that shows your Claude Pro usage at a glance.
- **SESSION** bar — current 5-hour window utilization with reset time - **SESSION** bar — current 5-hour window utilization with reset time
- **WEEKLY** bar — 7-day rolling usage with reset time - **WEEKLY** bar — 7-day rolling usage with reset time
- **Pace marker** — a colored tick on each bar shows where you *should be* right now to finish at
exactly 100% by reset. Tick color grades your projection: green (way under budget) → teal →
yellow → orange → red → purple (burning way too fast), with an "X% over/under pace" label.
- **Peak-hours indicator** — a Claude burst icon that lights up 🔥 during Anthropic's peak window
(511 AM Pacific, MonFri), when tokens burn faster, with a countdown to the window close.
- Tap the widget to open the app; tap ⟳ to force-refresh - Tap the widget to open the app; tap ⟳ to force-refresh
- 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
+3 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "me.khodak.claudeusage" applicationId = "me.khodak.claudeusage"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 8 versionCode = 13
versionName = "1.7" versionName = "1.12"
} }
signingConfigs { signingConfigs {
@@ -44,6 +44,7 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true
} }
} }
+1
View File
@@ -11,6 +11,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ClaudeUsage" android:theme="@style/Theme.ClaudeUsage"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="false">
<activity <activity
@@ -0,0 +1,63 @@
package me.khodak.claudeusage
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
/**
* Draws a usage bar as a Bitmap: rounded track + rounded fill + an optional vertical pace tick.
* Used both in the home-screen widget (setImageViewBitmap) and the in-app card, so the two render
* identically. Bitmaps are rendered at a fixed width and stretched horizontally via ImageView
* scaleType=fitXY; height is fixed so there is no vertical distortion.
*/
object BarRenderer {
private const val TRACK_COLOR = 0xFF252525.toInt()
fun render(
usedPct: Int,
markerPct: Int?,
fillColor: Int,
tierColor: Int?,
wPx: Int = 500,
hPx: Int = 14,
cornerPx: Float = 7f
): Bitmap {
val bmp = Bitmap.createBitmap(wPx, hPx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Track
paint.color = TRACK_COLOR
val full = RectF(0f, 0f, wPx.toFloat(), hPx.toFloat())
canvas.drawRoundRect(full, cornerPx, cornerPx, paint)
// Fill
val pct = usedPct.coerceIn(0, 100)
if (pct > 0) {
paint.color = fillColor
val fillW = (wPx * pct / 100f).coerceAtLeast(cornerPx * 2)
val fill = RectF(0f, 0f, fillW, hPx.toFloat())
canvas.drawRoundRect(fill, cornerPx, cornerPx, paint)
}
// Pace tick — "where you should be right now".
// Wider than before and wrapped in a white halo so it stands out on any fill color.
if (markerPct != null && tierColor != null) {
val m = markerPct.coerceIn(0, 100)
val coreW = (wPx * 0.022f).coerceIn(9f, 14f) // tier-colored core
val halo = coreW * 0.6f // white outline on each side
var x = wPx * m / 100f
x = x.coerceIn(coreW / 2f + halo, wPx - coreW / 2f - halo)
// white halo
paint.color = 0xFFFFFFFF.toInt()
canvas.drawRect(x - coreW / 2f - halo, 0f, x + coreW / 2f + halo, hPx.toFloat(), paint)
// tier-colored core
paint.color = tierColor
canvas.drawRect(x - coreW / 2f, 0f, x + coreW / 2f, hPx.toFloat(), paint)
}
return bmp
}
}
@@ -87,14 +87,15 @@ class ClaudeUsageWidget : AppWidgetProvider() {
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews { private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val v = RemoteViews(context.packageName, R.layout.widget_layout_small) val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
applyPeak(v, showText = false)
if (!prefs.isLoggedIn()) { if (!prefs.isLoggedIn()) {
v.setTextViewText(R.id.tv_session_value, "") v.setTextViewText(R.id.tv_session_value, "")
v.setTextViewText(R.id.tv_session_label, "Not signed in") v.setTextViewText(R.id.tv_session_label, "Not signed in")
v.setTextViewText(R.id.tv_weekly_value, "") v.setTextViewText(R.id.tv_weekly_value, "")
v.setTextViewText(R.id.tv_weekly_label, "") v.setTextViewText(R.id.tv_weekly_label, "")
v.setTextViewText(R.id.tv_status, "") v.setTextViewText(R.id.tv_status, "")
v.setProgressBar(R.id.progress_bar, 100, 0, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false) v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
return v return v
} }
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
@@ -103,15 +104,16 @@ class ClaudeUsageWidget : AppWidgetProvider() {
when { when {
hasUtilization -> { hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt() val pct = apiData!!.fiveHourUtilization.toInt()
val pace = PaceCalc.compute(apiData.fiveHourUtilization, apiData.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
v.setTextViewText(R.id.tv_session_value, "$pct%") v.setTextViewText(R.id.tv_session_value, "$pct%")
v.setProgressBar(R.id.progress_bar, 100, pct, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, pace?.markerPct, SESSION_FILL, pace?.tierColor))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch)) v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
} }
hasApiMessages -> { hasApiMessages -> {
val rem = apiData!!.effectiveRemaining val rem = apiData!!.effectiveRemaining
val lim = apiData.messagesLimit val lim = apiData.messagesLimit
v.setTextViewText(R.id.tv_session_value, "$rem/$lim") v.setTextViewText(R.id.tv_session_value, "$rem/$lim")
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch)) v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
} }
sessionStart > 0 -> { sessionStart > 0 -> {
@@ -119,25 +121,26 @@ class ClaudeUsageWidget : AppWidgetProvider() {
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs) val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60 val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
v.setTextViewText(R.id.tv_session_value, if (h > 0) "${h}h${m}m" else "${m}m") v.setTextViewText(R.id.tv_session_value, if (h > 0) "${h}h${m}m" else "${m}m")
v.setProgressBar(R.id.progress_bar, 100, 0, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "active") v.setTextViewText(R.id.tv_session_label, "active")
} }
else -> { else -> {
v.setTextViewText(R.id.tv_session_value, "") v.setTextViewText(R.id.tv_session_value, "")
v.setProgressBar(R.id.progress_bar, 100, 0, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "") v.setTextViewText(R.id.tv_session_label, "")
} }
} }
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
if (hasWeekly) { if (hasWeekly) {
val wPct = apiData!!.weeklyUtilization.toInt() val wPct = apiData!!.weeklyUtilization.toInt()
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
v.setTextViewText(R.id.tv_weekly_value, "$wPct%") v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false) v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, pace?.tierColor))
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch)) v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
} else { } else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask()) val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d") v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false) v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
v.setTextViewText(R.id.tv_weekly_label, "") v.setTextViewText(R.id.tv_weekly_label, "")
} }
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
@@ -157,6 +160,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews { private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
val v = RemoteViews(context.packageName, R.layout.widget_layout) val v = RemoteViews(context.packageName, R.layout.widget_layout)
applyPeak(v, showText = true)
// ── Not logged in ──────────────────────────────────────────────── // ── Not logged in ────────────────────────────────────────────────
if (!prefs.isLoggedIn()) { if (!prefs.isLoggedIn()) {
@@ -165,7 +169,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
v.setTextViewText(R.id.tv_weekly_value, "") v.setTextViewText(R.id.tv_weekly_value, "")
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in") v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
v.setTextViewText(R.id.tv_status, "") v.setTextViewText(R.id.tv_status, "")
v.setProgressBar(R.id.progress_bar, 100, 0, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
return v return v
} }
@@ -177,15 +182,16 @@ class ClaudeUsageWidget : AppWidgetProvider() {
when { when {
hasUtilization -> { hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt() val pct = apiData!!.fiveHourUtilization.toInt()
val pace = PaceCalc.compute(apiData.fiveHourUtilization, apiData.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
v.setTextViewText(R.id.tv_session_value, "$pct%") v.setTextViewText(R.id.tv_session_value, "$pct%")
v.setProgressBar(R.id.progress_bar, 100, pct, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, pace?.markerPct, SESSION_FILL, pace?.tierColor))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch)) v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
} }
hasApiMessages -> { hasApiMessages -> {
val rem = apiData!!.effectiveRemaining val rem = apiData!!.effectiveRemaining
val lim = apiData.messagesLimit val lim = apiData.messagesLimit
v.setTextViewText(R.id.tv_session_value, "$rem / $lim") v.setTextViewText(R.id.tv_session_value, "$rem / $lim")
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch)) v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
} }
sessionStart > 0 -> { sessionStart > 0 -> {
@@ -194,12 +200,12 @@ class ClaudeUsageWidget : AppWidgetProvider() {
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60 val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
val display = if (h > 0) "${h}h ${m}m" else if (m > 0) "${m}m" else "Just started" val display = if (h > 0) "${h}h ${m}m" else if (m > 0) "${m}m" else "Just started"
v.setTextViewText(R.id.tv_session_value, display) v.setTextViewText(R.id.tv_session_value, display)
v.setProgressBar(R.id.progress_bar, 100, 0, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "session active") v.setTextViewText(R.id.tv_session_label, "session active")
} }
else -> { else -> {
v.setTextViewText(R.id.tv_session_value, "") v.setTextViewText(R.id.tv_session_value, "")
v.setProgressBar(R.id.progress_bar, 100, 0, false) v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "") v.setTextViewText(R.id.tv_session_label, "")
} }
} }
@@ -208,13 +214,14 @@ class ClaudeUsageWidget : AppWidgetProvider() {
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
if (hasWeekly) { if (hasWeekly) {
val wPct = apiData!!.weeklyUtilization.toInt() val wPct = apiData!!.weeklyUtilization.toInt()
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
v.setTextViewText(R.id.tv_weekly_value, "$wPct%") v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false) v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, pace?.tierColor))
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch)) v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
} else { } else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask()) val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d") v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false) v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
v.setTextViewText(R.id.tv_weekly_label, "active this week") v.setTextViewText(R.id.tv_weekly_label, "active this week")
} }
@@ -238,6 +245,19 @@ class ClaudeUsageWidget : AppWidgetProvider() {
return v return v
} }
private const val SESSION_FILL = 0xFFCC785C.toInt()
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
/** Tints the header burst icon and (optionally) the PEAK text by current peak state. */
private fun applyPeak(v: RemoteViews, showText: Boolean) {
val peak = PeakHours.isPeak()
v.setInt(R.id.img_peak, "setColorFilter",
if (peak) 0xFFCC785C.toInt() else 0xFF666666.toInt())
if (showText) {
v.setTextViewText(R.id.tv_peak, if (peak) "🔥 PEAK" else "")
}
}
private fun formatReset(epochMs: Long): String { private fun formatReset(epochMs: Long): String {
if (epochMs <= 0) return "" if (epochMs <= 0) return ""
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -8,7 +8,7 @@ import android.view.View
import android.webkit.* import android.webkit.*
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.CoroutineScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager import me.khodak.claudeusage.data.PreferencesManager
@@ -72,8 +72,8 @@ class LoginActivity : AppCompatActivity() {
settings.apply { settings.apply {
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
databaseEnabled = true databaseEnabled = false
javaScriptCanOpenWindowsAutomatically = true javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(false) setSupportMultipleWindows(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) " +
@@ -145,7 +145,7 @@ class LoginActivity : AppCompatActivity() {
UsageUpdateWorker.triggerImmediateRefresh(this) UsageUpdateWorker.triggerImmediateRefresh(this)
UsageUpdateWorker.schedulePeriodicRefresh(this) UsageUpdateWorker.schedulePeriodicRefresh(this)
CoroutineScope(Dispatchers.IO).launch { lifecycleScope.launch(Dispatchers.IO) {
try { try {
val data = UsageRepository(prefs).fetchUsage() val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data) prefs.saveUsageData(data)
@@ -68,7 +68,10 @@ class MainActivity : AppCompatActivity() {
val cached = prefs.getUsageData() val cached = prefs.getUsageData()
updateUI(cached) updateUI(cached)
if (prefs.isLoggedIn()) { if (prefs.isLoggedIn()) {
refreshUsage() val staleMs = 5 * 60 * 1000L
if ((cached?.lastUpdated ?: 0L) < System.currentTimeMillis() - staleMs) {
refreshUsage()
}
} }
} }
@@ -79,6 +82,7 @@ class MainActivity : AppCompatActivity() {
val data = try { val data = try {
repo.fetchUsage() repo.fetchUsage()
} catch (e: Exception) { } catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
prefs.getUsageData()?.copy(errorMessage = "Network error") prefs.getUsageData()?.copy(errorMessage = "Network error")
?: UsageData(errorMessage = "Network error") ?: UsageData(errorMessage = "Network error")
} }
@@ -100,15 +104,38 @@ class MainActivity : AppCompatActivity() {
if (!loggedIn || data == null) return if (!loggedIn || data == null) return
binding.progressBar.progress = data.progressPercent // ── 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 + pace ──────────────────────────────────────
val sessionPct = data.progressPercent
val sessionPace = if (data.fiveHourUtilization >= 0f)
PaceCalc.compute(data.fiveHourUtilization, data.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS)
else null
binding.barSession.setImageBitmap(
BarRenderer.render(sessionPct, sessionPace?.markerPct, SESSION_FILL, sessionPace?.tierColor)
)
binding.tvSessionPace.text = paceSentence(data.fiveHourUtilization, sessionPace)
// ── Weekly (7-day) bar + pace ────────────────────────────────────────
if (data.weeklyUtilization >= 0f) { if (data.weeklyUtilization >= 0f) {
val wPct = data.weeklyUtilization.toInt() val wPct = data.weeklyUtilization.toInt()
binding.progressBarWeekly.progress = wPct val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
binding.barWeekly.setImageBitmap(
BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, weeklyPace?.tierColor)
)
binding.tvWeeklyUsage.text = "$wPct% this week" binding.tvWeeklyUsage.text = "$wPct% this week"
binding.tvWeeklyPace.text = paceSentence(data.weeklyUtilization, weeklyPace)
} else { } else {
binding.progressBarWeekly.progress = 0 binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null))
binding.tvWeeklyUsage.text = "" binding.tvWeeklyUsage.text = ""
binding.tvWeeklyPace.text = ""
} }
binding.tvUsage.text = when { binding.tvUsage.text = when {
@@ -135,6 +162,12 @@ class MainActivity : AppCompatActivity() {
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
} }
/** "20% over pace · will likely hit limit" — empty when no projection is available yet. */
private fun paceSentence(usedPct: Float, pace: PaceCalc.Pace?): String {
if (pace == null) return ""
return "${PaceCalc.shortTag(usedPct, pace)} · ${pace.label}"
}
private fun formatReset(epochMs: Long): String { private fun formatReset(epochMs: Long): String {
if (epochMs <= 0) return "" if (epochMs <= 0) return ""
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -147,4 +180,11 @@ class MainActivity : AppCompatActivity() {
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr" else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
} }
} }
companion object {
private const val SESSION_FILL = 0xFFCC785C.toInt()
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
private const val PEAK_ON = 0xFFCC785C.toInt()
private const val PEAK_OFF = 0xFF666666.toInt()
}
} }
@@ -0,0 +1,75 @@
package me.khodak.claudeusage
/**
* Pace projection mirroring hamed-elfayome/Claude-Usage-Tracker's PaceStatus.
*
* Idea: if you keep your current burn rate, where do you land at reset?
* projected = (usedPct / 100) / elapsedFraction
* The "where you should be right now" marker sits at elapsedFraction*100 on the bar —
* i.e. the position that finishes at exactly 100% by reset.
*/
object PaceCalc {
const val SESSION_WINDOW_MS = 5L * 60 * 60 * 1000 // 5 hours
const val WEEKLY_WINDOW_MS = 7L * 24 * 60 * 60 * 1000 // 7 days
// System-style tier colors (ARGB).
private const val GREEN = 0xFF34C759.toInt()
private const val TEAL = 0xFF30B0C7.toInt()
private const val YELLOW = 0xFFFFCC00.toInt()
private const val ORANGE = 0xFFFF9500.toInt()
private const val RED = 0xFFFF3B30.toInt()
private const val PURPLE = 0xFFAF52DE.toInt()
data class Pace(
val markerPct: Int, // where you "should be" now (0..100)
val projected: Float, // projected end-of-period fraction (1.0 == exactly 100%)
val tierColor: Int, // ARGB color for the tier
val label: String // short interpretation, e.g. "sustainable pace"
)
/**
* @param usedPct current utilization 0..100
* @param resetEpoch epoch millis when this window resets (period end)
* @param windowMs total length of the window
* @return Pace, or null if we can't meaningfully project yet
*/
fun compute(
usedPct: Float,
resetEpoch: Long,
windowMs: Long,
now: Long = System.currentTimeMillis()
): Pace? {
if (resetEpoch <= 0L || usedPct < 0f) return null
val start = resetEpoch - windowMs
val elapsed = now - start
val elapsedFraction = elapsed.toFloat() / windowMs.toFloat()
// Match reference: need >=3% elapsed and period not complete.
if (elapsedFraction < 0.03f || elapsedFraction >= 1.0f) return null
val markerPct = (elapsedFraction * 100f).toInt().coerceIn(0, 100)
if (usedPct == 0f) {
return Pace(markerPct, 0f, GREEN, "way under budget")
}
val projected = (usedPct / 100f) / elapsedFraction
val (color, label) = when {
projected < 0.50f -> GREEN to "way under budget"
projected < 0.75f -> TEAL to "sustainable pace"
projected < 0.90f -> YELLOW to "starting to push it"
projected < 1.00f -> ORANGE to "will likely hit limit"
projected < 1.20f -> RED to "on track to exceed"
else -> PURPLE to "burning way too fast"
}
return Pace(markerPct, projected, color, label)
}
/** Short tag for the widget reset line, e.g. "8% under pace" / "20% over pace" / "on pace". */
fun shortTag(usedPct: Float, pace: Pace): String {
val delta = usedPct.toInt() - pace.markerPct
return when {
delta >= 2 -> "$delta% over pace"
delta <= -2 -> "${-delta}% under pace"
else -> "on pace"
}
}
}
@@ -0,0 +1,80 @@
package me.khodak.claudeusage
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
/**
* Anthropic peak usage window, mirroring hamed-elfayome/Claude-Usage-Tracker's PeakHoursService:
* 5:0011:00 AM America/Los_Angeles, MondayFriday. Tokens burn faster during this window.
*/
object PeakHours {
private const val PEAK_START_HOUR = 5 // 5 AM PT inclusive
private const val PEAK_END_HOUR = 11 // 11 AM PT exclusive
private val PT: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
data class PeakState(
val active: Boolean,
val endsInLabel: String, // e.g. "ends in 2h 14m" (only meaningful when active)
val windowLabel: String // e.g. "511 AM PT · 8 AM2 PM your time"
)
fun isPeak(now: Long = System.currentTimeMillis()): Boolean {
val cal = GregorianCalendar(PT).apply { timeInMillis = now }
val dow = cal.get(Calendar.DAY_OF_WEEK) // Sun=1 .. Sat=7
val weekday = dow in Calendar.MONDAY..Calendar.FRIDAY
val hour = cal.get(Calendar.HOUR_OF_DAY)
return weekday && hour in PEAK_START_HOUR until PEAK_END_HOUR
}
/** Epoch millis of 11:00 AM PT on the current PT day (peak window close). */
fun peakEndEpoch(now: Long = System.currentTimeMillis()): Long {
val cal = GregorianCalendar(PT).apply {
timeInMillis = now
set(Calendar.HOUR_OF_DAY, PEAK_END_HOUR)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
return cal.timeInMillis
}
fun state(now: Long = System.currentTimeMillis()): PeakState {
val active = isPeak(now)
val endsIn = if (active) {
val rem = peakEndEpoch(now) - now
val h = TimeUnit.MILLISECONDS.toHours(rem)
val m = TimeUnit.MILLISECONDS.toMinutes(rem) % 60
if (h > 0) "ends in ${h}h ${m}m" else "ends in ${m}m"
} else ""
return PeakState(active, endsIn, localWindowString(now))
}
/** "511 AM PT · 8 AM2 PM your time" — peak window translated to the device timezone. */
fun localWindowString(now: Long = System.currentTimeMillis()): String {
val local = TimeZone.getDefault()
if (local.id == PT.id) return "511 AM PT"
val startLocal = ptHourToLocal(PEAK_START_HOUR, now, local)
val endLocal = ptHourToLocal(PEAK_END_HOUR, now, local)
val fmt = SimpleDateFormat("h a", Locale.US).apply { timeZone = local }
val s = fmt.format(Date(startLocal)).replace(" ", "")
val e = fmt.format(Date(endLocal)).replace(" ", "")
return "511 AM PT · $s$e your time"
}
private fun ptHourToLocal(ptHour: Int, now: Long, local: TimeZone): Long {
val cal = GregorianCalendar(PT).apply {
timeInMillis = now
set(Calendar.HOUR_OF_DAY, ptHour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
return cal.timeInMillis // absolute instant; formatting in `local` renders local wall time
}
}
@@ -3,6 +3,7 @@ package me.khodak.claudeusage
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.khodak.claudeusage.BuildConfig
import me.khodak.claudeusage.data.PreferencesManager import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData import me.khodak.claudeusage.data.UsageData
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -67,13 +68,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage" val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute() val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
val code = resp.code val code = resp.code
Log.d(TAG, "GET $usageUrl$code") if (BuildConfig.DEBUG) Log.d(TAG, "GET $usageUrl$code")
if (code == 401 || code == 403) { if (code == 401 || code == 403) {
prefs.clearSession() prefs.clearSession()
return@withContext UsageData(errorMessage = "Session expired — please sign in again") return@withContext UsageData(errorMessage = "Session expired — please sign in again")
} }
val body = resp.body?.string() ?: "" val body = resp.body?.string() ?: ""
debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n") if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n")
val utilData = tryParseUtilizationBody(body) val utilData = tryParseUtilizationBody(body)
if (utilData != null) { if (utilData != null) {
return@withContext base.copy( return@withContext base.copy(
@@ -98,7 +99,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
val req = buildRequest(url, cookies) val req = buildRequest(url, cookies)
val resp = client.newCall(req).execute() val resp = client.newCall(req).execute()
val code = resp.code val code = resp.code
Log.d(TAG, "GET $url$code") if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code")
if (code == 401 || code == 403) { if (code == 401 || code == 403) {
prefs.clearSession() prefs.clearSession()
@@ -107,8 +108,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
val rateLimitData = extractRateLimitHeaders(resp.headers) val rateLimitData = extractRateLimitHeaders(resp.headers)
val body = resp.body?.string() ?: "" val body = resp.body?.string() ?: ""
debugBuf.append("$url\n$code: ${body.take(300)}\n\n") if (BuildConfig.DEBUG) {
Log.d(TAG, "Body: ${body.take(300)}") debugBuf.append("$url\n$code: ${body.take(300)}\n\n")
Log.d(TAG, "Body: ${body.take(300)}")
}
val parsed = tryParseUsageBody(body, rateLimitData) val parsed = tryParseUsageBody(body, rateLimitData)
if (parsed.hasRateLimitData) { if (parsed.hasRateLimitData) {
@@ -121,8 +124,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Endpoint $url failed: ${e.message}") if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n") if (BuildConfig.DEBUG) debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
} }
} }
@@ -131,7 +134,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
val req = buildRequest("https://claude.ai/api/me", cookies) val req = buildRequest("https://claude.ai/api/me", cookies)
val resp = client.newCall(req).execute() val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: "" val body = resp.body?.string() ?: ""
debugBuf.append("https://claude.ai/api/me\n${resp.code}: ${body.take(400)}\n\n") if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me\n${resp.code}: ${body.take(400)}\n\n")
if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) { if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) {
val json = JSONObject(body) val json = JSONObject(body)
val parsed = tryParseOrgForUsage(json) val parsed = tryParseOrgForUsage(json)
@@ -145,7 +148,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n") if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
} }
// Step 4: try page HTML for __NEXT_DATA__ // Step 4: try page HTML for __NEXT_DATA__
@@ -169,7 +172,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
val marker = """<script id="__NEXT_DATA__" type="application/json">""" val marker = """<script id="__NEXT_DATA__" type="application/json">"""
val start = html.indexOf(marker) val start = html.indexOf(marker)
if (start < 0) { if (start < 0) {
debugBuf.append("HTML: no __NEXT_DATA__ found\n") if (BuildConfig.DEBUG) debugBuf.append("HTML: no __NEXT_DATA__ found\n")
return null return null
} }
val jsonStart = start + marker.length val jsonStart = start + marker.length
@@ -177,10 +180,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (jsonEnd < 0) return null if (jsonEnd < 0) return null
val nextData = JSONObject(html.substring(jsonStart, jsonEnd)) val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
val topKeys = nextData.keys().asSequence().toList() val topKeys = nextData.keys().asSequence().toList()
debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n") if (BuildConfig.DEBUG) debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
tryExtractFromNextData(nextData) tryExtractFromNextData(nextData)
} catch (e: Exception) { } catch (e: Exception) {
debugBuf.append("HTML scrape error: ${e.message}\n") if (BuildConfig.DEBUG) debugBuf.append("HTML scrape error: ${e.message}\n")
null null
} }
} }
@@ -200,7 +203,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
obj = (obj as? JSONObject)?.opt(key) obj = (obj as? JSONObject)?.opt(key)
} }
val o = obj as? JSONObject ?: continue val o = obj as? JSONObject ?: continue
debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n") if (BuildConfig.DEBUG) debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData()) val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
if (usage.hasRateLimitData) return usage if (usage.hasRateLimitData) return usage
} }
@@ -210,9 +213,9 @@ class UsageRepository(private val prefs: PreferencesManager) {
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> { private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
return try { return try {
val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute() val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute()
Log.d(TAG, "Orgs → ${resp.code}") if (BuildConfig.DEBUG) Log.d(TAG, "Orgs → ${resp.code}")
val body = resp.body?.string() ?: return Pair(null, null) val body = resp.body?.string() ?: return Pair(null, null)
debugBuf.append("https://claude.ai/api/organizations\n${resp.code}: ${body.take(400)}\n\n") if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/organizations\n${resp.code}: ${body.take(400)}\n\n")
if (body.isBlank() || body.startsWith("<")) return Pair(null, null) if (body.isBlank() || body.startsWith("<")) return Pair(null, null)
val arr = JSONArray(body) val arr = JSONArray(body)
val org = arr.optJSONObject(0) ?: return Pair(null, null) val org = arr.optJSONObject(0) ?: return Pair(null, null)
@@ -332,7 +335,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
.header("Accept", "application/json, */*") .header("Accept", "application/json, */*")
.header("Accept-Language", "en-US,en;q=0.9") .header("Accept-Language", "en-US,en;q=0.9")
.header("Referer", "https://claude.ai/") .header("Referer", "https://claude.ai/")
.header("Cookie", cookies) .header("Cookie", cookies.replace("\r", "").replace("\n", ""))
.get() .get()
.build() .build()
@@ -14,6 +14,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.khodak.claudeusage.data.PreferencesManager import me.khodak.claudeusage.data.PreferencesManager
import java.util.concurrent.TimeUnit
class UsageUpdateWorker( class UsageUpdateWorker(
private val context: Context, private val context: Context,
@@ -77,21 +78,39 @@ class UsageUpdateWorker(
companion object { companion object {
private const val WORK_ONE_SHOT = "claude_oneshot" private const val WORK_ONE_SHOT = "claude_oneshot"
private const val WORK_PERIODIC = "claude_periodic"
private const val ALARM_CODE = 1001 private const val ALARM_CODE = 1001
private const val INTERVAL_MS = 5 * 60 * 1000L private const val INTERVAL_MS = 5 * 60 * 1000L
fun schedulePeriodicRefresh(context: Context) { fun schedulePeriodicRefresh(context: Context) {
// 5-min alarm for fast updates when the device is active/awake
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
am.setAndAllowWhileIdle( am.setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP, AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + INTERVAL_MS, SystemClock.elapsedRealtime() + INTERVAL_MS,
alarmIntent(context) alarmIntent(context)
) )
// WorkManager periodic as a Doze/background backup (Android 16 reliability).
// WorkManager uses JobScheduler which survives Doze; AlarmManager may be batched
// up to 75 min in Doze mode. KEEP = don't reset the 15-min timer on every alarm.
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_PERIODIC,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<UsageUpdateWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
)
} }
fun cancelPeriodicRefresh(context: Context) = fun cancelPeriodicRefresh(context: Context) {
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager) (context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
.cancel(alarmIntent(context)) .cancel(alarmIntent(context))
WorkManager.getInstance(context).cancelUniqueWork(WORK_PERIODIC)
}
fun triggerImmediateRefresh(context: Context) { fun triggerImmediateRefresh(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork( WorkManager.getInstance(context).enqueueUniqueWork(
@@ -10,29 +10,25 @@ class PreferencesManager(context: Context) {
private val gson = Gson() private val gson = Gson()
private val securePrefs = try { private var usingFallbackPrefs = false
val masterKey = MasterKey.Builder(context) private val securePrefs = createSecurePrefs(context, onFallback = { usingFallbackPrefs = true })
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context, "claude_secure", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
}
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
fun saveCookies(cookies: String) { fun saveCookies(cookies: String) {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply() // Never store cookies in plain-text fallback prefs
if (usingFallbackPrefs) return
try {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
} catch (_: Exception) {}
} }
fun getCookies(): String? = securePrefs.getString(KEY_COOKIES, null) fun getCookies(): String? = try {
securePrefs.getString(KEY_COOKIES, null)
} catch (_: Exception) { null }
fun clearSession() { fun clearSession() {
securePrefs.edit().clear().apply() try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).apply() prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).apply()
} }
@@ -84,5 +80,47 @@ class PreferencesManager(context: Context) {
private const val KEY_USAGE_DATA = "usage_data" private const val KEY_USAGE_DATA = "usage_data"
private const val KEY_ACTIVE_WEEK = "active_week" private const val KEY_ACTIVE_WEEK = "active_week"
private const val KEY_ACTIVE_MASK = "active_mask" private const val KEY_ACTIVE_MASK = "active_mask"
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
return try {
buildEncryptedPrefs(context)
} catch (e: Exception) {
if (isKeyPermanentlyInvalidated(e)) {
// Key permanently gone (biometric/PIN changed) — must wipe; user must re-login.
try {
context.deleteSharedPreferences("claude_secure")
buildEncryptedPrefs(context)
} catch (_: Exception) {
onFallback?.invoke()
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
}
} else {
// Transient failure (Keystore busy, cold boot, screen locked during BG work).
// Do NOT delete the encrypted file — it will be readable next session.
onFallback?.invoke()
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
}
}
}
private fun buildEncryptedPrefs(context: Context): android.content.SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context, "claude_secure", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
private fun isKeyPermanentlyInvalidated(e: Exception): Boolean {
var t: Throwable? = e
while (t != null) {
if (t is android.security.keystore.KeyPermanentlyInvalidatedException) return true
t = t.cause
}
return false
}
} }
} }
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Claude "sunburst" mark. Drawn in white; tinted at runtime via setColorFilter
(calm #666666, peak #CC785C). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="1.7"
android:strokeLineCap="round"
android:pathData="
M12,3 L12,7
M12,17 L12,21
M3,12 L7,12
M17,12 L21,12
M5.6,5.6 L8.4,8.4
M15.6,15.6 L18.4,18.4
M18.4,5.6 L15.6,8.4
M8.4,15.6 L5.6,18.4
M12,4.5 L12,6.2
M12,17.8 L12,19.5
M4.5,12 L6.2,12
M17.8,12 L19.5,12" />
<path
android:fillColor="#FFFFFF"
android:pathData="M12,9.6 a2.4,2.4 0 1,0 0.01,0 z" />
</vector>
+59 -16
View File
@@ -92,6 +92,33 @@
android:background="@drawable/widget_background" android:background="@drawable/widget_background"
android:padding="20dp"> android:padding="20dp">
<!-- Peak-hours row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="14dp">
<ImageView
android:id="@+id/imgPeak"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_claude_burst"
android:contentDescription="Peak hours" />
<TextView
android:id="@+id/tvPeak"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text=""
android:textColor="#AAAAAA"
android:textSize="12sp" />
</LinearLayout>
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -110,16 +137,24 @@
android:textSize="20sp" android:textSize="20sp"
android:textStyle="bold" /> android:textStyle="bold" />
<ProgressBar <ImageView
android:id="@+id/progressBar" android:id="@+id/barSession"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="8dp" android:layout_height="9dp"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:max="100" android:scaleType="fitXY"
android:progress="0" android:adjustViewBounds="false"
android:progressTint="#CC785C" android:contentDescription="Session usage bar" />
android:progressBackgroundTint="#3A3A3A" />
<TextView
android:id="@+id/tvSessionPace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text=""
android:textColor="#AAAAAA"
android:textSize="13sp"
android:textStyle="bold" />
<TextView <TextView
android:id="@+id/tvReset" android:id="@+id/tvReset"
@@ -148,16 +183,24 @@
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
<ProgressBar <ImageView
android:id="@+id/progressBarWeekly" android:id="@+id/barWeekly"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="8dp" android:layout_height="9dp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:max="100" android:scaleType="fitXY"
android:progress="0" android:adjustViewBounds="false"
android:progressTint="#7B8FCC" android:contentDescription="Weekly usage bar" />
android:progressBackgroundTint="#3A3A3A" />
<TextView
android:id="@+id/tvWeeklyPace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text=""
android:textColor="#AAAAAA"
android:textSize="13sp"
android:textStyle="bold" />
<TextView <TextView
android:id="@+id/tvWeeklyReset" android:id="@+id/tvWeeklyReset"
+45 -27
View File
@@ -23,10 +23,28 @@
android:textSize="13sp" android:textSize="13sp"
android:textStyle="bold" /> android:textStyle="bold" />
<ImageView
android:id="@+id/img_peak"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="4dp"
android:src="@drawable/ic_claude_burst"
android:contentDescription="Peak hours" />
<TextView
android:id="@+id/tv_peak"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:text=""
android:textColor="#CC785C"
android:textSize="9sp"
android:textStyle="bold" />
<ImageButton <ImageButton
android:id="@+id/btn_refresh" android:id="@+id/btn_refresh"
android:layout_width="24dp" android:layout_width="32dp"
android:layout_height="24dp" android:layout_height="32dp"
android:src="@android:drawable/ic_menu_rotate" android:src="@android:drawable/ic_menu_rotate"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:tint="#999999" android:tint="#999999"
@@ -54,7 +72,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="SESSION" android:text="SESSION"
android:textColor="#555555" android:textColor="#FFFFFF"
android:textSize="9sp" android:textSize="9sp"
android:textStyle="bold" /> android:textStyle="bold" />
@@ -69,16 +87,14 @@
</LinearLayout> </LinearLayout>
<ProgressBar <ImageView
android:id="@+id/progress_bar" android:id="@+id/bar_session"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="5dp" android:layout_height="6dp"
android:layout_marginTop="5dp" android:layout_marginTop="5dp"
android:max="100" android:scaleType="fitXY"
android:progress="0" android:adjustViewBounds="false"
android:progressTint="#CC785C" android:contentDescription="Session usage bar" />
android:progressBackgroundTint="#252525" />
<TextView <TextView
android:id="@+id/tv_session_label" android:id="@+id/tv_session_label"
@@ -86,8 +102,9 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:text="" android:text=""
android:textColor="#666666" android:textColor="#FFFFFF"
android:textSize="9sp" /> android:textSize="11sp"
android:textStyle="bold" />
<!-- 7-day window bar --> <!-- 7-day window bar -->
<LinearLayout <LinearLayout
@@ -102,7 +119,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="WEEKLY" android:text="WEEKLY"
android:textColor="#555555" android:textColor="#FFFFFF"
android:textSize="9sp" android:textSize="9sp"
android:textStyle="bold" /> android:textStyle="bold" />
@@ -117,16 +134,14 @@
</LinearLayout> </LinearLayout>
<ProgressBar <ImageView
android:id="@+id/progress_bar_weekly" android:id="@+id/bar_weekly"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="5dp" android:layout_height="6dp"
android:layout_marginTop="5dp" android:layout_marginTop="5dp"
android:max="100" android:scaleType="fitXY"
android:progress="0" android:adjustViewBounds="false"
android:progressTint="#7B8FCC" android:contentDescription="Weekly usage bar" />
android:progressBackgroundTint="#252525" />
<TextView <TextView
android:id="@+id/tv_weekly_label" android:id="@+id/tv_weekly_label"
@@ -134,8 +149,9 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:text="" android:text=""
android:textColor="#666666" android:textColor="#FFFFFF"
android:textSize="9sp" /> android:textSize="11sp"
android:textStyle="bold" />
<!-- Footer --> <!-- Footer -->
<LinearLayout <LinearLayout
@@ -151,8 +167,9 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="" android:text=""
android:textColor="#CC785C" android:textColor="#FFFFFF"
android:textSize="9sp" android:textSize="9sp"
android:textStyle="bold"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" /> android:ellipsize="end" />
@@ -161,8 +178,9 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="" android:text=""
android:textColor="#444444" android:textColor="#FFFFFF"
android:textSize="9sp" /> android:textSize="9sp"
android:textStyle="bold" />
</LinearLayout> </LinearLayout>
+32 -25
View File
@@ -23,10 +23,18 @@
android:textSize="11sp" android:textSize="11sp"
android:textStyle="bold" /> android:textStyle="bold" />
<ImageView
android:id="@+id/img_peak"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginEnd="6dp"
android:src="@drawable/ic_claude_burst"
android:contentDescription="Peak hours" />
<ImageButton <ImageButton
android:id="@+id/btn_refresh" android:id="@+id/btn_refresh"
android:layout_width="20dp" android:layout_width="28dp"
android:layout_height="20dp" android:layout_height="28dp"
android:src="@android:drawable/ic_menu_rotate" android:src="@android:drawable/ic_menu_rotate"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:contentDescription="Refresh" /> android:contentDescription="Refresh" />
@@ -46,7 +54,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="SESSION" android:text="SESSION"
android:textColor="#555555" android:textColor="#FFFFFF"
android:textSize="8sp" android:textSize="8sp"
android:textStyle="bold" /> android:textStyle="bold" />
@@ -65,21 +73,20 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:text="" android:text=""
android:textColor="#555555" android:textColor="#FFFFFF"
android:textSize="8sp" /> android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout> </LinearLayout>
<ProgressBar <ImageView
android:id="@+id/progress_bar" android:id="@+id/bar_session"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="4dp" android:layout_height="5dp"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:max="100" android:scaleType="fitXY"
android:progress="0" android:adjustViewBounds="false"
android:progressTint="#CC785C" android:contentDescription="Session usage bar" />
android:progressBackgroundTint="#252525" />
<!-- 7-DAY row --> <!-- 7-DAY row -->
<LinearLayout <LinearLayout
@@ -94,7 +101,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="WEEKLY" android:text="WEEKLY"
android:textColor="#555555" android:textColor="#FFFFFF"
android:textSize="8sp" android:textSize="8sp"
android:textStyle="bold" /> android:textStyle="bold" />
@@ -113,21 +120,20 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:text="" android:text=""
android:textColor="#555555" android:textColor="#FFFFFF"
android:textSize="8sp" /> android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout> </LinearLayout>
<ProgressBar <ImageView
android:id="@+id/progress_bar_weekly" android:id="@+id/bar_weekly"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="4dp" android:layout_height="5dp"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:max="100" android:scaleType="fitXY"
android:progress="0" android:adjustViewBounds="false"
android:progressTint="#7B8FCC" android:contentDescription="Weekly usage bar" />
android:progressBackgroundTint="#252525" />
<TextView <TextView
android:id="@+id/tv_status" android:id="@+id/tv_status"
@@ -135,8 +141,9 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:text="" android:text=""
android:textColor="#CC785C" android:textColor="#FFFFFF"
android:textSize="8sp" android:textSize="8sp"
android:textStyle="bold"
android:maxLines="1" /> android:maxLines="1" />
</LinearLayout> </LinearLayout>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+1 -1
View File
@@ -11,6 +11,6 @@
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000" android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_layout" android:initialLayout="@layout/widget_layout"
android:widgetCategory="home_screen|keyguard" android:widgetCategory="home_screen"
android:description="@string/widget_description" android:description="@string/widget_description"
android:previewLayout="@layout/widget_layout" /> android:previewLayout="@layout/widget_layout" />
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.
Binary file not shown.