1 Commits
Author SHA1 Message Date
amir 63f1035e8a Initial commit 2026-05-22 01:49:35 +00:00
74 changed files with 2 additions and 5183 deletions
-117
View File
@@ -1,117 +0,0 @@
name: Build APK
# Push to master / open a PR → builds a DEBUG apk (smoke test, no secrets needed).
# Push a tag like v1.14 → builds a SIGNED RELEASE apk and attaches it to the
# Gitea release for that tag.
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Install Android SDK
run: |
set -e
SDK="$GITHUB_WORKSPACE/android-sdk"
mkdir -p "$SDK/cmdline-tools"
curl -sSL -o /tmp/cmdtools.zip \
https://dl.google.com/android/repository/commandlinetools-linux-9862592_latest.zip
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
# Feed "y" via process substitution, not a pipe: `yes |` triggers SIGPIPE (exit 141)
# once sdkmanager stops reading, and the step shell runs with `-eo pipefail`.
"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" --licenses >/dev/null < <(yes)
"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" \
"platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null
- name: Point Gradle at the SDK
run: echo "sdk.dir=$GITHUB_WORKSPACE/android-sdk" > local.properties
# ── Debug build: every push/PR that is NOT a tag ───────────────────────
- name: Build debug APK
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
run: ./gradlew :app:assembleDebug --no-daemon
# ── Release build: tags only (needs the KEYSTORE_BASE64 secret) ────────
- name: Decode signing keystore
if: startsWith(github.ref, 'refs/tags/')
run: |
if [ -z "${{ secrets.KEYSTORE_BASE64 }}" ]; then
echo "::error::KEYSTORE_BASE64 secret is not set — cannot build a signed release."
exit 1
fi
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
if: startsWith(github.ref, 'refs/tags/')
run: ./gradlew :app:assembleRelease --no-daemon
# ── Stage whichever APK was produced ───────────────────────────────────
- name: Stage APK
run: |
mkdir -p out
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
cp app/build/outputs/apk/release/app-release.apk out/claude-usage-widget.apk
else
cp app/build/outputs/apk/debug/app-debug.apk out/claude-usage-widget-debug.apk
fi
ls -la out/
- name: Upload APK artifact
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: apk
path: out/*.apk
# ── Attach the signed APK to the Gitea release on tag ──────────────────
- name: Publish APK to Gitea release
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
TAG="${GITHUB_REF#refs/tags/}"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
# Create the release if it doesn't exist yet (ignore "already exists").
curl -s -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated build of $TAG\"}" >/dev/null || true
RID=$(curl -s "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
# Replace any existing asset of the same name, then upload.
curl -s "$API/releases/$RID/assets" -H "Authorization: token $TOKEN" \
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='claude-usage-widget.apk']" \
| while read AID; do curl -s -X DELETE "$API/releases/$RID/assets/$AID" -H "Authorization: token $TOKEN"; done
curl -s -X POST "$API/releases/$RID/assets?name=claude-usage-widget.apk" \
-H "Authorization: token $TOKEN" \
-F "attachment=@out/claude-usage-widget.apk" >/dev/null
echo "Attached claude-usage-widget.apk to release $TAG"
-13
View File
@@ -1,13 +0,0 @@
*.iml
.gradle/
local.properties
.idea/
.DS_Store
/build/
app/build/
captures/
.externalNativeBuild/
.cxx/
*.keystore
*.jks
keystore.properties
-45
View File
@@ -1,45 +0,0 @@
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.
+2 -48
View File
@@ -1,49 +1,3 @@
<p align="center"> # claude-usage-widget
<img src="icon.png" width="108" alt="Claude Usage Widget">
</p>
# Claude Usage Widget Claude Pro usage Android widget
Android home screen widget that shows your Claude Pro usage at a glance.
## Features
- **SESSION** bar — current 5-hour window utilization 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.
- **Usage history chart** — the app plots your session and weekly utilization over the past 7 days,
so you can see your consumption trend, not just the current snapshot.
- **Usage alerts** — opt-in notifications when session or weekly usage crosses a threshold you set
(sliders, 50100%). Each alert fires at most once per limit window, so you're never spammed.
- Tap the widget to open the app; tap ⟳ to force-refresh
- Responsive: works as 4×1 (compact) or 4×2 (full)
- 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
1. Download `claude-usage-widget.apk` from the [latest release](../../releases/latest)
2. On your Android phone: **Settings → Apps → Install unknown apps** → allow your browser/file manager
3. Open the downloaded APK and tap Install
4. Open the **Claude Usage** app and sign in with your Claude.ai session cookies
5. Long-press your home screen → Widgets → Claude Usage → drag to place
## Sign-in
The app uses your Claude.ai browser cookies (not a password). In the app, tap **Sign In**, then paste your cookies from a logged-in Claude.ai browser session.
To get cookies: open claude.ai in Chrome → DevTools (F12) → Application → Cookies → copy the `Cookie` header value.
## Requirements
- Android 8.0+ (API 26)
- Active Claude Pro subscription
-107
View File
@@ -1,107 +0,0 @@
import java.util.Properties
plugins {
id("com.android.application")
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 {
namespace = "me.khodak.claudeusage"
compileSdk = 34
defaultConfig {
applicationId = "me.khodak.claudeusage"
minSdk = 26
targetSdk = 34
versionCode = 22
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 {
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")
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
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")
} else {
logger.warn("No signing credentials (KEYSTORE_PASSWORD/keystore.properties) — release APK will be unsigned.")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
buildConfig = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// HTTP
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// JSON
implementation("com.google.code.gson:gson:2.10.1")
// Background work
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Secure storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
// Unit tests (pure JVM — no Robolectric, no Android framework)
testImplementation("junit:junit:4.13.2")
}
-5
View File
@@ -1,5 +0,0 @@
-keep class me.khodak.claudeusage.data.** { *; }
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn okhttp3.**
-dontwarn okio.**
-67
View File
@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ClaudeUsage"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".LoginActivity"
android:exported="false"
android:label="@string/login_title" />
<receiver
android:name=".ClaudeUsageWidget"
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>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
<receiver
android:name=".AlarmReceiver"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name="androidx.work.impl.background.systemalarm.RescheduleReceiver"
android:exported="false"
android:directBootAware="false"
tools:node="replace"
xmlns:tools="http://schemas.android.com/tools"/>
</application>
</manifest>
@@ -1,12 +0,0 @@
package me.khodak.claudeusage
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
UsageUpdateWorker.triggerImmediateRefresh(context)
UsageUpdateWorker.schedulePeriodicRefresh(context) // chain next 5-min alarm
}
}
@@ -1,59 +0,0 @@
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,
markerColor: 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 marker — a single clean tick showing "where you should be right now".
// One color (no tiers); rounded ends to match the bar.
if (markerPct != null && markerColor != null) {
val m = markerPct.coerceIn(0, 100)
val tickW = (wPx * 0.016f).coerceIn(6f, 10f)
var x = wPx * m / 100f
x = x.coerceIn(tickW / 2f, wPx - tickW / 2f)
paint.color = markerColor
val tick = RectF(x - tickW / 2f, 0f, x + tickW / 2f, hPx.toFloat())
canvas.drawRoundRect(tick, tickW / 2f, tickW / 2f, paint)
}
return bmp
}
}
@@ -1,15 +0,0 @@
package me.khodak.claudeusage
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import me.khodak.claudeusage.data.PreferencesManager
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
if (!PreferencesManager(context).isLoggedIn()) return
UsageUpdateWorker.schedulePeriodicRefresh(context)
UsageUpdateWorker.triggerImmediateRefresh(context)
}
}
@@ -1,389 +0,0 @@
package me.khodak.claudeusage
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.SizeF
import android.widget.RemoteViews
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
class ClaudeUsageWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
ids.forEach { updateWidget(context, manager, it) }
if (PreferencesManager(context).isLoggedIn()) {
UsageUpdateWorker.schedulePeriodicRefresh(context)
UsageUpdateWorker.triggerImmediateRefresh(context)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_REFRESH) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
android.content.ComponentName(context, ClaudeUsageWidget::class.java)
)
isRefreshing = true
ids.forEach { updateWidget(context, manager, it) }
UsageUpdateWorker.triggerImmediateRefresh(context)
}
}
companion object {
const val ACTION_REFRESH = "me.khodak.claudeusage.ACTION_REFRESH"
@Volatile internal var isRefreshing = false
@Volatile internal var currentRotation = 0f
/** Redraw every placed widget from the current cached data (call after a refresh). */
fun notifyDataChanged(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
android.content.ComponentName(context, ClaudeUsageWidget::class.java)
)
ids.forEach { updateWidget(context, manager, it) }
}
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
val prefs = PreferencesManager(context)
val apiData = prefs.getUsageData()
val views = responsiveViews(context, prefs, apiData, widgetId)
manager.updateAppWidget(widgetId, views)
}
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int, data: UsageData?) {
val prefs = PreferencesManager(context)
val views = responsiveViews(context, prefs, data, widgetId)
manager.updateAppWidget(widgetId, views)
}
private fun responsiveViews(
context: Context, prefs: PreferencesManager, apiData: UsageData?, widgetId: Int
): RemoteViews {
val refreshPi = PendingIntent.getBroadcast(
context, widgetId,
Intent(context, ClaudeUsageWidget::class.java).apply { action = ACTION_REFRESH },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val openPi = PendingIntent.getActivity(
context, widgetId + 1000,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
fun attach(v: RemoteViews): RemoteViews {
v.setOnClickPendingIntent(R.id.btn_refresh, refreshPi)
v.setOnClickPendingIntent(R.id.widget_root, openPi)
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) {
RemoteViews(mapOf(
SizeF(50f, 50f) to attach(buildSmallViews(context, prefs, apiData)),
SizeF(50f, 120f) to attach(buildFull())
))
} else {
attach(buildFull())
}
}
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)
applyPeak(v, showText = false)
if (!prefs.isLoggedIn()) {
v.setTextViewText(R.id.tv_session_value, "")
v.setTextViewText(R.id.tv_session_label, "Not signed in")
v.setTextViewText(R.id.tv_weekly_value, "")
v.setTextViewText(R.id.tv_weekly_label, "")
v.setTextViewText(R.id.tv_status, "")
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
}
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.setTextViewText(R.id.tv_session_value, "$pct%")
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
}
hasApiMessages -> {
val rem = apiData!!.effectiveRemaining
val lim = apiData.messagesLimit
v.setTextViewText(R.id.tv_session_value, "$rem/$lim")
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
}
sessionStart > 0 -> {
val elapsedMs = System.currentTimeMillis() - sessionStart
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
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.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "active")
}
else -> {
v.setTextViewText(R.id.tv_session_value, "")
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "")
}
}
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.setTextViewText(R.id.tv_weekly_value, "$wPct%")
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null))
v.setTextViewText(R.id.tv_weekly_label, formatResetShort(apiData.weeklyResetAtEpoch))
} else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
v.setTextViewText(R.id.tv_weekly_label, "")
}
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 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)
applyPeak(v, showText = true)
// ── Not logged in ────────────────────────────────────────────────
if (!prefs.isLoggedIn()) {
v.setTextViewText(R.id.tv_session_value, "")
v.setTextViewText(R.id.tv_session_label, "Not signed in")
v.setTextViewText(R.id.tv_weekly_value, "")
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
v.setTextViewText(R.id.tv_status, "")
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
}
// ── 5-hour window bar ────────────────────────────────────────────
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.setTextViewText(R.id.tv_session_value, "$pct%")
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
}
hasApiMessages -> {
val rem = apiData!!.effectiveRemaining
val lim = apiData.messagesLimit
v.setTextViewText(R.id.tv_session_value, "$rem / $lim")
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
}
sessionStart > 0 -> {
val elapsedMs = System.currentTimeMillis() - sessionStart
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
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"
v.setTextViewText(R.id.tv_session_value, display)
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "session active")
}
else -> {
v.setTextViewText(R.id.tv_session_value, "")
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
v.setTextViewText(R.id.tv_session_label, "")
}
}
// ── 7-day usage bar ──────────────────────────────────────────────
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.setTextViewText(R.id.tv_weekly_value, "$wPct%")
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null))
v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch))
} else {
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
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
}
/**
* 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 WEEKLY_FILL = 0xFF7B8FCC.toInt()
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color pace marker (weekly only)
/** 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 {
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 with weekday + date ("Resets Friday, Jun 6 · 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"
}
/** Compact weekly reset for the space-tight small widget: "Fri, Jun 6". */
private fun formatResetShort(epochMs: Long): String {
if (epochMs <= 0) return ""
if (epochMs <= System.currentTimeMillis()) return "soon"
return SimpleDateFormat("EEE, MMM d", Locale.US).format(Date(epochMs))
}
private fun formatTime(ms: Long) =
SimpleDateFormat("h:mm a", Locale.US).format(Date(ms))
}
}
@@ -1,131 +0,0 @@
package me.khodak.claudeusage
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
import me.khodak.claudeusage.data.UsageSnapshot
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Lightweight line chart for usage history, hand-drawn on Canvas to stay dependency-free
* and consistent with [BarRenderer]. Plots session (orange) and weekly (blue) utilization
* 0-100% over time. Gaps longer than [GAP_MS] are not connected, so an offline stretch
* shows as a break rather than a misleading straight line.
*/
class HistoryChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : View(context, attrs, defStyle) {
private var points: List<UsageSnapshot> = emptyList()
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF2A2A2A.toInt(); strokeWidth = dp(1f); style = Paint.Style.STROKE
}
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF666666.toInt(); textSize = sp(10f)
}
private val sessionPaint = linePaint(0xFFCC785C.toInt())
private val weeklyPaint = linePaint(0xFF7B8FCC.toInt())
private val emptyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF666666.toInt(); textSize = sp(13f); textAlign = Paint.Align.CENTER
}
private val padL = dp(28f)
private val padR = dp(8f)
private val padT = dp(10f)
private val padB = dp(18f)
fun setData(data: List<UsageSnapshot>) {
points = data
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val left = padL
val top = padT
val right = width - padR
val bottom = height - padB
if (right <= left || bottom <= top) return
// Y gridlines + labels at 0 / 50 / 100%
for (pct in intArrayOf(0, 50, 100)) {
val y = bottom - (pct / 100f) * (bottom - top)
canvas.drawLine(left, y, right, y, gridPaint)
canvas.drawText("$pct", dp(4f), y + sp(3.5f), labelPaint)
}
val plottable = points.filter { it.sessionPct >= 0f || it.weeklyPct >= 0f }
if (plottable.size < 2) {
canvas.drawText(
"Collecting history… check back later",
(left + right) / 2f, (top + bottom) / 2f, emptyPaint
)
return
}
val tMin = plottable.first().epochMs
val tMax = plottable.last().epochMs
val tSpan = (tMax - tMin).coerceAtLeast(1L)
fun x(ms: Long) = left + (ms - tMin).toFloat() / tSpan * (right - left)
fun y(pct: Float) = bottom - (pct / 100f) * (bottom - top)
drawSeries(canvas, plottable, sessionPaint, ::x, ::y) { it.sessionPct }
drawSeries(canvas, plottable, weeklyPaint, ::x, ::y) { it.weeklyPct }
// X axis time labels (start … end)
val fmt = SimpleDateFormat("MMM d, h a", Locale.US)
labelPaint.textAlign = Paint.Align.LEFT
canvas.drawText(fmt.format(Date(tMin)), left, height - dp(4f), labelPaint)
labelPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(fmt.format(Date(tMax)), right, height - dp(4f), labelPaint)
labelPaint.textAlign = Paint.Align.LEFT
}
private inline fun drawSeries(
canvas: Canvas,
data: List<UsageSnapshot>,
paint: Paint,
x: (Long) -> Float,
y: (Float) -> Float,
value: (UsageSnapshot) -> Float
) {
val path = Path()
var penDown = false
var prevMs = 0L
for (p in data) {
val v = value(p)
if (v < 0f) { penDown = false; continue }
val px = x(p.epochMs); val py = y(v)
if (!penDown || p.epochMs - prevMs > GAP_MS) {
path.moveTo(px, py)
} else {
path.lineTo(px, py)
}
penDown = true
prevMs = p.epochMs
}
canvas.drawPath(path, paint)
}
private fun linePaint(c: Int) = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = c; strokeWidth = dp(2f); style = Paint.Style.STROKE
strokeJoin = Paint.Join.ROUND; strokeCap = Paint.Cap.ROUND
}
private fun dp(v: Float) = v * resources.displayMetrics.density
private fun sp(v: Float) = v * resources.displayMetrics.scaledDensity
companion object {
// Don't connect points separated by more than ~35 min (a missed refresh cycle or two).
private const val GAP_MS = 35 * 60 * 1000L
}
}
@@ -1,222 +0,0 @@
package me.khodak.claudeusage
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.os.Bundle
import android.view.View
import android.webkit.*
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.databinding.ActivityLoginBinding
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private lateinit var prefs: PreferencesManager
private var loginHandled = false
// JS injected before page scripts run — hides WebView fingerprints
private val antiDetectionJs = """
(function() {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = { runtime: {} };
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3] });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US','en'] });
})();
""".trimIndent()
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = PreferencesManager(this)
setupTabs()
setupWebView()
setupCookiePanel()
}
private fun setupTabs() {
binding.tabBrowser.setOnClickListener {
binding.panelBrowser.visibility = View.VISIBLE
binding.panelCookie.visibility = View.GONE
binding.tabBrowser.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFFCC785C.toInt())
binding.tabBrowser.setTextColor(0xFFFFFFFF.toInt())
binding.tabCookie.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFF2A2A2A.toInt())
binding.tabCookie.setTextColor(0xFF888888.toInt())
}
binding.tabCookie.setOnClickListener {
binding.panelBrowser.visibility = View.GONE
binding.panelCookie.visibility = View.VISIBLE
binding.tabCookie.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFFCC785C.toInt())
binding.tabCookie.setTextColor(0xFFFFFFFF.toInt())
binding.tabBrowser.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFF2A2A2A.toInt())
binding.tabBrowser.setTextColor(0xFF888888.toInt())
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
binding.btnDone.setOnClickListener { attemptCookieCapture(force = true) }
with(binding.webView) {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = false
javaScriptCanOpenWindowsAutomatically = 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
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/120.0.6099.230 Mobile Safari/537.36"
}
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val uri = request.url
val url = uri.toString()
// Keep blocking app-redirect schemes outright.
if (url.startsWith("market://") || url.startsWith("intent://")) return true
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?) {
// Inject before page scripts
view.evaluateJavascript(antiDetectionJs, null)
}
override fun onPageFinished(view: WebView, url: String) {
// Re-inject after page load
view.evaluateJavascript(antiDetectionJs, null)
if (url.contains("claude.ai")) {
binding.btnDone.visibility = View.VISIBLE
}
if (loginHandled) return
val onMain = url.startsWith("https://claude.ai") &&
!url.contains("/login") && !url.contains("/auth") &&
!url.contains("/sign") && !url.contains("/verify")
if (onMain) attemptCookieCapture(force = false)
}
}
loadUrl("https://claude.ai/login")
}
}
private fun setupCookiePanel() {
binding.btnSaveCookie.setOnClickListener {
val cookie = binding.etCookie.text.toString().trim()
if (cookie.length < 10) {
Toast.makeText(this, "Cookie too short — paste the full string", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
handleLoginSuccess(cookie)
}
}
private fun attemptCookieCapture(force: Boolean) {
if (loginHandled) return
CookieManager.getInstance().flush()
val cookies = CookieManager.getInstance().getCookie("https://claude.ai") ?: ""
if (cookies.length > 10) {
loginHandled = true
handleLoginSuccess(cookies)
} else if (force) {
Toast.makeText(this, "No session found — finish logging in first, or use the Paste Cookie tab", Toast.LENGTH_LONG).show()
}
}
private fun handleLoginSuccess(cookies: String) {
prefs.saveCookies(cookies)
prefs.saveSessionStart(System.currentTimeMillis())
prefs.markTodayActive()
Toast.makeText(this, "Signed in — loading usage…", Toast.LENGTH_SHORT).show()
UsageUpdateWorker.triggerImmediateRefresh(this)
UsageUpdateWorker.schedulePeriodicRefresh(this)
lifecycleScope.launch(Dispatchers.IO) {
try {
val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data)
val mgr = AppWidgetManager.getInstance(this@LoginActivity)
val ids = mgr.getAppWidgetIds(ComponentName(this@LoginActivity, ClaudeUsageWidget::class.java))
ids.forEach { id -> ClaudeUsageWidget.updateWidget(this@LoginActivity, mgr, id, data) }
} catch (_: Exception) {}
}
setResult(RESULT_OK)
finish()
}
override fun onBackPressed() {
if (binding.panelBrowser.visibility == View.VISIBLE && binding.webView.canGoBack())
binding.webView.goBack()
else super.onBackPressed()
}
override fun onDestroy() {
binding.webView.destroy()
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"
)
}
}
@@ -1,314 +0,0 @@
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 — 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() {
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()
}
}
@@ -1,310 +0,0 @@
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()
}
}
@@ -1,161 +0,0 @@
package me.khodak.claudeusage
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
/**
* Fires exactly two alerts per metric — one at 90% and one at 100% — and no more.
*
* Uses hysteresis, not the API's reset timestamp: each level fires once when usage first
* crosses it, and only re-arms after usage drops back below that level (i.e. a new window /
* usage reset). That keeps it quiet even if the reset time drifts between fetches. Only the
* background worker calls this — never the in-app refresh loop — so you're not pinged while
* you're already looking at the app.
*/
object Notifier {
private const val CHANNEL_ID = "usage_alerts"
private const val AUTH_CHANNEL_ID = "session_alerts"
private const val AUTH_NOTIF_ID = 3000
private val LEVELS = intArrayOf(90, 100)
/**
* Fired once when the saved claude.ai session is cleared after repeated 401/403s — without
* this the widget would just silently go stale and the user would have to notice the dead
* data themselves. Tapping opens LoginActivity straight to the cookie re-paste flow. Posts on
* its own channel so it can be muted independently of usage alerts, and is not gated on the
* usage-alert toggle: a dead widget is a functional failure the user needs to know about.
*/
fun notifySessionExpired(context: Context) {
val mgr = NotificationManagerCompat.from(context)
if (!mgr.areNotificationsEnabled()) return
ensureAuthChannel(context)
val intent = Intent(context, LoginActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pi = PendingIntent.getActivity(
context, 1, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notif = NotificationCompat.Builder(context, AUTH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_claude_burst)
.setContentTitle("Claude sign-in expired")
.setContentText("Tap to sign in again — the widget can't update until you do.")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pi)
.build()
try {
mgr.notify(AUTH_NOTIF_ID, notif)
} catch (_: SecurityException) {
// Notifications revoked between the check and post — nothing to do.
}
}
fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) {
if (!prefs.isNotifyEnabled()) return
val mgr = NotificationManagerCompat.from(context)
if (!mgr.areNotificationsEnabled()) return // OS-level or runtime permission off
ensureChannel(context)
evaluate(context, mgr, prefs, "session", data.fiveHourUtilization, "5-hour session")
evaluate(context, mgr, prefs, "weekly", data.weeklyUtilization, "weekly")
}
private fun evaluate(
context: Context,
mgr: NotificationManagerCompat,
prefs: PreferencesManager,
metric: String,
utilization: Float,
label: String
) {
if (utilization < 0f) return
val util = utilization.toInt()
for ((i, level) in LEVELS.withIndex()) {
val key = "${metric}_$level"
if (util >= level) {
if (!prefs.wasNotified(key)) {
fire(context, mgr, notifId(metric, i), level, label, util)
prefs.setNotified(key, true)
}
} else if (prefs.wasNotified(key)) {
prefs.setNotified(key, false) // dropped below the line → re-arm for next window
}
}
}
private fun fire(
context: Context,
mgr: NotificationManagerCompat,
notifId: Int,
level: Int,
label: String,
util: Int
) {
val title: String
val body: String
if (level >= 100) {
title = "${label.replaceFirstChar { it.uppercase() }} limit reached"
body = "You've hit 100% of your $label limit."
} else {
title = "${label.replaceFirstChar { it.uppercase() }} at $util%"
body = "You're at $level% of your $label limit."
}
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_claude_burst)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(openAppIntent(context))
.build()
try {
mgr.notify(notifId, notif)
} catch (_: SecurityException) {
// Notifications revoked between the check and post — nothing to do.
}
}
// Stable id per (metric, level) so re-posts replace rather than stack.
private fun notifId(metric: String, levelIndex: Int) =
2000 + (if (metric == "weekly") 10 else 0) + levelIndex
private fun ensureAuthChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (mgr.getNotificationChannel(AUTH_CHANNEL_ID) != null) return
mgr.createNotificationChannel(
NotificationChannel(
AUTH_CHANNEL_ID, "Sign-in alerts", NotificationManager.IMPORTANCE_HIGH
).apply { description = "Tells you when your claude.ai session expires and needs re-auth" }
)
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID, "Usage alerts", NotificationManager.IMPORTANCE_DEFAULT
).apply { description = "Alerts at 90% and 100% of your Claude usage limits" }
)
}
private fun openAppIntent(context: Context): PendingIntent {
val intent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
return PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}
@@ -1,65 +0,0 @@
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)
}
}
@@ -1,80 +0,0 @@
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
}
}
@@ -1,120 +0,0 @@
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
}
}
@@ -1,30 +0,0 @@
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
}
@@ -1,365 +0,0 @@
package me.khodak.claudeusage
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.khodak.claudeusage.BuildConfig
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
class UsageRepository(private val prefs: PreferencesManager) {
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.followRedirects(false)
.build()
private val desktopUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private val debugBuf = StringBuilder()
val lastDebugInfo: String get() = debugBuf.toString()
suspend fun fetchUsage(): UsageData = withContext(Dispatchers.IO) {
debugBuf.clear()
val cookies = prefs.getCookies()
?: return@withContext UsageData(errorMessage = "Not logged in")
prefs.markTodayActive()
val base = UsageData(
isLoggedIn = true,
sessionStartEpoch = prefs.getSessionStart(),
weeklyActiveDaysMask = prefs.getWeeklyMask(),
lastUpdated = System.currentTimeMillis()
)
// Step 1: get org ID
var orgId = prefs.getOrgId()
var orgUsageData: UsageData? = null
val (fetchedId, fetchedUsage) = fetchOrgInfo(cookies)
if (orgId == null && fetchedId != null) {
orgId = fetchedId
prefs.saveOrgId(orgId)
}
orgUsageData = fetchedUsage
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
// NOTE: message-count data from the org endpoint (orgUsageData) is a FALLBACK only — do
// NOT return here. The /usage utilization endpoint below is the preferred signal and the
// one the weekly bar + in-app history chart depend on, so it must be attempted whenever we
// have an org id. Returning early here was why the history chart stayed empty: any account
// whose org JSON carried a message limit short-circuited before /usage, so
// fiveHourUtilization/weeklyUtilization never got populated and recordHistory() had nothing
// to store. The org message data is used only if /usage yields nothing (fallback below).
// Step 2: /usage endpoint — returns utilization percentages (preferred signal)
try {
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
val code = resp.code
if (BuildConfig.DEBUG) Log.d(TAG, "GET $usageUrl$code")
if (code == 401 || code == 403) {
prefs.clearSession()
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
}
val body = resp.body?.string() ?: ""
if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n")
val utilData = tryParseUtilizationBody(body)
if (utilData != null) {
prefs.resetAuthFailCount()
return@withContext base.copy(
fiveHourUtilization = utilData.fiveHourUtilization,
weeklyUtilization = utilData.weeklyUtilization,
utilizationResetAtEpoch = utilData.utilizationResetAtEpoch,
weeklyResetAtEpoch = utilData.weeklyResetAtEpoch
)
}
} catch (e: Exception) {
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)
val endpoints = listOf(
"https://claude.ai/api/organizations/$orgId/rate_limit_status",
"https://claude.ai/api/organizations/$orgId"
)
for (url in endpoints) {
try {
val req = buildRequest(url, cookies)
val resp = client.newCall(req).execute()
val code = resp.code
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code")
if (code == 401 || code == 403) {
if (prefs.incAuthFailCount() >= AUTH_FAIL_LIMIT) {
prefs.clearSession()
prefs.resetAuthFailCount()
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
}
// Transient auth failure — keep showing last-good data instead of logging out.
return@withContext base
}
val rateLimitData = extractRateLimitHeaders(resp.headers)
val body = resp.body?.string() ?: ""
if (BuildConfig.DEBUG) {
debugBuf.append("$url\n$code: ${body.take(300)}\n\n")
Log.d(TAG, "Body: ${body.take(300)}")
}
val parsed = tryParseUsageBody(body, rateLimitData)
if (parsed.hasRateLimitData) {
return@withContext base.copy(
messagesUsed = parsed.messagesUsed,
messagesLimit = parsed.messagesLimit,
messagesRemaining = parsed.messagesRemaining,
resetAtEpoch = parsed.resetAtEpoch,
isRateLimited = parsed.isRateLimited
)
}
} catch (e: Exception) {
if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
if (BuildConfig.DEBUG) debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
}
}
// Step 3: try /api/me endpoint
try {
val req = buildRequest("https://claude.ai/api/me", cookies)
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
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("<")) {
val json = JSONObject(body)
val parsed = tryParseOrgForUsage(json)
if (parsed?.hasRateLimitData == true) {
return@withContext base.copy(
messagesUsed = parsed.messagesUsed,
messagesLimit = parsed.messagesLimit,
messagesRemaining = parsed.messagesRemaining,
resetAtEpoch = parsed.resetAtEpoch
)
}
}
} catch (e: Exception) {
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
}
// Step 4: try page HTML for __NEXT_DATA__
val htmlData = fetchFromPageHtml(cookies)
if (htmlData?.hasRateLimitData == true) {
return@withContext base.copy(
messagesUsed = htmlData.messagesUsed,
messagesLimit = htmlData.messagesLimit,
messagesRemaining = htmlData.messagesRemaining,
resetAtEpoch = htmlData.resetAtEpoch
)
}
base
}
private fun fetchFromPageHtml(cookies: String): UsageData? {
return try {
val resp = client.newCall(buildRequest("https://claude.ai/", cookies)).execute()
val html = resp.body?.string() ?: return null
val marker = """<script id="__NEXT_DATA__" type="application/json">"""
val start = html.indexOf(marker)
if (start < 0) {
if (BuildConfig.DEBUG) debugBuf.append("HTML: no __NEXT_DATA__ found\n")
return null
}
val jsonStart = start + marker.length
val jsonEnd = html.indexOf("</script>", jsonStart)
if (jsonEnd < 0) return null
val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
val topKeys = nextData.keys().asSequence().toList()
if (BuildConfig.DEBUG) debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
tryExtractFromNextData(nextData)
} catch (e: Exception) {
if (BuildConfig.DEBUG) debugBuf.append("HTML scrape error: ${e.message}\n")
null
}
}
private fun tryExtractFromNextData(json: JSONObject): UsageData? {
// Walk common Next.js page prop paths looking for usage data
val paths = listOf(
listOf("props", "pageProps", "rateLimits"),
listOf("props", "pageProps", "usage"),
listOf("props", "pageProps", "account"),
listOf("props", "pageProps", "organization"),
listOf("props", "pageProps"),
)
for (path in paths) {
var obj: Any? = json
for (key in path) {
obj = (obj as? JSONObject)?.opt(key)
}
val o = obj as? JSONObject ?: continue
if (BuildConfig.DEBUG) debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
if (usage.hasRateLimitData) return usage
}
return null
}
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
return try {
val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute()
if (BuildConfig.DEBUG) Log.d(TAG, "Orgs → ${resp.code}")
val body = resp.body?.string() ?: return Pair(null, null)
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)
val arr = JSONArray(body)
val org = arr.optJSONObject(0) ?: return Pair(null, null)
val orgId = org.optString("uuid").ifBlank { null }
val usageData = tryParseOrgForUsage(org)
Pair(orgId, usageData)
} catch (e: Exception) {
Log.w(TAG, "fetchOrgInfo: ${e.message}")
Pair(null, null)
}
}
private fun tryParseOrgForUsage(org: JSONObject): UsageData? {
val limit = org.optInt("message_limit", -1).takeIf { it > 0 }
?: org.optJSONObject("capabilities")?.optInt("message_limit", -1)?.takeIf { it > 0 }
?: org.optJSONObject("usage")?.optInt("limit", -1)?.takeIf { it > 0 }
?: org.optJSONObject("rate_limit")?.optInt("limit", -1)?.takeIf { it > 0 }
?: org.optInt("limit", -1).takeIf { it > 0 }
val used = org.optInt("messages_sent", -1).takeIf { it >= 0 }
?: org.optJSONObject("usage")?.optInt("messages_sent", -1)?.takeIf { it >= 0 }
?: org.optJSONObject("rate_limit")?.optInt("used", -1)?.takeIf { it >= 0 }
?: org.optInt("used", -1).takeIf { it >= 0 }
val remaining = org.optInt("messages_remaining", -1).takeIf { it >= 0 }
?: org.optJSONObject("usage")?.optInt("remaining", -1)?.takeIf { it >= 0 }
?: org.optJSONObject("rate_limit")?.optInt("remaining", -1)?.takeIf { it >= 0 }
?: org.optInt("remaining", -1).takeIf { it >= 0 }
val resetStr = org.optString("usage_reset_at", "")
.ifBlank { org.optJSONObject("usage")?.optString("reset_at", "") ?: "" }
.ifBlank { org.optJSONObject("rate_limit")?.optString("reset_at", "") ?: "" }
if (limit == null && used == null && remaining == null) return null
return UsageData(
messagesLimit = limit ?: -1,
messagesUsed = used ?: -1,
messagesRemaining = remaining ?: -1,
resetAtEpoch = if (resetStr.isNotBlank()) parseResetTime(resetStr) else -1L
)
}
// Parses the /usage endpoint response: {"five_hour":{"utilization":75.0,"resets_at":"..."},...}
private fun tryParseUtilizationBody(body: String): UsageData? {
if (body.isBlank() || body.startsWith("<")) return null
return try {
val json = JSONObject(body)
val fiveHour = json.optJSONObject("five_hour") ?: return null
val utilization = fiveHour.optDouble("utilization", -1.0).toFloat()
if (utilization < 0f) return null
val resetsAt = fiveHour.optString("resets_at", "")
val sevenDay = json.optJSONObject("seven_day")
val weeklyUtil = sevenDay?.optDouble("utilization", -1.0)?.toFloat() ?: -1f
val weeklyResetsAt = sevenDay?.optString("resets_at", "") ?: ""
UsageData(
fiveHourUtilization = utilization,
weeklyUtilization = weeklyUtil,
utilizationResetAtEpoch = if (resetsAt.isNotBlank()) parseResetTime(resetsAt) else -1L,
weeklyResetAtEpoch = if (weeklyResetsAt.isNotBlank()) parseResetTime(weeklyResetsAt) else -1L
)
} catch (e: Exception) { null }
}
private fun extractRateLimitHeaders(headers: okhttp3.Headers): UsageData {
val remaining = headers["anthropic-ratelimit-requests-remaining"]?.toIntOrNull()
val limit = headers["anthropic-ratelimit-requests-limit"]?.toIntOrNull()
val reset = headers["anthropic-ratelimit-requests-reset"]
return UsageData(
messagesRemaining = remaining ?: -1,
messagesLimit = limit ?: -1,
resetAtEpoch = parseResetTime(reset)
)
}
private fun tryParseUsageBody(body: String, base: UsageData): UsageData {
if (body.isBlank() || body.startsWith("<")) return base
return try {
val json = JSONObject(body)
val used = json.optInt("messages_sent", -1).takeIf { it >= 0 }
?: json.optInt("message_count", -1).takeIf { it >= 0 }
?: json.optInt("used", -1).takeIf { it >= 0 }
?: -1
val limit = json.optInt("messages_limit", -1).takeIf { it >= 0 }
?: json.optInt("limit", -1).takeIf { it >= 0 }
?: base.messagesLimit
val remaining = json.optInt("messages_remaining", -1).takeIf { it >= 0 }
?: json.optInt("remaining", -1).takeIf { it >= 0 }
?: base.messagesRemaining
val reset = json.optString("reset_at", "").ifBlank { json.optString("resets_at", "") }
base.copy(
messagesUsed = used,
messagesLimit = limit,
messagesRemaining = remaining,
resetAtEpoch = if (reset.isNotBlank()) parseResetTime(reset) else base.resetAtEpoch
)
} catch (e: Exception) { base }
}
private fun parseResetTime(value: String?): Long {
if (value.isNullOrBlank()) return -1L
// Normalize: truncate sub-second digits beyond 3 (e.g. microseconds → milliseconds)
val normalized = value.replace(Regex("(\\.\\d{3})\\d+"), "$1")
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'"
)
for (fmt in formats) {
try {
val sdf = SimpleDateFormat(fmt, Locale.US).also { it.timeZone = TimeZone.getTimeZone("UTC") }
return sdf.parse(normalized)?.time ?: continue
} catch (_: Exception) {}
}
return value.toLongOrNull()?.let { System.currentTimeMillis() + it * 1000 } ?: -1L
}
private fun buildRequest(url: String, cookies: String) = Request.Builder()
.url(url)
.header("User-Agent", desktopUA)
.header("Accept", "application/json, */*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Referer", "https://claude.ai/")
.header("Cookie", cookies.replace("\r", "").replace("\n", ""))
.get()
.build()
companion object {
private const val TAG = "UsageRepo"
// Clear the session only after this many consecutive 401/403s, so one transient
// auth failure (Cloudflare challenge, brief edge hiccup) doesn't sign the user out.
private const val AUTH_FAIL_LIMIT = 3
}
}
@@ -1,138 +0,0 @@
package me.khodak.claudeusage
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import androidx.work.*
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.khodak.claudeusage.data.PreferencesManager
import java.util.concurrent.TimeUnit
class UsageUpdateWorker(
private val context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
val prefs = PreferencesManager(context)
if (!prefs.isLoggedIn()) return Result.success()
prefs.markTodayActive()
coroutineScope {
val animJob = launch { rotateRefreshIcon() }
try {
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.
prefs.saveUsageData(data.mergedWith(prefs.getUsageData()))
prefs.recordHistory(data) // history records only fresh readings
Notifier.checkAndNotify(context, prefs, data)
}
} catch (_: Exception) {}
animJob.cancel()
animJob.join()
}
pushWidgetUpdate()
return Result.success()
}
private suspend fun rotateRefreshIcon() {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(ComponentName(context, ClaudeUsageWidget::class.java))
val startMs = System.currentTimeMillis()
val msPerRotation = 800L // one full rotation every 0.8 seconds
fun angleAt(now: Long) = ((now - startMs) % msPerRotation) * 360f / msPerRotation
try {
while (true) {
ClaudeUsageWidget.currentRotation = angleAt(System.currentTimeMillis())
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
delay(16) // aim for ~60fps; IPC speed sets the real ceiling
}
} finally {
// Finish the current rotation cleanly — run until at least one full spin
withContext(NonCancellable) {
val minEndMs = startMs + msPerRotation
while (System.currentTimeMillis() < minEndMs) {
ClaudeUsageWidget.currentRotation = angleAt(System.currentTimeMillis())
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
delay(16)
}
}
}
}
private fun pushWidgetUpdate() {
ClaudeUsageWidget.isRefreshing = false
ClaudeUsageWidget.currentRotation = 0f
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(ComponentName(context, ClaudeUsageWidget::class.java))
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
}
companion object {
private const val WORK_ONE_SHOT = "claude_oneshot"
private const val WORK_PERIODIC = "claude_periodic"
private const val ALARM_CODE = 1001
private const val INTERVAL_MS = 5 * 60 * 1000L
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
am.setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + INTERVAL_MS,
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) {
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
.cancel(alarmIntent(context))
WorkManager.getInstance(context).cancelUniqueWork(WORK_PERIODIC)
}
fun triggerImmediateRefresh(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_ONE_SHOT,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<UsageUpdateWorker>().build()
)
}
private fun alarmIntent(context: Context) = PendingIntent.getBroadcast(
context, ALARM_CODE,
Intent(context, AlarmReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}
@@ -1,263 +0,0 @@
package me.khodak.claudeusage.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.Calendar
class PreferencesManager(context: Context) {
private val gson = Gson()
private var usingFallbackPrefs = false
private val securePrefs = createSecurePrefs(context, onFallback = { usingFallbackPrefs = true })
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
fun saveCookies(cookies: String) {
// Never store cookies in plain-text fallback prefs
if (usingFallbackPrefs) return
try {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
} catch (_: Exception) {}
}
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)
} catch (_: Exception) { null }
}
fun clearSession() {
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).apply()
}
fun saveOrgId(id: String) = prefs.edit().putString(KEY_ORG_ID, id).apply()
fun getOrgId(): String? = prefs.getString(KEY_ORG_ID, null)
fun saveSessionStart(epochMs: Long) = prefs.edit().putLong(KEY_SESSION_START, epochMs).apply()
fun getSessionStart(): Long = prefs.getLong(KEY_SESSION_START, 0L)
fun markTodayActive() {
val cal = Calendar.getInstance()
val dayBit = 1 shl cal.get(Calendar.DAY_OF_WEEK) - 1
val weekKey = getWeekKey(cal)
// Reset mask if it's a new week
val storedWeek = prefs.getString(KEY_ACTIVE_WEEK, "")
val currentMask = if (storedWeek == weekKey) prefs.getInt(KEY_ACTIVE_MASK, 0) else 0
prefs.edit()
.putString(KEY_ACTIVE_WEEK, weekKey)
.putInt(KEY_ACTIVE_MASK, currentMask or dayBit)
.apply()
}
fun getWeeklyMask(): Int {
val cal = Calendar.getInstance()
val weekKey = getWeekKey(cal)
val storedWeek = prefs.getString(KEY_ACTIVE_WEEK, "")
return if (storedWeek == weekKey) prefs.getInt(KEY_ACTIVE_MASK, 0) else 0
}
private fun getWeekKey(cal: Calendar): String {
val year = cal.get(Calendar.YEAR)
val week = cal.get(Calendar.WEEK_OF_YEAR)
return "$year-$week"
}
fun saveUsageData(data: UsageData) =
prefs.edit().putString(KEY_USAGE_DATA, gson.toJson(data)).apply()
fun getUsageData(): UsageData? = try {
prefs.getString(KEY_USAGE_DATA, null)?.let { gson.fromJson(it, UsageData::class.java) }
} catch (e: Exception) { null }
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
// Consecutive 401/403 counter — we only clear the session after several in a row, so a
// single transient auth failure (e.g. a Cloudflare challenge) doesn't log the user out.
fun getAuthFailCount(): Int = prefs.getInt(KEY_AUTH_FAILS, 0)
fun incAuthFailCount(): Int {
val n = getAuthFailCount() + 1
prefs.edit().putInt(KEY_AUTH_FAILS, n).apply()
return n
}
fun resetAuthFailCount() {
if (getAuthFailCount() != 0) prefs.edit().putInt(KEY_AUTH_FAILS, 0).apply()
}
// ── Usage history (for the in-app chart) ─────────────────────────────────
/**
* 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
* 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].
*/
fun recordHistory(data: UsageData) {
val sessionPct = data.sessionReadingPct
if (sessionPct < 0f && data.weeklyUtilization < 0f) return
val now = System.currentTimeMillis()
val history = getHistory().toMutableList()
// Throttle to at most one point per MIN_HISTORY_GAP_MS by SKIPPING a too-soon reading.
// (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(
UsageSnapshot(
epochMs = now,
sessionPct = sessionPct,
weeklyPct = data.weeklyUtilization
)
)
val cutoff = now - HISTORY_RETENTION_MS
val pruned = history.filter { it.epochMs >= cutoff }
.takeLast(MAX_HISTORY_POINTS)
prefs.edit().putString(KEY_HISTORY, gson.toJson(pruned)).apply()
}
fun getHistory(): List<UsageSnapshot> {
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<UsageSnapshot>>() {}.type
gson.fromJson<List<UsageSnapshot>>(json, type) ?: 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 ────────────────────────────────────────────────
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
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,
* cleared when usage drops back below it — so each level fires once per window.
*/
fun wasNotified(key: String): Boolean = prefs.getBoolean("notified_$key", false)
fun setNotified(key: String, v: Boolean) =
prefs.edit().putBoolean("notified_$key", v).apply()
companion object {
private const val KEY_COOKIES = "session_cookies"
private const val KEY_ORG_ID = "org_id"
private const val KEY_SESSION_START = "session_start"
private const val KEY_USAGE_DATA = "usage_data"
private const val KEY_ACTIVE_WEEK = "active_week"
private const val KEY_ACTIVE_MASK = "active_mask"
private const val KEY_HISTORY = "usage_history"
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
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"
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 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 {
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
}
}
}
@@ -1,97 +0,0 @@
package me.khodak.claudeusage.data
data class UsageData(
val messagesUsed: Int = -1,
val messagesLimit: Int = -1,
val messagesRemaining: Int = -1,
val resetAtEpoch: Long = -1L,
val isRateLimited: Boolean = false,
val lastUpdated: Long = 0L,
val errorMessage: String = "",
val isLoggedIn: Boolean = false,
val sessionStartEpoch: Long = 0L,
val weeklyActiveDaysMask: Int = 0, // bitmask: bit0=Sun, bit1=Mon ... bit6=Sat
// From /api/organizations/{id}/usage — utilization as percentage 0-100
val fiveHourUtilization: Float = -1f,
val weeklyUtilization: Float = -1f,
val utilizationResetAtEpoch: Long = -1L,
val weeklyResetAtEpoch: Long = -1L
) {
val hasRateLimitData: Boolean get() =
messagesLimit > 0 || messagesRemaining >= 0 || fiveHourUtilization >= 0f
val hasSessionData: Boolean get() = sessionStartEpoch > 0
val progressPercent: Int get() = when {
messagesLimit > 0 && effectiveUsed >= 0 ->
((effectiveUsed.toFloat() / messagesLimit.toFloat()) * 100).toInt().coerceIn(0, 100)
fiveHourUtilization >= 0f -> fiveHourUtilization.toInt().coerceIn(0, 100)
else -> 0
}
val effectiveUsed: Int get() = when {
messagesUsed >= 0 -> messagesUsed
messagesRemaining >= 0 && messagesLimit > 0 -> messagesLimit - messagesRemaining
else -> -1
}
val effectiveRemaining: Int get() = when {
messagesRemaining >= 0 -> messagesRemaining
messagesUsed >= 0 && messagesLimit > 0 -> messagesLimit - messagesUsed
else -> -1
}
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)
val effectiveResetEpoch: Long get() = when {
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
resetAtEpoch > 0 -> resetAtEpoch
else -> -1L
}
/** True if this fetch produced any usable usage reading at all. */
val hasAnyReading: Boolean get() =
fiveHourUtilization >= 0f || weeklyUtilization >= 0f || hasRateLimitData
/**
* Merge a fresh fetch over the last cached reading so a failed or partial refresh never
* blanks the widget. If this fetch got nothing usable, the whole previous snapshot is kept
* (with its original timestamp, so the footer shows the data's true age). Otherwise each
* metric this fetch didn't return falls back to the previous value.
*/
fun mergedWith(previous: UsageData?): UsageData {
if (previous == null || !previous.hasAnyReading) return this
if (!hasAnyReading) {
// Keep last-good data; only carry this attempt's login/session context forward.
return previous.copy(
isLoggedIn = isLoggedIn,
sessionStartEpoch = if (sessionStartEpoch > 0) sessionStartEpoch else previous.sessionStartEpoch,
weeklyActiveDaysMask = if (weeklyActiveDaysMask != 0) weeklyActiveDaysMask else previous.weeklyActiveDaysMask
)
}
return copy(
fiveHourUtilization = if (fiveHourUtilization >= 0f) fiveHourUtilization else previous.fiveHourUtilization,
utilizationResetAtEpoch = if (fiveHourUtilization >= 0f) utilizationResetAtEpoch else previous.utilizationResetAtEpoch,
weeklyUtilization = if (weeklyUtilization >= 0f) weeklyUtilization else previous.weeklyUtilization,
weeklyResetAtEpoch = if (weeklyUtilization >= 0f) weeklyResetAtEpoch else previous.weeklyResetAtEpoch,
messagesLimit = if (messagesLimit > 0) messagesLimit else previous.messagesLimit,
messagesUsed = if (messagesUsed >= 0) messagesUsed else previous.messagesUsed,
messagesRemaining = if (messagesRemaining >= 0) messagesRemaining else previous.messagesRemaining,
resetAtEpoch = if (resetAtEpoch > 0) resetAtEpoch else previous.resetAtEpoch
)
}
}
@@ -1,11 +0,0 @@
package me.khodak.claudeusage.data
/**
* A single point-in-time reading of usage, stored for the in-app history chart.
* Percentages are 0-100; -1 means "no reading for this metric at this time".
*/
data class UsageSnapshot(
val epochMs: Long = 0L,
val sessionPct: Float = -1f,
val weeklyPct: Float = -1f
)
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="4dp" />
<stroke android:width="1dp" android:color="#CC785C" />
</shape>
@@ -1,29 +0,0 @@
<?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>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Claude-style hexagon "C" icon -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,18 C34.1,18 18,34.1 18,54 C18,73.9 34.1,90 54,90 C73.9,90 90,73.9 90,54 C90,34.1 73.9,18 54,18Z M54,78 C40.7,78 30,67.3 30,54 C30,40.7 40.7,30 54,30 C61.8,30 68.8,33.6 73.4,39.2 L64.9,46.6 C62.4,43.6 58.4,41.6 54,41.6 C47.1,41.6 41.6,47.1 41.6,54 C41.6,60.9 47.1,66.4 54,66.4 C58.4,66.4 62.3,64.4 64.9,61.4 L73.4,68.8 C68.8,74.4 61.8,78 54,78Z" />
</vector>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="#1E1E1E" />
</shape>
-122
View File
@@ -1,122 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#121212">
<!-- Tab bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="48dp"
android:orientation="horizontal"
android:background="#1E1E1E">
<Button
android:id="@+id/tab_browser"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="Browser Login"
android:textSize="12sp"
android:backgroundTint="#CC785C"
android:textColor="#FFFFFF" />
<Button
android:id="@+id/tab_cookie"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="Paste Cookie"
android:textSize="12sp"
android:backgroundTint="#2A2A2A"
android:textColor="#888888" />
</LinearLayout>
<!-- Browser login panel -->
<LinearLayout
android:id="@+id/panel_browser"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/btn_done"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="I'm logged in — save session"
android:backgroundTint="#CC785C"
android:textColor="#FFFFFF"
android:visibility="gone"
android:layout_margin="12dp" />
</LinearLayout>
<!-- Manual cookie panel -->
<LinearLayout
android:id="@+id/panel_cookie"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="20dp"
android:visibility="gone">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Paste Session Cookie"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="1. Open Chrome on your phone\n2. Go to claude.ai and sign in\n3. Install the &quot;Cookie-Editor&quot; extension in Kiwi Browser, or use Firefox → Settings → Privacy → Cookies\n4. Find and copy the full cookie string for claude.ai\n5. Paste it below"
android:textColor="#AAAAAA"
android:textSize="13sp"
android:lineSpacingExtra="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Simpler: open claude.ai in browser → log in → come back here and tap the Browser Login tab — then tap 'I'm logged in'"
android:textColor="#CC785C"
android:textSize="12sp"
android:lineSpacingExtra="3dp" />
<EditText
android:id="@+id/et_cookie"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginTop="20dp"
android:hint="Paste cookie string here…"
android:textColor="#FFFFFF"
android:textColorHint="#555555"
android:background="#1E1E1E"
android:padding="12dp"
android:gravity="top"
android:inputType="textMultiLine"
android:textSize="12sp" />
<Button
android:id="@+id/btn_save_cookie"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Save and connect"
android:backgroundTint="#CC785C"
android:textColor="#FFFFFF" />
</LinearLayout>
</LinearLayout>
-529
View File
@@ -1,529 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:background="#121212">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center_horizontal">
<!-- Logo / Title -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="⬡"
android:textColor="#CC785C"
android:textSize="48sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Claude Usage"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Home screen widget"
android:textColor="#888888"
android:textSize="14sp" />
<!-- Loading indicator -->
<ProgressBar
android:id="@+id/progressIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone" />
<!-- LOGGED OUT state -->
<LinearLayout
android:id="@+id/layoutLoggedOut"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="48dp"
android:gravity="center">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sign in with your Claude account to track Pro usage on your home screen widget."
android:textColor="#AAAAAA"
android:textSize="14sp"
android:textAlignment="center"
android:lineSpacingExtra="4dp" />
<Button
android:id="@+id/btnLogin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Sign in to Claude"
android:backgroundTint="#CC785C"
android:textColor="#FFFFFF" />
</LinearLayout>
<!-- LOGGED IN state -->
<LinearLayout
android:id="@+id/layoutLoggedIn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="32dp"
android:visibility="gone">
<!-- Usage card -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/widget_background"
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
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="CURRENT USAGE"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/tvUsage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="—"
android:textColor="#FFFFFF"
android:textSize="20sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/barSession"
android:layout_width="match_parent"
android:layout_height="9dp"
android:layout_marginTop="12dp"
android:scaleType="fitXY"
android:adjustViewBounds="false"
android:contentDescription="Session usage bar" />
<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
android:id="@+id/tvReset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#888888"
android:textSize="13sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="WEEKLY"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/tvWeeklyUsage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="—"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/barWeekly"
android:layout_width="match_parent"
android:layout_height="9dp"
android:layout_marginTop="8dp"
android:scaleType="fitXY"
android:adjustViewBounds="false"
android:contentDescription="Weekly usage bar" />
<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
android:id="@+id/tvWeeklyReset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#888888"
android:textSize="13sp" />
<TextView
android:id="@+id/tvUpdated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#666666"
android:textSize="12sp" />
<TextView
android:id="@+id/tvError"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#FF7070"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
<!-- History 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="wrap_content"
android:layout_height="wrap_content"
android:text="HISTORY"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<me.khodak.claudeusage.HistoryChartView
android:id="@+id/historyChart"
android:layout_width="match_parent"
android:layout_height="140dp"
android:layout_marginTop="12dp" />
<!-- Legend -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="10dp"
android:gravity="center_vertical">
<View
android:layout_width="12dp"
android:layout_height="3dp"
android:background="#CC785C" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="16dp"
android:text="Session"
android:textColor="#AAAAAA"
android:textSize="12sp" />
<View
android:layout_width="12dp"
android:layout_height="3dp"
android:background="#7B8FCC" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="Weekly"
android:textColor="#AAAAAA"
android:textSize="12sp" />
</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 -->
<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="USAGE ALERTS"
android:textColor="#888888"
android:textSize="11sp"
android:letterSpacing="0.1" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchNotify"
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="One alert at 90% and one at 100%, for both your session and weekly limits. Each fires once until usage resets."
android:textColor="#AAAAAA"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
</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
android:id="@+id/btnRefresh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Refresh Now"
android:backgroundTint="#CC785C"
android:textColor="#FFFFFF" />
<TextView
android:id="@+id/tvWidgetHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Long-press your home screen → Widgets → Claude Usage to add the widget"
android:textColor="#666666"
android:textSize="13sp"
android:textAlignment="center"
android:lineSpacingExtra="4dp"
android:padding="8dp" />
<Button
android:id="@+id/btnLogout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Sign Out"
android:backgroundTint="#2A2A2A"
android:textColor="#888888" />
<Button
android:id="@+id/btnDebug"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Show API Debug"
android:backgroundTint="#1A1A1A"
android:textColor="#555555"
android:textSize="12sp" />
<TextView
android:id="@+id/tvDebugInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:padding="12dp"
android:background="#1A1A1A"
android:textColor="#888888"
android:textSize="11sp"
android:fontFamily="monospace"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</ScrollView>
-189
View File
@@ -1,189 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<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 — View is not in RemoteViews allowlist on Android 12+; use TextView instead -->
<TextView
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="10dp"
android:background="#2A2A2A" />
<!-- 5-hour window bar -->
<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="SESSION"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_session_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="—"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:textStyle="bold" />
</LinearLayout>
<ImageView
android:id="@+id/bar_session"
android:layout_width="match_parent"
android:layout_height="6dp"
android:layout_marginTop="5dp"
android:scaleType="fitXY"
android:adjustViewBounds="false"
android:contentDescription="Session usage bar" />
<TextView
android:id="@+id/tv_session_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text=""
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<!-- 7-day window bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="10dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="WEEKLY"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_weekly_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="—"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:textStyle="bold" />
</LinearLayout>
<ImageView
android:id="@+id/bar_weekly"
android:layout_width="match_parent"
android:layout_height="6dp"
android:layout_marginTop="5dp"
android:scaleType="fitXY"
android:adjustViewBounds="false"
android:contentDescription="Weekly usage bar" />
<TextView
android:id="@+id/tv_weekly_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text=""
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<!-- Footer -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp"
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>
@@ -1,169 +0,0 @@
<?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>
@@ -1,149 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
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="12dp">
<!-- 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="11sp"
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
android:id="@+id/btn_refresh"
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@android:drawable/ic_menu_rotate"
android:background="@android:color/transparent"
android:contentDescription="Refresh" />
</LinearLayout>
<!-- SESSION row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="7dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SESSION"
android:textColor="#FFFFFF"
android:textSize="8sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_session_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="—"
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_session_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text=""
android:textColor="#FFFFFF"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
<ImageView
android:id="@+id/bar_session"
android:layout_width="match_parent"
android:layout_height="5dp"
android:layout_marginTop="3dp"
android:scaleType="fitXY"
android:adjustViewBounds="false"
android:contentDescription="Session usage bar" />
<!-- 7-DAY row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="7dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="WEEKLY"
android:textColor="#FFFFFF"
android:textSize="8sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_weekly_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="—"
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_weekly_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text=""
android:textColor="#FFFFFF"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
<ImageView
android:id="@+id/bar_weekly"
android:layout_width="match_parent"
android:layout_height="5dp"
android:layout_marginTop="3dp"
android:scaleType="fitXY"
android:adjustViewBounds="false"
android:contentDescription="Weekly usage bar" />
<TextView
android:id="@+id/tv_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text=""
android:textColor="#FFFFFF"
android:textSize="8sp"
android:textStyle="bold"
android:maxLines="1" />
</LinearLayout>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="claude_orange">#CC785C</color>
<color name="background_dark">#121212</color>
<color name="surface_dark">#1E1E1E</color>
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#888888</color>
<color name="ic_launcher_bg">#0B1D27</color>
</resources>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Claude Usage</string>
<string name="login_title">Sign in to Claude</string>
<string name="widget_description">Shows your Claude Pro message usage</string>
</resources>
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ClaudeUsage" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/claude_orange</item>
<item name="colorPrimaryVariant">@color/claude_orange</item>
<item name="colorOnPrimary">@color/text_primary</item>
<item name="android:windowBackground">@color/background_dark</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style>
</resources>
@@ -1,8 +0,0 @@
<?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>
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="55dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:minResizeWidth="180dp"
android:minResizeHeight="55dp"
android:maxResizeWidth="500dp"
android:maxResizeHeight="300dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_layout"
android:widgetCategory="home_screen"
android:description="@string/widget_description"
android:previewLayout="@layout/widget_layout" />
@@ -1,44 +0,0 @@
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))
}
}
@@ -1,75 +0,0 @@
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)
}
}
@@ -1,58 +0,0 @@
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)))
}
}
@@ -1,180 +0,0 @@
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()))
}
}
-4
View File
@@ -1,4 +0,0 @@
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
-56
View File
@@ -1,56 +0,0 @@
#!/bin/bash
# Build + install on emulator or connected device
# Usage:
# ./deploy.sh → emulator
# ./deploy.sh wifi → phone via ADB WiFi (prompts for IP if not set)
# ./deploy.sh logs → stream logs only
set -e
export ANDROID_HOME=~/android-sdk
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
GRADLE=~/gradle/gradle-8.6/bin/gradle
APK=app/build/outputs/apk/debug/app-debug.apk
PKG=me.khodak.claudeusage
# -- connect to phone over WiFi if requested
if [[ "$1" == "wifi" ]]; then
PHONE_IP="${PHONE_IP:-$(cat .phone-ip 2>/dev/null)}"
if [[ -z "$PHONE_IP" ]]; then
read -rp "Phone IP address: " PHONE_IP
echo "$PHONE_IP" > .phone-ip
fi
adb connect "$PHONE_IP:5555" 2>&1 | head -3
fi
# -- logs only
if [[ "$1" == "logs" ]]; then
echo "Streaming logs (Ctrl-C to stop)..."
adb logcat -s UsageRepo:D ClaudeWidget:D UsageWorker:D AlarmReceiver:D | sed 's/.*\(D\|W\|E\) //'
exit 0
fi
# -- build
echo "Building..."
$GRADLE assembleDebug --no-daemon -q
echo "Build OK"
# -- pick target device
DEVICES=$(adb devices | awk '/device$/{print $1}')
if [[ -z "$DEVICES" ]]; then
echo "No device/emulator connected. Start emulator with ./start-emulator.sh or enable WiFi debugging."
exit 1
fi
echo "Devices: $DEVICES"
# -- install (keep data = upgrade)
echo "Installing..."
adb install -r "$APK"
echo "Installed."
# -- launch main activity
adb shell am start -n "$PKG/.MainActivity" 2>/dev/null || true
# -- stream relevant logs
echo ""
echo "Streaming logs (Ctrl-C to stop)..."
adb logcat -c && adb logcat -s UsageRepo:D MainActivity:D | sed 's/.*[VDIWEF]\///'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

-4
View File
@@ -1,4 +0,0 @@
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.code.style=official
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
Vendored
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
Vendored
-84
View File
@@ -1,84 +0,0 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

-12
View File
@@ -1,12 +0,0 @@
# 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.
Binary file not shown.
-16
View File
@@ -1,16 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ClaudeUsage"
include(":app")
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
# Start the emulator headless (no window) — run this first, then use deploy.sh
export ANDROID_HOME=~/android-sdk
export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH"
echo "Starting emulator (headless)..."
emulator -avd claude_test -no-window -no-audio -no-snapshot -gpu swiftshader_indirect &
EMU_PID=$!
echo "Emulator PID: $EMU_PID"
echo "Waiting for device to be ready..."
adb wait-for-device
adb shell getprop sys.boot_completed | grep -q 1 || \
timeout 120 bash -c 'until adb shell getprop sys.boot_completed 2>/dev/null | grep -q 1; do sleep 3; done'
echo "Emulator ready!"