1 Commits

Author SHA1 Message Date
amir 63f1035e8a Initial commit 2026-05-22 01:49:35 +00:00
63 changed files with 2 additions and 3605 deletions
-103
View File
@@ -1,103 +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
- 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"
-12
View File
@@ -1,12 +0,0 @@
*.iml
.gradle/
local.properties
.idea/
.DS_Store
/build/
app/build/
captures/
.externalNativeBuild/
.cxx/
*.keystore
*.jks
+2 -42
View File
@@ -1,43 +1,3 @@
<p align="center">
<img src="icon.png" width="108" alt="Claude Usage Widget">
</p>
# claude-usage-widget
# Claude Usage Widget
Android home screen widget that shows your Claude Pro usage at a glance.
## 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
## 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
Claude Pro usage Android widget
-75
View File
@@ -1,75 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "me.khodak.claudeusage"
compileSdk = 34
defaultConfig {
applicationId = "me.khodak.claudeusage"
minSdk = 26
targetSdk = 34
versionCode = 19
versionName = "1.18"
}
signingConfigs {
create("release") {
storeFile = file("claude-widget-release.keystore")
storePassword = "ClaudeWidget2026!"
keyAlias = "claudewidget"
keyPassword = "ClaudeWidget2026!"
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
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")
}
-5
View File
@@ -1,5 +0,0 @@
-keep class me.khodak.claudeusage.data.** { *; }
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn okhttp3.**
-dontwarn okio.**
-65
View File
@@ -1,65 +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">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="me.khodak.claudeusage.ACTION_REFRESH" />
</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,302 +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
}
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(buildViews(context, prefs, apiData))
))
} else {
attach(buildViews(context, prefs, apiData))
}
}
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
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 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
}
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,172 +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)
// 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 url = request.url.toString()
if (url.startsWith("market://") || url.startsWith("intent://")) return true
return false
}
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()
}
}
@@ -1,253 +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 */ }
/** 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()
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()
}
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,116 +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 val LEVELS = intArrayOf(90, 100)
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 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,355 +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")
if (orgUsageData?.hasRateLimitData == true) {
prefs.resetAuthFailCount()
return@withContext base.copy(
messagesUsed = orgUsageData.messagesUsed,
messagesLimit = orgUsageData.messagesLimit,
messagesRemaining = orgUsageData.messagesRemaining,
resetAtEpoch = orgUsageData.resetAtEpoch
)
}
// Step 2: /usage endpoint — returns utilization percentages
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) {
Log.w(TAG, "/usage failed: ${e.message}")
}
// 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,132 +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()
// 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,195 +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? = 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 utilization reading.
* 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) {
if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return
val now = System.currentTimeMillis()
val history = getHistory().toMutableList()
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) {
history.removeAt(history.size - 1) // collapse near-simultaneous readings
}
history.add(
UsageSnapshot(
epochMs = now,
sessionPct = data.fiveHourUtilization,
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() }
}
// ── Notification settings ────────────────────────────────────────────────
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
/**
* 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 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
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,84 +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)
// 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>
-391
View File
@@ -1,391 +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>
<!-- 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>
<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>
-187
View File
@@ -1,187 +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="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"
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,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" />
-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]\///'
-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

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!"