15 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
amir a95b05dada chore: bump to 1.20 (rings release)
Build APK / build (push) Successful in 1m48s
Build APK / build (pull_request) Successful in 1m33s
2026-06-12 08:13:24 +00:00
amir 10cc064f1f fix(rings): make widget rings fill available space + higher render res
Build APK / build (pull_request) Successful in 1m42s
Rings were capped at a fixed 86dp; now each gauge fills its half of the widget
(weighted ImageView) so they grow with the placed widget size. Bumped RingRenderer
default bitmap to 360px/36 stroke to stay crisp at the larger display size.
2026-06-12 08:02:11 +00:00
amir 952c8261e9 feat: ring widget style (selectable bars/rings)
Build APK / build (pull_request) Successful in 1m34s
Add RingRenderer (circular gauge mirror of BarRenderer), a widget_layout_rings
layout, a Bars/Rings preference + in-app toggle, and ring rendering branch in
ClaudeUsageWidget. Full (4x2) widget honors the chosen style; compact size stays
bars. Phase 1 of porting hamed-elfayome/Claude-Usage-Tracker features.
2026-06-12 07:45:45 +00:00
amir 58e3a0fcd7 v1.19: fix empty usage-history chart (always fetch utilization)
Build APK / build (push) Successful in 1m52s
recordHistory() only ever stored fiveHourUtilization/weeklyUtilization, but
fetchUsage() returned early with message-count data — before calling the
/usage utilization endpoint — whenever the org JSON carried a message limit.
So utilization was never populated and the history chart stayed stuck on
'Collecting history…'. The prior chart fix only corrected the throttle.

- UsageRepository: always attempt /usage (preferred signal that drives the
  weekly bar + history); fall back to org message-count data only when
  utilization is unavailable.
- UsageData.sessionReadingPct: utilization preferred, message-count % fallback,
  -1f when no reading — so message-only accounts also build history.
- PreferencesManager.recordHistory: record the session line from
  sessionReadingPct instead of utilization-only.
- UsageDataTest: cover sessionReadingPct.
2026-06-12 01:48:04 +00:00
amir 31e18ed5e9 Harden WebView nav + add test suite + fix lint for production
Build APK / build (push) Successful in 1m29s
Security:
- LoginActivity WebView now enforces a host allow-list in
  shouldOverrideUrlLoading: only claude.ai + required SSO/CDN hosts
  (Google, Apple, Cloudflare, gstatic, recaptcha) can navigate; everything
  else is blocked. market://intent:// still blocked; about:/data: allowed.
  Device-verified: claude.ai login + Cloudflare challenge still load.

Tests (33, pure-JVM JUnit4, no device needed):
- Extracted shouldRecordHistory() pure throttle decision (regression guard
  for the empty-history-chart bug) + HistoryThrottleTest.
- UsageDataTest (mergedWith last-good/partial-union, computed props),
  PaceCalcTest, PeakHoursTest.
- Added junit:junit:4.13.2 as testImplementation only.

Build quality:
- widget_layout.xml: suppress false-positive UseAppTint lint on the widget
  refresh button (app:tint doesn't work in RemoteViews; android:tint is
  correct here) so lintDebug is clean.

Verified locally: 33 unit tests pass, lintDebug 0 errors, signed
assembleRelease OK (apksigner verified, signer identity unchanged),
emulator smoke test launches + renders without crash.
2026-06-10 11:12:02 +00:00
amir a6d930415c Fix empty usage-history chart + externalize signing secrets
Build APK / build (push) Successful in 2m18s
History chart: recordHistory() threw away the previous point whenever a
new reading landed within the 2-min de-dup window, but the foreground loop
refreshes every 30s — so history could never grow past one point while the
app was open and the chart stayed stuck on 'Collecting history…'. Now it
throttles by SKIPPING a too-soon reading instead of replacing the last one,
so points accumulate during normal use.

Security:
- Remove hardcoded release keystore passwords from build.gradle.kts; read
  from env vars / gitignored keystore.properties; CI injects from Gitea
  secrets (KEYSTORE_PASSWORD/KEY_PASSWORD). Signing identity unchanged.
- Make the cookie-never-plaintext invariant explicit on the read path.
- Drop custom ACTION_REFRESH from the exported widget intent-filter so other
  apps can't trigger refreshes; internal explicit PendingIntent still works.
- Gate an unguarded Log.w behind BuildConfig.DEBUG.
2026-06-10 10:28:37 +00:00
amirandClaude Opus 4.8 c69147530e v1.18: center the tank + driver in the launcher icon
Build APK / build (push) Successful in 1m58s
The subject was filling the frame edge-to-edge, so launcher masking
clipped the gun barrel, treads, and the driver. Auto-detect the tank+bot
(erode away the debris specks, take that bbox), recenter on a square
canvas at ~74% with matching navy #0B1D27 margin so the whole subject
stays inside any mask shape. Regenerated all densities + icon.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:40:00 +00:00
amirandClaude Opus 4.8 5a5f6ed1e4 v1.17: updated tank launcher icon (close-up, navy #16222B)
Build APK / build (push) Successful in 1m55s
Swap in the closer-up tank art. Source padded to square (no distortion),
adaptive foreground inset ~10% so edges don't clip on round masks, bg
color updated to #16222B. Regenerated all densities + repo icon.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:31:02 +00:00
44 changed files with 1616 additions and 38 deletions
+14
View File
@@ -56,6 +56,20 @@ jobs:
fi fi
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore
# Signing passwords are no longer hardcoded in build.gradle.kts — inject them at build time.
- name: Write signing credentials
if: startsWith(github.ref, 'refs/tags/')
run: |
if [ -z "${{ secrets.KEYSTORE_PASSWORD }}" ] || [ -z "${{ secrets.KEY_PASSWORD }}" ]; then
echo "::error::KEYSTORE_PASSWORD / KEY_PASSWORD secrets not set — cannot sign release."
exit 1
fi
{
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}"
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
echo "keyAlias=${{ secrets.KEY_ALIAS || 'claudewidget' }}"
} > keystore.properties
- name: Build release APK - name: Build release APK
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
run: ./gradlew :app:assembleRelease --no-daemon run: ./gradlew :app:assembleRelease --no-daemon
+1
View File
@@ -10,3 +10,4 @@ captures/
.cxx/ .cxx/
*.keystore *.keystore
*.jks *.jks
keystore.properties
+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)
+37 -5
View File
@@ -1,8 +1,21 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
} }
// Signing credentials are NEVER committed. They are read from (in order):
// 1. environment variables (KEYSTORE_PASSWORD / KEY_PASSWORD / KEY_ALIAS) — used by CI
// 2. keystore.properties at the repo root (gitignored) — used locally
// See keystore.properties.example. Debug builds need none of this.
val keystoreProps = Properties().apply {
val f = rootProject.file("keystore.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
fun signingCred(envName: String, propName: String): String? =
System.getenv(envName) ?: keystoreProps.getProperty(propName)
android { android {
namespace = "me.khodak.claudeusage" namespace = "me.khodak.claudeusage"
compileSdk = 34 compileSdk = 34
@@ -11,16 +24,26 @@ android {
applicationId = "me.khodak.claudeusage" applicationId = "me.khodak.claudeusage"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 17 versionCode = 22
versionName = "1.16" versionName = "1.21"
} }
val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword")
val releaseKeyPassword = signingCred("KEY_PASSWORD", "keyPassword")
val releaseKeyAlias = signingCred("KEY_ALIAS", "keyAlias") ?: "claudewidget"
val hasSigningCreds = releaseStorePassword != null && releaseKeyPassword != null
signingConfigs { signingConfigs {
create("release") { create("release") {
// Only wire the keystore when credentials are present, so debug builds and
// credential-less checkouts configure cleanly. Same keystore file + alias as before —
// signing identity is unchanged; only the password source moved out of version control.
if (hasSigningCreds) {
storeFile = file("claude-widget-release.keystore") storeFile = file("claude-widget-release.keystore")
storePassword = "ClaudeWidget2026!" storePassword = releaseStorePassword
keyAlias = "claudewidget" keyAlias = releaseKeyAlias
keyPassword = "ClaudeWidget2026!" keyPassword = releaseKeyPassword
}
} }
} }
@@ -29,7 +52,13 @@ android {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// Sign only when creds were supplied; otherwise fail loudly at assembleRelease rather
// than silently shipping an unsigned APK. Tag builds in CI inject creds (see workflow).
if (hasSigningCreds) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} else {
logger.warn("No signing credentials (KEYSTORE_PASSWORD/keystore.properties) — release APK will be unsigned.")
}
} }
} }
@@ -72,4 +101,7 @@ dependencies {
// Lifecycle // Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
// Unit tests (pure JVM — no Robolectric, no Android framework)
testImplementation("junit:junit:4.13.2")
} }
+3 -1
View File
@@ -32,9 +32,11 @@
<receiver <receiver
android:name=".ClaudeUsageWidget" android:name=".ClaudeUsageWidget"
android:exported="true"> android:exported="true">
<!-- Only the system APPWIDGET_UPDATE action is exposed. ACTION_REFRESH is delivered via
the widget's own explicit PendingIntent (explicit broadcasts need no intent-filter),
so other apps can no longer trigger refreshes by sending that action externally. -->
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="me.khodak.claudeusage.ACTION_REFRESH" />
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
@@ -84,17 +84,26 @@ class ClaudeUsageWidget : AppWidgetProvider() {
return v return v
} }
// Full-size builder follows the user's chosen style; the compact (1-cell-tall) size
// always uses bars, since two rings don't fit that height.
val rings = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
fun buildFull() =
if (rings) buildRingViews(context, prefs, apiData)
else buildViews(context, prefs, apiData)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
RemoteViews(mapOf( RemoteViews(mapOf(
SizeF(50f, 50f) to attach(buildSmallViews(context, prefs, apiData)), SizeF(50f, 50f) to attach(buildSmallViews(context, prefs, apiData)),
SizeF(50f, 120f) to attach(buildViews(context, prefs, apiData)) SizeF(50f, 120f) to attach(buildFull())
)) ))
} else { } else {
attach(buildViews(context, prefs, apiData)) attach(buildFull())
} }
} }
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()) {
@@ -167,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)
@@ -252,6 +263,82 @@ class ClaudeUsageWidget : AppWidgetProvider() {
return v return v
} }
/**
* Ring style of the full widget — same data/branching as [buildViews] but each metric is a
* circular [RingRenderer] gauge (percentage drawn in the center) instead of a bar.
*/
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)
applyPeak(v, showText = true)
if (!prefs.isLoggedIn()) {
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "", "SESSION"))
v.setImageViewBitmap(R.id.ring_weekly, RingRenderer.render(0, null, WEEKLY_FILL, null, "", "WEEKLY"))
v.setTextViewText(R.id.tv_session_label, "Not signed in")
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
v.setTextViewText(R.id.tv_status, "")
return v
}
// ── 5-hour session ring ──────────────────────────────────────────
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
val hasApiMessages = apiData != null && apiData.effectiveRemaining >= 0 && apiData.messagesLimit > 0
val sessionStart = prefs.getSessionStart()
when {
hasUtilization -> {
val pct = apiData!!.fiveHourUtilization.toInt()
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(pct, null, SESSION_FILL, null, "$pct%", "SESSION"))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
}
hasApiMessages -> {
val pct = apiData!!.progressPercent
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(pct, null, SESSION_FILL, null, "$pct%", "SESSION"))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
}
sessionStart > 0 -> {
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "·", "SESSION"))
v.setTextViewText(R.id.tv_session_label, "session active")
}
else -> {
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "", "SESSION"))
v.setTextViewText(R.id.tv_session_label, "")
}
}
// ── 7-day weekly ring (with pace tick) ───────────────────────────
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
if (hasWeekly) {
val wPct = apiData!!.weeklyUtilization.toInt()
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
v.setImageViewBitmap(
R.id.ring_weekly,
RingRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null, "$wPct%", "WEEKLY")
)
v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch))
} else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setImageViewBitmap(R.id.ring_weekly, RingRenderer.render(0, null, WEEKLY_FILL, null, "${weeklyDays}d", "WEEKLY"))
v.setTextViewText(R.id.tv_weekly_label, "active this week")
}
// ── Footer ───────────────────────────────────────────────────────
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
val status = when {
isRefreshing -> "Refreshing…"
apiData?.isRateLimited == true -> "Rate limited · ${formatReset(resetEpoch)}"
else -> ""
}
val updatedMs = apiData?.lastUpdated ?: 0L
v.setTextViewText(R.id.tv_status,
if (status.isNotBlank()) status else if (updatedMs > 0) formatTime(updatedMs) else "")
v.setInt(R.id.btn_refresh, "setColorFilter",
if (isRefreshing) 0xFFCC785C.toInt() else 0xFF999999.toInt())
v.setFloat(R.id.btn_refresh, "setRotation", currentRotation)
return v
}
private const val SESSION_FILL = 0xFFCC785C.toInt() private const val SESSION_FILL = 0xFFCC785C.toInt()
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt() private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color pace marker (weekly only) private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color pace marker (weekly only)
@@ -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) " +
@@ -85,9 +94,19 @@ class LoginActivity : AppCompatActivity() {
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString() val uri = request.url
val url = uri.toString()
// Keep blocking app-redirect schemes outright.
if (url.startsWith("market://") || url.startsWith("intent://")) return true if (url.startsWith("market://") || url.startsWith("intent://")) return true
return false val scheme = uri.scheme?.lowercase()
// about:blank and data: URIs are used by the login page itself (e.g. blank
// bootstrap frames, inline images). They have no host to allow-list, so let them load.
if (scheme == "about" || scheme == "data") return false
// Host ALLOW-LIST: only navigate to claude.ai and its SSO/CDN providers.
// OAuth runs as full-page redirects in THIS WebView (multiple windows disabled,
// JS-opened windows disabled), so the list MUST include the Google/Apple SSO
// and Cloudflare challenge hosts or sign-in breaks. Anything else is blocked.
return !isHostAllowed(uri.host)
} }
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) { override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
@@ -169,4 +188,35 @@ class LoginActivity : AppCompatActivity() {
binding.webView.destroy() binding.webView.destroy()
super.onDestroy() super.onDestroy()
} }
/**
* True if [host] belongs to an allowed domain (exact match or a subdomain via dot-suffix).
* A null/blank host is never allowed. accounts.google.com, challenges.cloudflare.com, etc. are
* covered by their parent-domain suffixes (google.com, cloudflare.com).
*/
private fun isHostAllowed(host: String?): Boolean {
if (host.isNullOrBlank()) return false
val h = host.lowercase()
return ALLOWED_DOMAINS.any { d -> h == d || h.endsWith(".$d") }
}
companion object {
// WebView host allow-list. Login navigation is restricted to claude.ai and the SSO/CDN
// hosts it redirects through. This MUST include the SSO providers (Google, Apple) and
// Cloudflare challenge hosts — OAuth happens as same-WebView full-page redirects, so
// dropping any of these breaks sign-in. Subdomains are matched by dot-suffix.
private val ALLOWED_DOMAINS = listOf(
"claude.ai",
"anthropic.com",
"google.com", // covers accounts.google.com
"gstatic.com",
"googleusercontent.com",
"googleapis.com",
"apple.com",
"icloud.com",
"cloudflare.com", // covers challenges.cloudflare.com
"cloudflareinsights.com",
"recaptcha.net"
)
}
} }
@@ -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
@@ -66,6 +93,9 @@ class MainActivity : AppCompatActivity() {
} }
setupNotificationSettings() setupNotificationSettings()
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) {
@@ -112,6 +142,37 @@ class MainActivity : AppCompatActivity() {
if (prefs.isNotifyEnabled()) requestNotificationPermission() 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 — 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,120 @@
package me.khodak.claudeusage
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import kotlin.math.cos
import kotlin.math.sin
/**
* Draws a usage gauge as a circular ring Bitmap: rounded track ring + colored progress arc
* (sweeping clockwise from 12 o'clock) + an optional radial pace tick + center percentage and
* label. The ring counterpart to [BarRenderer] — same Bitmap contract, so it drops into both the
* home-screen widget (setImageViewBitmap) and the in-app card and renders identically.
*
* Design mirrors hamed-elfayome/Claude-Usage-Tracker's ring gauges, using this app's existing
* palette (SESSION_FILL clay / WEEKLY_FILL periwinkle, #252525 track, white pace marker).
*/
object RingRenderer {
private const val TRACK_COLOR = 0xFF252525.toInt()
private const val CENTER_TEXT_COLOR = 0xFFFFFFFF.toInt()
private const val LABEL_TEXT_COLOR = 0xFF999999.toInt()
/**
* @param usedPct 0..100 fill of the ring
* @param markerPct where you "should be" right now (pace), or null for no tick
* @param fillColor ARGB progress-arc color (e.g. SESSION_FILL / WEEKLY_FILL)
* @param markerColor ARGB pace-tick color, or null
* @param centerText big text in the middle, e.g. "47%" (caller formats it)
* @param labelText small caption under the number, e.g. "SESSION" (null hides it)
* @param sizePx square bitmap edge; ring scales to fit
* @param strokePx ring thickness
*/
fun render(
usedPct: Int,
markerPct: Int?,
fillColor: Int,
markerColor: Int?,
centerText: String,
labelText: String? = null,
sizePx: Int = 360,
strokePx: Float = 36f
): Bitmap {
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
val cx = sizePx / 2f
val cy = sizePx / 2f
val pad = strokePx / 2f + 2f
val arcRect = RectF(pad, pad, sizePx - pad, sizePx - pad)
val radius = (sizePx - strokePx) / 2f - 2f
// Track ring (full circle).
val track = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokePx
strokeCap = Paint.Cap.ROUND
color = TRACK_COLOR
}
canvas.drawArc(arcRect, 0f, 360f, false, track)
// Progress arc — clockwise from 12 o'clock (-90°).
val pct = usedPct.coerceIn(0, 100)
if (pct > 0) {
val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokePx
strokeCap = Paint.Cap.ROUND
color = fillColor
}
canvas.drawArc(arcRect, -90f, pct / 100f * 360f, false, fill)
}
// Pace tick — a short radial mark across the ring at the "should be here" angle.
if (markerPct != null && markerColor != null) {
val m = markerPct.coerceIn(0, 100)
val ang = Math.toRadians((-90f + m / 100f * 360f).toDouble())
val rInner = radius - strokePx / 2f - 1f
val rOuter = radius + strokePx / 2f + 1f
val tick = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokePx * 0.18f
strokeCap = Paint.Cap.ROUND
color = markerColor
}
canvas.drawLine(
cx + (rInner * cos(ang)).toFloat(), cy + (rInner * sin(ang)).toFloat(),
cx + (rOuter * cos(ang)).toFloat(), cy + (rOuter * sin(ang)).toFloat(),
tick
)
}
// Center percentage.
val hasLabel = !labelText.isNullOrEmpty()
val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = CENTER_TEXT_COLOR
textAlign = Paint.Align.CENTER
textSize = sizePx * 0.27f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
val vfm = valuePaint.fontMetrics
val valueBaselineShift = -(vfm.ascent + vfm.descent) / 2f
val valueY = cy + valueBaselineShift - (if (hasLabel) sizePx * 0.07f else 0f)
canvas.drawText(centerText, cx, valueY, valuePaint)
if (hasLabel) {
val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = LABEL_TEXT_COLOR
textAlign = Paint.Align.CENTER
textSize = sizePx * 0.105f
letterSpacing = 0.12f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
canvas.drawText(labelText!!.uppercase(), cx, cy + sizePx * 0.17f, labelPaint)
}
return bmp
}
}
@@ -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
}
@@ -54,17 +54,15 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai") if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
if (orgUsageData?.hasRateLimitData == true) { // NOTE: message-count data from the org endpoint (orgUsageData) is a FALLBACK only — do
prefs.resetAuthFailCount() // NOT return here. The /usage utilization endpoint below is the preferred signal and the
return@withContext base.copy( // one the weekly bar + in-app history chart depend on, so it must be attempted whenever we
messagesUsed = orgUsageData.messagesUsed, // have an org id. Returning early here was why the history chart stayed empty: any account
messagesLimit = orgUsageData.messagesLimit, // whose org JSON carried a message limit short-circuited before /usage, so
messagesRemaining = orgUsageData.messagesRemaining, // fiveHourUtilization/weeklyUtilization never got populated and recordHistory() had nothing
resetAtEpoch = orgUsageData.resetAtEpoch // to store. The org message data is used only if /usage yields nothing (fallback below).
)
}
// Step 2: /usage endpoint — returns utilization percentages // Step 2: /usage endpoint — returns utilization percentages (preferred signal)
try { try {
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()
@@ -87,7 +85,19 @@ class UsageRepository(private val prefs: PreferencesManager) {
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "/usage failed: ${e.message}") if (BuildConfig.DEBUG) Log.w(TAG, "/usage failed: ${e.message}")
}
// /usage gave no utilization → fall back to the message-count data the org endpoint
// already returned (this is the early-return that used to live before Step 2).
if (orgUsageData?.hasRateLimitData == true) {
prefs.resetAuthFailCount()
return@withContext base.copy(
messagesUsed = orgUsageData.messagesUsed,
messagesLimit = orgUsageData.messagesLimit,
messagesRemaining = orgUsageData.messagesRemaining,
resetAtEpoch = orgUsageData.resetAtEpoch
)
} }
// Step 3: fallback endpoints (message-count style) // Step 3: fallback endpoints (message-count style)
@@ -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()
@@ -24,9 +24,14 @@ class PreferencesManager(context: Context) {
} catch (_: Exception) {} } catch (_: Exception) {}
} }
fun getCookies(): String? = try { fun getCookies(): String? {
// Cookies are never written in fallback (plaintext) mode — make that invariant explicit on
// the read side too, so any future write that bypasses the guard still can't surface here.
if (usingFallbackPrefs) return null
return try {
securePrefs.getString(KEY_COOKIES, null) securePrefs.getString(KEY_COOKIES, null)
} catch (_: Exception) { null } } catch (_: Exception) { null }
}
fun clearSession() { fun clearSession() {
try { securePrefs.edit().clear().apply() } catch (_: Exception) {} try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
@@ -89,22 +94,27 @@ class PreferencesManager(context: Context) {
// ── Usage history (for the in-app chart) ───────────────────────────────── // ── Usage history (for the in-app chart) ─────────────────────────────────
/** /**
* Append a history point if [data] carries a real utilization reading. * Append a history point if [data] carries a real reading.
* The session line uses [UsageData.sessionReadingPct] (utilization preferred, message-count
* progress as fallback) so accounts that only expose message counts still build history.
* De-duplicates rapid double-fires (manual refresh + background worker landing * De-duplicates rapid double-fires (manual refresh + background worker landing
* together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and * together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and
* prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS]. * prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS].
*/ */
fun recordHistory(data: UsageData) { fun recordHistory(data: UsageData) {
if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return val sessionPct = data.sessionReadingPct
if (sessionPct < 0f && data.weeklyUtilization < 0f) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val history = getHistory().toMutableList() val history = getHistory().toMutableList()
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) { // Throttle to at most one point per MIN_HISTORY_GAP_MS by SKIPPING a too-soon reading.
history.removeAt(history.size - 1) // collapse near-simultaneous readings // (Previously we deleted the last point and re-added in place — but the foreground loop
} // refreshes every 30s, well inside this 2-min window, so history could never grow past a
// single point while the app was open and the chart stayed on "Collecting history…".)
if (!shouldRecordHistory(history.lastOrNull()?.epochMs, now, MIN_HISTORY_GAP_MS)) return
history.add( history.add(
UsageSnapshot( UsageSnapshot(
epochMs = now, epochMs = now,
sessionPct = data.fiveHourUtilization, sessionPct = sessionPct,
weeklyPct = data.weeklyUtilization weeklyPct = data.weeklyUtilization
) )
) )
@@ -122,11 +132,53 @@ class PreferencesManager(context: Context) {
} catch (e: Exception) { emptyList() } } catch (e: Exception) { emptyList() }
} }
// ── Widget style ─────────────────────────────────────────────────────────
/** Home-screen widget visual style: [STYLE_BARS] (default) or [STYLE_RINGS]. */
fun getWidgetStyle(): String = prefs.getString(KEY_WIDGET_STYLE, STYLE_BARS) ?: STYLE_BARS
fun setWidgetStyle(style: String) = prefs.edit().putString(KEY_WIDGET_STYLE, style).apply()
// ── Notification settings ──────────────────────────────────────────────── // ── Notification settings ────────────────────────────────────────────────
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.
@@ -145,11 +197,27 @@ class PreferencesManager(context: Context) {
private const val KEY_HISTORY = "usage_history" private const val KEY_HISTORY = "usage_history"
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_MATERIAL_YOU = "material_you"
private const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart const val STYLE_BARS = "bars"
const val STYLE_RINGS = "rings"
internal const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
private const val MAX_HISTORY_POINTS = 600 private const val MAX_HISTORY_POINTS = 600
/**
* Pure throttle decision for [recordHistory]: should a new point be appended?
* Returns false only when a previous point exists ([lastEpochMs] != null) AND the gap to
* [now] is below [minGapMs]; true otherwise (including the first-ever point, lastEpochMs == null).
* No Android dependencies — kept separate so the throttle rule is unit-testable.
*/
internal fun shouldRecordHistory(lastEpochMs: Long?, now: Long, minGapMs: Long): Boolean {
if (lastEpochMs == null) return true
return now - lastEpochMs >= minGapMs
}
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences { fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
return try { return try {
buildEncryptedPrefs(context) buildEncryptedPrefs(context)
@@ -43,6 +43,19 @@ data class UsageData(
val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask) val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask)
/**
* Session utilization as a 0-100 reading for the history chart, preferring the /usage
* utilization endpoint and falling back to message-count progress. Returns -1f when there is
* no usable reading — so message-only accounts (no utilization endpoint) still build history
* instead of leaving the chart permanently on "Collecting history…".
*/
val sessionReadingPct: Float get() = when {
fiveHourUtilization >= 0f -> fiveHourUtilization
messagesLimit > 0 && effectiveUsed >= 0 ->
((effectiveUsed.toFloat() / messagesLimit.toFloat()) * 100f).coerceIn(0f, 100f)
else -> -1f
}
// The best reset time we have (utilization endpoint is preferred) // The best reset time we have (utilization endpoint is preferred)
val effectiveResetEpoch: Long get() = when { val effectiveResetEpoch: Long get() = when {
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
+138
View File
@@ -291,6 +291,90 @@
</LinearLayout> </LinearLayout>
<!-- Widget style 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="RING WIDGET STYLE"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchRingStyle"
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="Show usage as circular rings instead of bars on the full-size home-screen widget."
android:textColor="#AAAAAA"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
</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"
@@ -333,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"
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@@ -48,6 +49,7 @@
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"
tools:ignore="UseAppTint"
android:contentDescription="Refresh" /> android:contentDescription="Refresh" />
</LinearLayout> </LinearLayout>
@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Ring style of the full (4x2) widget. Header + footer mirror widget_layout.xml (same view
IDs) so ClaudeUsageWidget can attach the same click intents and peak/refresh state; the middle
swaps the two horizontal bars for two circular RingRenderer gauges side by side. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/widget_background"
android:padding="14dp">
<!-- Header -->
<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="Claude Pro"
android:textColor="#FFFFFF"
android:textSize="13sp"
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
android:id="@+id/btn_refresh"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@android:drawable/ic_menu_rotate"
android:background="@android:color/transparent"
android:tint="#999999"
tools:ignore="UseAppTint"
android:contentDescription="Refresh" />
</LinearLayout>
<!-- Divider -->
<TextView
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="6dp"
android:background="#2A2A2A" />
<!-- Two ring gauges side by side — each fills its half so the ring grows as large as the
placed widget allows (limited by the available height), instead of a fixed 86dp. -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:gravity="center"
android:weightSum="2">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/ring_session"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scaleType="fitCenter"
android:adjustViewBounds="false"
android:contentDescription="Session usage ring" />
<TextView
android:id="@+id/tv_session_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="center"
android:text=""
android:textColor="#999999"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/ring_weekly"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scaleType="fitCenter"
android:adjustViewBounds="false"
android:contentDescription="Weekly usage ring" />
<TextView
android:id="@+id/tv_weekly_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="center"
android:text=""
android:textColor="#999999"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<!-- Footer -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/tv_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text=""
android:textColor="#FFFFFF"
android:textSize="9sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end" />
<TextView
android:id="@+id/tv_updated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textColor="#FFFFFF"
android:textSize="9sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+1 -1
View File
@@ -5,5 +5,5 @@
<color name="surface_dark">#1E1E1E</color> <color name="surface_dark">#1E1E1E</color>
<color name="text_primary">#FFFFFF</color> <color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#888888</color> <color name="text_secondary">#888888</color>
<color name="ic_launcher_bg">#284950</color> <color name="ic_launcher_bg">#0B1D27</color>
</resources> </resources>
@@ -0,0 +1,44 @@
package me.khodak.claudeusage
import me.khodak.claudeusage.data.PreferencesManager
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure-JVM tests for the history-throttle decision extracted from PreferencesManager.recordHistory.
* No Android Context — exercises only PreferencesManager.shouldRecordHistory.
*/
class HistoryThrottleTest {
private val gap = PreferencesManager.MIN_HISTORY_GAP_MS
@Test
fun returnsTrueWhenNoPreviousPoint() {
// First-ever reading (lastEpochMs == null) is always recorded.
assertTrue(PreferencesManager.shouldRecordHistory(null, now = 1_000_000L, minGapMs = gap))
}
@Test
fun returnsFalseWhenWithinGap() {
val last = 1_000_000L
// One millisecond before the gap elapses → throttled.
val now = last + gap - 1L
assertFalse(PreferencesManager.shouldRecordHistory(last, now, gap))
}
@Test
fun returnsTrueExactlyAtGapBoundary() {
val last = 1_000_000L
// Exactly at the gap (>= boundary is inclusive) → recorded.
val now = last + gap
assertTrue(PreferencesManager.shouldRecordHistory(last, now, gap))
}
@Test
fun returnsTrueAfterGap() {
val last = 1_000_000L
val now = last + gap + 1L
assertTrue(PreferencesManager.shouldRecordHistory(last, now, gap))
}
}
@@ -0,0 +1,75 @@
package me.khodak.claudeusage
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure-JVM tests for PaceCalc.compute. The clock is injected via the `now` parameter, so these are
* deterministic and require no Android framework.
*/
class PaceCalcTest {
private val window = PaceCalc.SESSION_WINDOW_MS // 5h
@Test
fun computeReturnsPaceForValidFutureReset() {
// Place "now" halfway through the window (elapsedFraction = 0.5, within [0.03, 1.0)).
val resetEpoch = 10_000_000_000L
val now = resetEpoch - window / 2
val pace = PaceCalc.compute(usedPct = 25f, resetEpoch = resetEpoch, windowMs = window, now = now)
assertNotNull(pace)
val p = pace!!
// markerPct mirrors elapsedFraction*100 → ~50, and must stay in 0..100.
assertTrue("markerPct in range", p.markerPct in 0..100)
assertEquals50(p.markerPct)
}
@Test
fun markerPctAlwaysInRangeNearWindowEnd() {
// Near the end of the window (95% elapsed, still < 1.0) marker approaches 100, never exceeds.
// Kept off the exact boundary so Float rounding of (window-elapsed)/window can't tip it to 1.0.
val resetEpoch = 10_000_000_000L
val now = resetEpoch - (window * 5L / 100L) // 95% elapsed
val pace = PaceCalc.compute(usedPct = 80f, resetEpoch = resetEpoch, windowMs = window, now = now)
assertNotNull(pace)
assertTrue(pace!!.markerPct in 0..100)
}
@Test
fun computeReturnsNullForPastReset() {
// Reset already passed → elapsedFraction >= 1.0 → null.
val resetEpoch = 10_000_000_000L
val now = resetEpoch + 60_000L
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = resetEpoch, windowMs = window, now = now))
}
@Test
fun computeReturnsNullTooEarlyInWindow() {
// Less than 3% elapsed → not enough signal → null.
val resetEpoch = 10_000_000_000L
val now = resetEpoch - window + (window * 0.01f).toLong() // 1% elapsed
assertNull(PaceCalc.compute(usedPct = 5f, resetEpoch = resetEpoch, windowMs = window, now = now))
}
@Test
fun computeReturnsNullForNonPositiveReset() {
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = 0L, windowMs = window))
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = -1L, windowMs = window))
}
@Test
fun computeReturnsNullForNegativeUsedPct() {
val resetEpoch = 10_000_000_000L
val now = resetEpoch - window / 2
assertNull(PaceCalc.compute(usedPct = -1f, resetEpoch = resetEpoch, windowMs = window, now = now))
}
private fun assertEquals50(markerPct: Int) {
// elapsedFraction 0.5 → (0.5*100).toInt() == 50.
org.junit.Assert.assertEquals(50, markerPct)
}
}
@@ -0,0 +1,58 @@
package me.khodak.claudeusage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.TimeZone
/**
* Pure-JVM tests for PeakHours.isPeak. The clock is injected via `now`, and PeakHours evaluates the
* window in America/Los_Angeles regardless of the device timezone — so we build each instant in PT
* explicitly, making these deterministic without touching the system clock.
*/
class PeakHoursTest {
private val pt: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
/** Absolute epoch millis for the given wall-clock time in PT on a fixed reference date. */
private fun ptInstant(year: Int, month0: Int, day: Int, hour: Int): Long {
val cal = GregorianCalendar(pt)
cal.clear()
cal.set(year, month0, day, hour, 0, 0)
return cal.timeInMillis
}
@Test
fun activeOnWeekdayInsideWindow() {
// 2026-06-10 is a Wednesday. 8 AM PT is inside 511 AM.
assertTrue(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 8)))
}
@Test
fun inactiveAtStartBoundaryIsInclusiveButBeforeIsNot() {
// 5 AM PT is inside (inclusive start); 4 AM PT is outside.
assertTrue(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 5)))
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 4)))
}
@Test
fun inactiveAtEndBoundaryExclusive() {
// 11 AM PT is the exclusive end → not peak.
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 11)))
}
@Test
fun inactiveOnWeekendEvenInsideHours() {
// 2026-06-13 is a Saturday, 2026-06-14 is a Sunday. 8 AM PT, but weekend → not peak.
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 13, 8)))
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 14, 8)))
}
@Test
fun inactiveOnWeekdayEvening() {
// 8 PM PT Wednesday → outside the morning window.
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 20)))
}
}
@@ -0,0 +1,180 @@
package me.khodak.claudeusage
import me.khodak.claudeusage.data.UsageData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure-JVM tests for the UsageData data class: derived metrics and the mergedWith fallback logic.
* UsageData has no Android dependencies, so these run on the plain JVM.
*/
class UsageDataTest {
@Test
fun progressPercentMessagesPath() {
// messagesLimit > 0 and effectiveUsed derived from messagesUsed → 25/100 = 25%.
val d = UsageData(messagesUsed = 25, messagesLimit = 100)
assertEquals(25, d.progressPercent)
}
@Test
fun progressPercentMessagesPathCoercedTo100() {
// Used above limit must clamp to 100, never exceed.
val d = UsageData(messagesUsed = 150, messagesLimit = 100)
assertEquals(100, d.progressPercent)
}
@Test
fun progressPercentUtilizationPathWhenNoMessages() {
// No messages limit → fall through to utilization, truncated to Int and coerced.
val d = UsageData(fiveHourUtilization = 42.9f)
assertEquals(42, d.progressPercent)
}
@Test
fun progressPercentUtilizationCoercedTo100() {
val d = UsageData(fiveHourUtilization = 130f)
assertEquals(100, d.progressPercent)
}
@Test
fun progressPercentZeroWhenNoReadings() {
assertEquals(0, UsageData().progressPercent)
}
@Test
fun effectiveUsedPrefersMessagesUsed() {
assertEquals(30, UsageData(messagesUsed = 30, messagesLimit = 100).effectiveUsed)
}
@Test
fun effectiveUsedDerivedFromRemaining() {
// No messagesUsed, but remaining + limit present → limit - remaining.
val d = UsageData(messagesRemaining = 40, messagesLimit = 100)
assertEquals(60, d.effectiveUsed)
}
@Test
fun effectiveUsedUnknownIsNegativeOne() {
assertEquals(-1, UsageData().effectiveUsed)
}
@Test
fun effectiveRemainingPrefersRemaining() {
assertEquals(40, UsageData(messagesRemaining = 40, messagesLimit = 100).effectiveRemaining)
}
@Test
fun effectiveRemainingDerivedFromUsed() {
val d = UsageData(messagesUsed = 70, messagesLimit = 100)
assertEquals(30, d.effectiveRemaining)
}
@Test
fun effectiveRemainingUnknownIsNegativeOne() {
assertEquals(-1, UsageData().effectiveRemaining)
}
@Test
fun hasAnyReadingTrueWithUtilization() {
assertTrue(UsageData(fiveHourUtilization = 10f).hasAnyReading)
assertTrue(UsageData(weeklyUtilization = 5f).hasAnyReading)
}
@Test
fun hasAnyReadingTrueWithRateLimitData() {
// hasRateLimitData true when messagesLimit > 0.
assertTrue(UsageData(messagesLimit = 100).hasAnyReading)
}
@Test
fun hasAnyReadingFalseWhenEmpty() {
assertFalse(UsageData().hasAnyReading)
}
@Test
fun sessionReadingPctPrefersUtilization() {
// Utilization present → used verbatim (kept as Float, not truncated), even alongside messages.
val d = UsageData(fiveHourUtilization = 42.5f, messagesUsed = 10, messagesLimit = 100)
assertEquals(42.5f, d.sessionReadingPct, 0f)
}
@Test
fun sessionReadingPctFallsBackToMessages() {
// No utilization → derive from message counts so message-only accounts still get history.
val d = UsageData(messagesUsed = 30, messagesLimit = 120)
assertEquals(25f, d.sessionReadingPct, 0f)
}
@Test
fun sessionReadingPctMessagesCoercedTo100() {
val d = UsageData(messagesUsed = 150, messagesLimit = 100)
assertEquals(100f, d.sessionReadingPct, 0f)
}
@Test
fun sessionReadingPctNegativeWhenNoReading() {
// No utilization and no message data → -1f, so recordHistory skips the point.
assertEquals(-1f, UsageData().sessionReadingPct, 0f)
}
@Test
fun mergedWithEmptyFetchKeepsPreviousMetrics() {
// (a) previous has a reading; this fetch is empty (all defaults) → previous metrics kept.
val previous = UsageData(
fiveHourUtilization = 55f,
utilizationResetAtEpoch = 1_000L,
weeklyUtilization = 20f,
weeklyResetAtEpoch = 2_000L,
messagesLimit = 100,
messagesUsed = 40
)
val emptyFetch = UsageData(isLoggedIn = true)
val merged = emptyFetch.mergedWith(previous)
assertEquals(55f, merged.fiveHourUtilization, 0f)
assertEquals(20f, merged.weeklyUtilization, 0f)
assertEquals(100, merged.messagesLimit)
assertEquals(40, merged.messagesUsed)
assertEquals(1_000L, merged.utilizationResetAtEpoch)
assertEquals(2_000L, merged.weeklyResetAtEpoch)
// Login context from the fresh (empty) attempt is carried forward.
assertTrue(merged.isLoggedIn)
}
@Test
fun mergedWithPartialFetchUnionsBothWindows() {
// (b) previous only had five-hour; this fetch only has weekly → result has both.
val previous = UsageData(
fiveHourUtilization = 33f,
utilizationResetAtEpoch = 500L
)
val partial = UsageData(
weeklyUtilization = 77f,
weeklyResetAtEpoch = 900L
)
val merged = partial.mergedWith(previous)
assertEquals(33f, merged.fiveHourUtilization, 0f)
assertEquals(500L, merged.utilizationResetAtEpoch)
assertEquals(77f, merged.weeklyUtilization, 0f)
assertEquals(900L, merged.weeklyResetAtEpoch)
}
@Test
fun mergedWithNullPreviousReturnsThis() {
// (c) previous == null → returns this unchanged.
val fetch = UsageData(fiveHourUtilization = 12f)
assertSame(fetch, fetch.mergedWith(null))
}
@Test
fun mergedWithPreviousWithoutReadingReturnsThis() {
// previous has no usable reading → this is returned unchanged.
val fetch = UsageData(fiveHourUtilization = 12f)
assertSame(fetch, fetch.mergedWith(UsageData()))
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+12
View File
@@ -0,0 +1,12 @@
# Release signing credentials for local builds.
# Copy to `keystore.properties` (which is gitignored) and fill in the real values.
# These are NEVER committed. CI injects the same values from Gitea Actions secrets.
#
# cp keystore.properties.example keystore.properties
#
# The keystore file itself (app/claude-widget-release.keystore) is also gitignored.
# Signing identity is unchanged from older builds — same keystore, same alias.
storePassword=CHANGE_ME
keyPassword=CHANGE_ME
keyAlias=claudewidget
Binary file not shown.