Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350cad21ab | ||
|
|
451f1c8af8 | ||
|
|
8ae6183901 | ||
|
|
7ca0a1db4d | ||
|
|
206f572369 | ||
|
|
602c9e2cf5 | ||
|
|
6dd87a30d3 | ||
|
|
a95b05dada | ||
|
|
10cc064f1f | ||
|
|
952c8261e9 | ||
|
|
58e3a0fcd7 | ||
|
|
31e18ed5e9 | ||
|
|
a6d930415c |
@@ -56,6 +56,20 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore
|
||||||
|
|
||||||
|
# Signing passwords are no longer hardcoded in build.gradle.kts — inject them at build time.
|
||||||
|
- name: Write signing credentials
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.KEYSTORE_PASSWORD }}" ] || [ -z "${{ secrets.KEY_PASSWORD }}" ]; then
|
||||||
|
echo "::error::KEYSTORE_PASSWORD / KEY_PASSWORD secrets not set — cannot sign release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}"
|
||||||
|
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
|
||||||
|
echo "keyAlias=${{ secrets.KEY_ALIAS || 'claudewidget' }}"
|
||||||
|
} > keystore.properties
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: ./gradlew :app:assembleRelease --no-daemon
|
run: ./gradlew :app:assembleRelease --no-daemon
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ captures/
|
|||||||
.cxx/
|
.cxx/
|
||||||
*.keystore
|
*.keystore
|
||||||
*.jks
|
*.jks
|
||||||
|
keystore.properties
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
Claude Usage Widget Source-Available License
|
||||||
|
Copyright (c) 2026 Amir Khodak. All rights reserved.
|
||||||
|
|
||||||
|
This is NOT an OSI-approved open-source license. The source code is made
|
||||||
|
publicly viewable ("source-available"), but the rights granted are limited as
|
||||||
|
described below. Where this license is silent, all rights are reserved.
|
||||||
|
|
||||||
|
1. DEFINITIONS
|
||||||
|
"Software" means the Claude Usage Widget source code, assets, and
|
||||||
|
documentation in this repository. "You" means anyone other than the
|
||||||
|
copyright holder.
|
||||||
|
|
||||||
|
2. WHAT YOU MAY DO
|
||||||
|
a. View, read, and study the Software.
|
||||||
|
b. Clone or fork the repository for your own private, personal,
|
||||||
|
non-commercial use and experimentation.
|
||||||
|
c. Build the Software from source and run it on devices you personally own.
|
||||||
|
d. Submit contributions (pull requests) back to this repository; by doing so
|
||||||
|
you license your contribution to the copyright holder under these terms.
|
||||||
|
|
||||||
|
3. WHAT YOU MAY NOT DO (without the copyright holder's prior written permission)
|
||||||
|
a. Redistribute, publish, or make available the Software or any derivative
|
||||||
|
work — in source or binary/APK form — to any third party or app store
|
||||||
|
(including but not limited to Google Play, F-Droid, Amazon Appstore,
|
||||||
|
Gitea/GitHub releases, or any website).
|
||||||
|
b. Use the Software, in whole or in part, for any commercial purpose.
|
||||||
|
c. Sell, sublicense, rent, or offer the Software as a service.
|
||||||
|
d. Use the names, app identity ("Claude Usage Widget"), package identifier
|
||||||
|
("me.khodak.claudeusage"), logos, or signing keys of the original work.
|
||||||
|
e. Remove or alter this license or the copyright notice.
|
||||||
|
|
||||||
|
4. RESERVED RIGHTS
|
||||||
|
All publishing and distribution rights are reserved exclusively to the
|
||||||
|
copyright holder. Only the copyright holder may publish official builds.
|
||||||
|
|
||||||
|
5. TRADEMARK NOTE
|
||||||
|
"Claude" is a trademark of Anthropic. This is an unofficial, independent
|
||||||
|
utility and is not affiliated with or endorsed by Anthropic.
|
||||||
|
|
||||||
|
6. NO WARRANTY
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
|
||||||
|
DAMAGES, OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||||
|
|
||||||
|
To request permission for anything in section 3, contact the copyright holder.
|
||||||
@@ -23,6 +23,12 @@ Android home screen widget that shows your Claude Pro usage at a glance.
|
|||||||
- Responsive: works as 4×1 (compact) or 4×2 (full)
|
- Responsive: works as 4×1 (compact) or 4×2 (full)
|
||||||
- Auto-refreshes every 5 minutes in the background
|
- Auto-refreshes every 5 minutes in the background
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/signin.png" width="240" alt="Sign in">
|
||||||
|
</p>
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. Download `claude-usage-widget.apk` from the [latest release](../../releases/latest)
|
1. Download `claude-usage-widget.apk` from the [latest release](../../releases/latest)
|
||||||
|
|||||||
+39
-7
@@ -1,8 +1,21 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signing credentials are NEVER committed. They are read from (in order):
|
||||||
|
// 1. environment variables (KEYSTORE_PASSWORD / KEY_PASSWORD / KEY_ALIAS) — used by CI
|
||||||
|
// 2. keystore.properties at the repo root (gitignored) — used locally
|
||||||
|
// See keystore.properties.example. Debug builds need none of this.
|
||||||
|
val keystoreProps = Properties().apply {
|
||||||
|
val f = rootProject.file("keystore.properties")
|
||||||
|
if (f.exists()) f.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
fun signingCred(envName: String, propName: String): String? =
|
||||||
|
System.getenv(envName) ?: keystoreProps.getProperty(propName)
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "me.khodak.claudeusage"
|
namespace = "me.khodak.claudeusage"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
@@ -11,16 +24,26 @@ android {
|
|||||||
applicationId = "me.khodak.claudeusage"
|
applicationId = "me.khodak.claudeusage"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 19
|
versionCode = 22
|
||||||
versionName = "1.18"
|
versionName = "1.21"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword")
|
||||||
|
val releaseKeyPassword = signingCred("KEY_PASSWORD", "keyPassword")
|
||||||
|
val releaseKeyAlias = signingCred("KEY_ALIAS", "keyAlias") ?: "claudewidget"
|
||||||
|
val hasSigningCreds = releaseStorePassword != null && releaseKeyPassword != null
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
storeFile = file("claude-widget-release.keystore")
|
// Only wire the keystore when credentials are present, so debug builds and
|
||||||
storePassword = "ClaudeWidget2026!"
|
// credential-less checkouts configure cleanly. Same keystore file + alias as before —
|
||||||
keyAlias = "claudewidget"
|
// signing identity is unchanged; only the password source moved out of version control.
|
||||||
keyPassword = "ClaudeWidget2026!"
|
if (hasSigningCreds) {
|
||||||
|
storeFile = file("claude-widget-release.keystore")
|
||||||
|
storePassword = releaseStorePassword
|
||||||
|
keyAlias = releaseKeyAlias
|
||||||
|
keyPassword = releaseKeyPassword
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +52,13 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
signingConfig = signingConfigs.getByName("release")
|
// Sign only when creds were supplied; otherwise fail loudly at assembleRelease rather
|
||||||
|
// than silently shipping an unsigned APK. Tag builds in CI inject creds (see workflow).
|
||||||
|
if (hasSigningCreds) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
logger.warn("No signing credentials (KEYSTORE_PASSWORD/keystore.properties) — release APK will be unsigned.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,4 +101,7 @@ dependencies {
|
|||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
|
|
||||||
|
// Unit tests (pure JVM — no Robolectric, no Android framework)
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,11 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".ClaudeUsageWidget"
|
android:name=".ClaudeUsageWidget"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
<!-- Only the system APPWIDGET_UPDATE action is exposed. ACTION_REFRESH is delivered via
|
||||||
|
the widget's own explicit PendingIntent (explicit broadcasts need no intent-filter),
|
||||||
|
so other apps can no longer trigger refreshes by sending that action externally. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
<action android:name="me.khodak.claudeusage.ACTION_REFRESH" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.appwidget.provider"
|
android:name="android.appwidget.provider"
|
||||||
|
|||||||
@@ -84,17 +84,26 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full-size builder follows the user's chosen style; the compact (1-cell-tall) size
|
||||||
|
// always uses bars, since two rings don't fit that height.
|
||||||
|
val rings = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
|
||||||
|
fun buildFull() =
|
||||||
|
if (rings) buildRingViews(context, prefs, apiData)
|
||||||
|
else buildViews(context, prefs, apiData)
|
||||||
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
RemoteViews(mapOf(
|
RemoteViews(mapOf(
|
||||||
SizeF(50f, 50f) to attach(buildSmallViews(context, prefs, apiData)),
|
SizeF(50f, 50f) to attach(buildSmallViews(context, prefs, apiData)),
|
||||||
SizeF(50f, 120f) to attach(buildViews(context, prefs, apiData))
|
SizeF(50f, 120f) to attach(buildFull())
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
attach(buildViews(context, prefs, apiData))
|
attach(buildFull())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
|
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
|
||||||
|
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
|
||||||
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
||||||
applyPeak(v, showText = false)
|
applyPeak(v, showText = false)
|
||||||
if (!prefs.isLoggedIn()) {
|
if (!prefs.isLoggedIn()) {
|
||||||
@@ -167,6 +176,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
|
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
|
||||||
|
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
|
||||||
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
||||||
applyPeak(v, showText = true)
|
applyPeak(v, showText = true)
|
||||||
|
|
||||||
@@ -252,6 +263,82 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ring style of the full widget — same data/branching as [buildViews] but each metric is a
|
||||||
|
* circular [RingRenderer] gauge (percentage drawn in the center) instead of a bar.
|
||||||
|
*/
|
||||||
|
private fun buildRingViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
|
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
|
||||||
|
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
|
||||||
|
val v = RemoteViews(context.packageName, R.layout.widget_layout_rings)
|
||||||
|
applyPeak(v, showText = true)
|
||||||
|
|
||||||
|
if (!prefs.isLoggedIn()) {
|
||||||
|
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "—", "SESSION"))
|
||||||
|
v.setImageViewBitmap(R.id.ring_weekly, RingRenderer.render(0, null, WEEKLY_FILL, null, "—", "WEEKLY"))
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "Not signed in")
|
||||||
|
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
|
||||||
|
v.setTextViewText(R.id.tv_status, "")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5-hour session ring ──────────────────────────────────────────
|
||||||
|
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
|
||||||
|
val hasApiMessages = apiData != null && apiData.effectiveRemaining >= 0 && apiData.messagesLimit > 0
|
||||||
|
val sessionStart = prefs.getSessionStart()
|
||||||
|
when {
|
||||||
|
hasUtilization -> {
|
||||||
|
val pct = apiData!!.fiveHourUtilization.toInt()
|
||||||
|
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(pct, null, SESSION_FILL, null, "$pct%", "SESSION"))
|
||||||
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
||||||
|
}
|
||||||
|
hasApiMessages -> {
|
||||||
|
val pct = apiData!!.progressPercent
|
||||||
|
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(pct, null, SESSION_FILL, null, "$pct%", "SESSION"))
|
||||||
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
||||||
|
}
|
||||||
|
sessionStart > 0 -> {
|
||||||
|
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "·", "SESSION"))
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "session active")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "—", "SESSION"))
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7-day weekly ring (with pace tick) ───────────────────────────
|
||||||
|
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
||||||
|
if (hasWeekly) {
|
||||||
|
val wPct = apiData!!.weeklyUtilization.toInt()
|
||||||
|
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
||||||
|
v.setImageViewBitmap(
|
||||||
|
R.id.ring_weekly,
|
||||||
|
RingRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null, "$wPct%", "WEEKLY")
|
||||||
|
)
|
||||||
|
v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch))
|
||||||
|
} else {
|
||||||
|
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
||||||
|
v.setImageViewBitmap(R.id.ring_weekly, RingRenderer.render(0, null, WEEKLY_FILL, null, "${weeklyDays}d", "WEEKLY"))
|
||||||
|
v.setTextViewText(R.id.tv_weekly_label, "active this week")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer ───────────────────────────────────────────────────────
|
||||||
|
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
|
||||||
|
val status = when {
|
||||||
|
isRefreshing -> "Refreshing…"
|
||||||
|
apiData?.isRateLimited == true -> "Rate limited · ${formatReset(resetEpoch)}"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
val updatedMs = apiData?.lastUpdated ?: 0L
|
||||||
|
v.setTextViewText(R.id.tv_status,
|
||||||
|
if (status.isNotBlank()) status else if (updatedMs > 0) formatTime(updatedMs) else "")
|
||||||
|
v.setInt(R.id.btn_refresh, "setColorFilter",
|
||||||
|
if (isRefreshing) 0xFFCC785C.toInt() else 0xFF999999.toInt())
|
||||||
|
v.setFloat(R.id.btn_refresh, "setRotation", currentRotation)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
private const val SESSION_FILL = 0xFFCC785C.toInt()
|
private const val SESSION_FILL = 0xFFCC785C.toInt()
|
||||||
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
||||||
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color pace marker (weekly only)
|
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color pace marker (weekly only)
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
databaseEnabled = false
|
databaseEnabled = false
|
||||||
javaScriptCanOpenWindowsAutomatically = false
|
javaScriptCanOpenWindowsAutomatically = false
|
||||||
setSupportMultipleWindows(false)
|
setSupportMultipleWindows(false)
|
||||||
|
// Hardening: this WebView holds the claude.ai session, so lock down every path a
|
||||||
|
// page could use to reach the device. None of these affect the login flow.
|
||||||
|
allowFileAccess = false
|
||||||
|
allowContentAccess = false
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
allowFileAccessFromFileURLs = false
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
allowUniversalAccessFromFileURLs = false
|
||||||
|
setGeolocationEnabled(false)
|
||||||
// Standard Android Chrome UA — less suspicious than desktop
|
// Standard Android Chrome UA — less suspicious than desktop
|
||||||
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
|
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
@@ -85,9 +94,19 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
val url = request.url.toString()
|
val uri = request.url
|
||||||
|
val url = uri.toString()
|
||||||
|
// Keep blocking app-redirect schemes outright.
|
||||||
if (url.startsWith("market://") || url.startsWith("intent://")) return true
|
if (url.startsWith("market://") || url.startsWith("intent://")) return true
|
||||||
return false
|
val scheme = uri.scheme?.lowercase()
|
||||||
|
// about:blank and data: URIs are used by the login page itself (e.g. blank
|
||||||
|
// bootstrap frames, inline images). They have no host to allow-list, so let them load.
|
||||||
|
if (scheme == "about" || scheme == "data") return false
|
||||||
|
// Host ALLOW-LIST: only navigate to claude.ai and its SSO/CDN providers.
|
||||||
|
// OAuth runs as full-page redirects in THIS WebView (multiple windows disabled,
|
||||||
|
// JS-opened windows disabled), so the list MUST include the Google/Apple SSO
|
||||||
|
// and Cloudflare challenge hosts or sign-in breaks. Anything else is blocked.
|
||||||
|
return !isHostAllowed(uri.host)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
|
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
|
||||||
@@ -169,4 +188,35 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
binding.webView.destroy()
|
binding.webView.destroy()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if [host] belongs to an allowed domain (exact match or a subdomain via dot-suffix).
|
||||||
|
* A null/blank host is never allowed. accounts.google.com, challenges.cloudflare.com, etc. are
|
||||||
|
* covered by their parent-domain suffixes (google.com, cloudflare.com).
|
||||||
|
*/
|
||||||
|
private fun isHostAllowed(host: String?): Boolean {
|
||||||
|
if (host.isNullOrBlank()) return false
|
||||||
|
val h = host.lowercase()
|
||||||
|
return ALLOWED_DOMAINS.any { d -> h == d || h.endsWith(".$d") }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// WebView host allow-list. Login navigation is restricted to claude.ai and the SSO/CDN
|
||||||
|
// hosts it redirects through. This MUST include the SSO providers (Google, Apple) and
|
||||||
|
// Cloudflare challenge hosts — OAuth happens as same-WebView full-page redirects, so
|
||||||
|
// dropping any of these breaks sign-in. Subdomains are matched by dot-suffix.
|
||||||
|
private val ALLOWED_DOMAINS = listOf(
|
||||||
|
"claude.ai",
|
||||||
|
"anthropic.com",
|
||||||
|
"google.com", // covers accounts.google.com
|
||||||
|
"gstatic.com",
|
||||||
|
"googleusercontent.com",
|
||||||
|
"googleapis.com",
|
||||||
|
"apple.com",
|
||||||
|
"icloud.com",
|
||||||
|
"cloudflare.com", // covers challenges.cloudflare.com
|
||||||
|
"cloudflareinsights.com",
|
||||||
|
"recaptcha.net"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val notifPermLauncher =
|
private val notifPermLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
|
||||||
|
|
||||||
|
/** SAF: pick a destination file and write the current settings JSON to it. */
|
||||||
|
private val exportLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
try {
|
||||||
|
contentResolver.openOutputStream(uri)?.use { it.write(prefs.exportSettings().toByteArray()) }
|
||||||
|
toast("Settings exported")
|
||||||
|
} catch (_: Exception) { toast("Export failed") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SAF: pick a backup file and apply it. */
|
||||||
|
private val importLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
try {
|
||||||
|
val json = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: ""
|
||||||
|
if (prefs.importSettings(json)) {
|
||||||
|
toast("Settings restored")
|
||||||
|
// Reflect restored prefs immediately: re-sync switches and redraw any widgets.
|
||||||
|
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
|
||||||
|
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
|
||||||
|
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
} else toast("Not a valid settings file")
|
||||||
|
} catch (_: Exception) { toast("Import failed") }
|
||||||
|
}
|
||||||
|
|
||||||
/** Live refresh loop that runs only while the app is in the foreground. */
|
/** Live refresh loop that runs only while the app is in the foreground. */
|
||||||
private var autoRefreshJob: Job? = null
|
private var autoRefreshJob: Job? = null
|
||||||
|
|
||||||
@@ -66,6 +93,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupNotificationSettings()
|
setupNotificationSettings()
|
||||||
|
setupWidgetStyleSetting()
|
||||||
|
setupMaterialYouSetting()
|
||||||
|
setupBackupRestore()
|
||||||
|
|
||||||
binding.btnDebug.setOnClickListener {
|
binding.btnDebug.setOnClickListener {
|
||||||
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
||||||
@@ -112,6 +142,37 @@ class MainActivity : AppCompatActivity() {
|
|||||||
if (prefs.isNotifyEnabled()) requestNotificationPermission()
|
if (prefs.isNotifyEnabled()) requestNotificationPermission()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bars ⇄ Rings switch for the home-screen widget; redraws any placed widgets immediately. */
|
||||||
|
private fun setupWidgetStyleSetting() {
|
||||||
|
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
|
||||||
|
binding.switchRingStyle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.setWidgetStyle(if (checked) PreferencesManager.STYLE_RINGS else PreferencesManager.STYLE_BARS)
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Material You toggle — switches the bars/rings to wallpaper-derived accents (Android 12+). */
|
||||||
|
private fun setupMaterialYouSetting() {
|
||||||
|
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
|
||||||
|
binding.switchMaterialYou.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.setMaterialYou(checked)
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export/Import settings via the Storage Access Framework (no extra permissions needed). */
|
||||||
|
private fun setupBackupRestore() {
|
||||||
|
binding.btnExportSettings.setOnClickListener {
|
||||||
|
exportLauncher.launch("claude-usage-settings.json")
|
||||||
|
}
|
||||||
|
binding.btnImportSettings.setOnClickListener {
|
||||||
|
importLauncher.launch(arrayOf("application/json", "text/*", "*/*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(msg: String) =
|
||||||
|
android.widget.Toast.makeText(this, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
private fun requestNotificationPermission() {
|
private fun requestNotificationPermission() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import me.khodak.claudeusage.data.UsageData
|
||||||
|
import me.khodak.claudeusage.databinding.ActivityMainBinding
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var binding: ActivityMainBinding
|
||||||
|
private lateinit var prefs: PreferencesManager
|
||||||
|
private lateinit var repo: UsageRepository
|
||||||
|
|
||||||
|
private val notifPermLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
|
||||||
|
|
||||||
|
/** SAF: pick a destination file and write the current settings JSON to it. */
|
||||||
|
private val exportLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
try {
|
||||||
|
contentResolver.openOutputStream(uri)?.use { it.write(prefs.exportSettings().toByteArray()) }
|
||||||
|
toast("Settings exported")
|
||||||
|
} catch (_: Exception) { toast("Export failed") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SAF: pick a backup file and apply it. */
|
||||||
|
private val importLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
try {
|
||||||
|
val json = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: ""
|
||||||
|
if (prefs.importSettings(json)) {
|
||||||
|
toast("Settings restored")
|
||||||
|
// Reflect restored prefs immediately: re-sync switches and redraw any widgets.
|
||||||
|
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
|
||||||
|
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
|
||||||
|
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
} else toast("Not a valid settings file")
|
||||||
|
} catch (_: Exception) { toast("Import failed") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live refresh loop that runs only while the app is in the foreground. */
|
||||||
|
private var autoRefreshJob: Job? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
prefs = PreferencesManager(this)
|
||||||
|
repo = UsageRepository(prefs)
|
||||||
|
|
||||||
|
binding.btnLogin.setOnClickListener {
|
||||||
|
startActivity(Intent(this, LoginActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already logged in, go straight to logged-in state without re-opening login
|
||||||
|
if (prefs.isLoggedIn()) {
|
||||||
|
updateUI(prefs.getUsageData())
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnLogout.setOnClickListener {
|
||||||
|
prefs.clearSession()
|
||||||
|
updateUI(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnRefresh.setOnClickListener {
|
||||||
|
refreshUsage()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvWidgetHint.setOnClickListener {
|
||||||
|
startActivity(Intent(Intent.ACTION_MAIN).apply {
|
||||||
|
addCategory(Intent.CATEGORY_HOME)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNotificationSettings()
|
||||||
|
setupWidgetStyleSetting()
|
||||||
|
setupMaterialYouSetting()
|
||||||
|
setupBackupRestore()
|
||||||
|
|
||||||
|
binding.btnDebug.setOnClickListener {
|
||||||
|
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
||||||
|
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
|
||||||
|
binding.tvDebugInfo.visibility = android.view.View.VISIBLE
|
||||||
|
binding.btnDebug.text = "Hide API Debug"
|
||||||
|
} else {
|
||||||
|
binding.tvDebugInfo.visibility = android.view.View.GONE
|
||||||
|
binding.btnDebug.text = "Show API Debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
updateUI(prefs.getUsageData()) // show cached instantly — never a blank screen
|
||||||
|
if (prefs.isLoggedIn()) startAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
autoRefreshJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh immediately on open, then every [REFRESH_INTERVAL_MS] while foregrounded. */
|
||||||
|
private fun startAutoRefresh() {
|
||||||
|
autoRefreshJob?.cancel()
|
||||||
|
autoRefreshJob = lifecycleScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
doRefresh(silent = true)
|
||||||
|
delay(REFRESH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupNotificationSettings() {
|
||||||
|
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
|
||||||
|
binding.switchNotify.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.setNotifyEnabled(checked)
|
||||||
|
if (checked) requestNotificationPermission()
|
||||||
|
}
|
||||||
|
// Alerts default on, so prompt for the runtime permission once on first launch
|
||||||
|
// (a user who never toggles the switch would otherwise never be asked).
|
||||||
|
if (prefs.isNotifyEnabled()) requestNotificationPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bars ⇄ Rings switch for the home-screen widget; redraws any placed widgets immediately. */
|
||||||
|
private fun setupWidgetStyleSetting() {
|
||||||
|
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
|
||||||
|
binding.switchRingStyle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.setWidgetStyle(if (checked) PreferencesManager.STYLE_RINGS else PreferencesManager.STYLE_BARS)
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Material You toggle — redraws placed widgets so the new accent applies immediately. */
|
||||||
|
private fun setupMaterialYouSetting() {
|
||||||
|
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
|
||||||
|
binding.switchMaterialYou.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.setMaterialYou(checked)
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
updateUI(prefs.getUsageData()) // refresh the in-app bars too
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupBackupRestore() {
|
||||||
|
binding.btnExportSettings.setOnClickListener { exportLauncher.launch("claude-usage-settings.json") }
|
||||||
|
binding.btnImportSettings.setOnClickListener { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(msg: String) =
|
||||||
|
android.widget.Toast.makeText(this, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
private fun requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notifPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual "Refresh Now" button — shows the spinner. */
|
||||||
|
private fun refreshUsage() {
|
||||||
|
lifecycleScope.launch { doRefresh(silent = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doRefresh(silent: Boolean) {
|
||||||
|
if (!silent) {
|
||||||
|
binding.btnRefresh.isEnabled = false
|
||||||
|
binding.progressIndicator.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
val fresh = try {
|
||||||
|
repo.fetchUsage()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||||
|
UsageData(errorMessage = "Network error")
|
||||||
|
}
|
||||||
|
// Preserve last-good data so a failed/partial fetch never blanks the UI or widget.
|
||||||
|
val merged = fresh.mergedWith(prefs.getUsageData())
|
||||||
|
prefs.saveUsageData(merged)
|
||||||
|
prefs.recordHistory(fresh)
|
||||||
|
// Note: alerts fire only from the background worker, not here — no point pinging you
|
||||||
|
// with a notification while you're already looking at the app.
|
||||||
|
updateUI(merged)
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this) // opening the app refreshes the widget too
|
||||||
|
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
|
||||||
|
binding.tvDebugInfo.text = repo.lastDebugInfo
|
||||||
|
}
|
||||||
|
if (!silent) {
|
||||||
|
binding.btnRefresh.isEnabled = true
|
||||||
|
binding.progressIndicator.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUI(data: UsageData?) {
|
||||||
|
val loggedIn = prefs.isLoggedIn()
|
||||||
|
|
||||||
|
binding.layoutLoggedOut.visibility = if (loggedIn) View.GONE else View.VISIBLE
|
||||||
|
binding.layoutLoggedIn.visibility = if (loggedIn) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
if (!loggedIn || data == null) return
|
||||||
|
|
||||||
|
// ── Peak-hours row ───────────────────────────────────────────────────
|
||||||
|
val peak = PeakHours.state()
|
||||||
|
binding.imgPeak.setColorFilter(if (peak.active) PEAK_ON else PEAK_OFF)
|
||||||
|
binding.tvPeak.setTextColor(if (peak.active) PEAK_ON else 0xFFAAAAAA.toInt())
|
||||||
|
binding.tvPeak.text = if (peak.active)
|
||||||
|
"🔥 Peak hours — ${peak.endsInLabel} · ${peak.windowLabel}"
|
||||||
|
else
|
||||||
|
"Off-peak · ${peak.windowLabel}"
|
||||||
|
|
||||||
|
// ── Session (5-hour) bar — no pace marker ────────────────────────────
|
||||||
|
binding.barSession.setImageBitmap(
|
||||||
|
BarRenderer.render(data.progressPercent, null, SESSION_FILL, null)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Weekly (7-day) bar — single-color pace marker ────────────────────
|
||||||
|
if (data.weeklyUtilization >= 0f) {
|
||||||
|
val wPct = data.weeklyUtilization.toInt()
|
||||||
|
val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
||||||
|
binding.barWeekly.setImageBitmap(
|
||||||
|
BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, if (weeklyPace != null) MARKER_COLOR else null)
|
||||||
|
)
|
||||||
|
binding.tvWeeklyUsage.text = "$wPct% this week"
|
||||||
|
} else {
|
||||||
|
binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null))
|
||||||
|
binding.tvWeeklyUsage.text = "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pace text removed per design — bars carry the signal.
|
||||||
|
binding.tvSessionPace.visibility = View.GONE
|
||||||
|
binding.tvWeeklyPace.visibility = View.GONE
|
||||||
|
|
||||||
|
binding.tvUsage.text = when {
|
||||||
|
data.fiveHourUtilization >= 0f -> {
|
||||||
|
val pct = data.fiveHourUtilization.toInt()
|
||||||
|
"$pct% of limit used"
|
||||||
|
}
|
||||||
|
data.messagesLimit > 0 && data.effectiveUsed >= 0 ->
|
||||||
|
"${data.effectiveUsed} of ${data.messagesLimit} messages used"
|
||||||
|
data.effectiveRemaining >= 0 && data.messagesLimit > 0 ->
|
||||||
|
"${data.effectiveRemaining} of ${data.messagesLimit} remaining"
|
||||||
|
data.effectiveRemaining >= 0 ->
|
||||||
|
"${data.effectiveRemaining} messages remaining"
|
||||||
|
else -> "Usage data unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvReset.text = formatReset(data.effectiveResetEpoch)
|
||||||
|
binding.tvWeeklyReset.text = formatResetDay(data.weeklyResetAtEpoch)
|
||||||
|
binding.tvUpdated.text = if (data.lastUpdated > 0)
|
||||||
|
"Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}"
|
||||||
|
else ""
|
||||||
|
|
||||||
|
binding.tvError.text = data.errorMessage
|
||||||
|
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.historyChart.setData(prefs.getHistory())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatReset(epochMs: Long): String {
|
||||||
|
if (epochMs <= 0) return ""
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (epochMs <= now) return "Resets soon"
|
||||||
|
val diffH = TimeUnit.MILLISECONDS.toHours(epochMs - now)
|
||||||
|
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
|
||||||
|
return when {
|
||||||
|
diffH < 24 -> "Resets at $timeStr"
|
||||||
|
diffH < 48 -> "Resets tomorrow $timeStr"
|
||||||
|
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weekly reset shown with the weekday name ("Resets Friday 3:00 PM"), never "tomorrow". */
|
||||||
|
private fun formatResetDay(epochMs: Long): String {
|
||||||
|
if (epochMs <= 0) return ""
|
||||||
|
if (epochMs <= System.currentTimeMillis()) return "Resets soon"
|
||||||
|
val day = SimpleDateFormat("EEEE", Locale.US).format(Date(epochMs))
|
||||||
|
val date = SimpleDateFormat("MMM d", Locale.US).format(Date(epochMs))
|
||||||
|
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
|
||||||
|
return "Resets $day, $date · $timeStr"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val REFRESH_INTERVAL_MS = 30_000L // live refresh cadence while app is open
|
||||||
|
private const val SESSION_FILL = 0xFFCC785C.toInt()
|
||||||
|
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
||||||
|
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color weekly pace marker
|
||||||
|
private const val PEAK_ON = 0xFFCC785C.toInt()
|
||||||
|
private const val PEAK_OFF = 0xFF666666.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,42 @@ import me.khodak.claudeusage.data.UsageData
|
|||||||
object Notifier {
|
object Notifier {
|
||||||
|
|
||||||
private const val CHANNEL_ID = "usage_alerts"
|
private const val CHANNEL_ID = "usage_alerts"
|
||||||
|
private const val AUTH_CHANNEL_ID = "session_alerts"
|
||||||
|
private const val AUTH_NOTIF_ID = 3000
|
||||||
private val LEVELS = intArrayOf(90, 100)
|
private val LEVELS = intArrayOf(90, 100)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired once when the saved claude.ai session is cleared after repeated 401/403s — without
|
||||||
|
* this the widget would just silently go stale and the user would have to notice the dead
|
||||||
|
* data themselves. Tapping opens LoginActivity straight to the cookie re-paste flow. Posts on
|
||||||
|
* its own channel so it can be muted independently of usage alerts, and is not gated on the
|
||||||
|
* usage-alert toggle: a dead widget is a functional failure the user needs to know about.
|
||||||
|
*/
|
||||||
|
fun notifySessionExpired(context: Context) {
|
||||||
|
val mgr = NotificationManagerCompat.from(context)
|
||||||
|
if (!mgr.areNotificationsEnabled()) return
|
||||||
|
ensureAuthChannel(context)
|
||||||
|
val intent = Intent(context, LoginActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
context, 1, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val notif = NotificationCompat.Builder(context, AUTH_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_claude_burst)
|
||||||
|
.setContentTitle("Claude sign-in expired")
|
||||||
|
.setContentText("Tap to sign in again — the widget can't update until you do.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
mgr.notify(AUTH_NOTIF_ID, notif)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
// Notifications revoked between the check and post — nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) {
|
fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) {
|
||||||
if (!prefs.isNotifyEnabled()) return
|
if (!prefs.isNotifyEnabled()) return
|
||||||
val mgr = NotificationManagerCompat.from(context)
|
val mgr = NotificationManagerCompat.from(context)
|
||||||
@@ -94,6 +128,17 @@ object Notifier {
|
|||||||
private fun notifId(metric: String, levelIndex: Int) =
|
private fun notifId(metric: String, levelIndex: Int) =
|
||||||
2000 + (if (metric == "weekly") 10 else 0) + levelIndex
|
2000 + (if (metric == "weekly") 10 else 0) + levelIndex
|
||||||
|
|
||||||
|
private fun ensureAuthChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (mgr.getNotificationChannel(AUTH_CHANNEL_ID) != null) return
|
||||||
|
mgr.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
AUTH_CHANNEL_ID, "Sign-in alerts", NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply { description = "Tells you when your claude.ai session expires and needs re-auth" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun ensureChannel(context: Context) {
|
private fun ensureChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a usage gauge as a circular ring Bitmap: rounded track ring + colored progress arc
|
||||||
|
* (sweeping clockwise from 12 o'clock) + an optional radial pace tick + center percentage and
|
||||||
|
* label. The ring counterpart to [BarRenderer] — same Bitmap contract, so it drops into both the
|
||||||
|
* home-screen widget (setImageViewBitmap) and the in-app card and renders identically.
|
||||||
|
*
|
||||||
|
* Design mirrors hamed-elfayome/Claude-Usage-Tracker's ring gauges, using this app's existing
|
||||||
|
* palette (SESSION_FILL clay / WEEKLY_FILL periwinkle, #252525 track, white pace marker).
|
||||||
|
*/
|
||||||
|
object RingRenderer {
|
||||||
|
|
||||||
|
private const val TRACK_COLOR = 0xFF252525.toInt()
|
||||||
|
private const val CENTER_TEXT_COLOR = 0xFFFFFFFF.toInt()
|
||||||
|
private const val LABEL_TEXT_COLOR = 0xFF999999.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param usedPct 0..100 fill of the ring
|
||||||
|
* @param markerPct where you "should be" right now (pace), or null for no tick
|
||||||
|
* @param fillColor ARGB progress-arc color (e.g. SESSION_FILL / WEEKLY_FILL)
|
||||||
|
* @param markerColor ARGB pace-tick color, or null
|
||||||
|
* @param centerText big text in the middle, e.g. "47%" (caller formats it)
|
||||||
|
* @param labelText small caption under the number, e.g. "SESSION" (null hides it)
|
||||||
|
* @param sizePx square bitmap edge; ring scales to fit
|
||||||
|
* @param strokePx ring thickness
|
||||||
|
*/
|
||||||
|
fun render(
|
||||||
|
usedPct: Int,
|
||||||
|
markerPct: Int?,
|
||||||
|
fillColor: Int,
|
||||||
|
markerColor: Int?,
|
||||||
|
centerText: String,
|
||||||
|
labelText: String? = null,
|
||||||
|
sizePx: Int = 360,
|
||||||
|
strokePx: Float = 36f
|
||||||
|
): Bitmap {
|
||||||
|
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bmp)
|
||||||
|
val cx = sizePx / 2f
|
||||||
|
val cy = sizePx / 2f
|
||||||
|
val pad = strokePx / 2f + 2f
|
||||||
|
val arcRect = RectF(pad, pad, sizePx - pad, sizePx - pad)
|
||||||
|
val radius = (sizePx - strokePx) / 2f - 2f
|
||||||
|
|
||||||
|
// Track ring (full circle).
|
||||||
|
val track = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = strokePx
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
color = TRACK_COLOR
|
||||||
|
}
|
||||||
|
canvas.drawArc(arcRect, 0f, 360f, false, track)
|
||||||
|
|
||||||
|
// Progress arc — clockwise from 12 o'clock (-90°).
|
||||||
|
val pct = usedPct.coerceIn(0, 100)
|
||||||
|
if (pct > 0) {
|
||||||
|
val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = strokePx
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
color = fillColor
|
||||||
|
}
|
||||||
|
canvas.drawArc(arcRect, -90f, pct / 100f * 360f, false, fill)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pace tick — a short radial mark across the ring at the "should be here" angle.
|
||||||
|
if (markerPct != null && markerColor != null) {
|
||||||
|
val m = markerPct.coerceIn(0, 100)
|
||||||
|
val ang = Math.toRadians((-90f + m / 100f * 360f).toDouble())
|
||||||
|
val rInner = radius - strokePx / 2f - 1f
|
||||||
|
val rOuter = radius + strokePx / 2f + 1f
|
||||||
|
val tick = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = strokePx * 0.18f
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
color = markerColor
|
||||||
|
}
|
||||||
|
canvas.drawLine(
|
||||||
|
cx + (rInner * cos(ang)).toFloat(), cy + (rInner * sin(ang)).toFloat(),
|
||||||
|
cx + (rOuter * cos(ang)).toFloat(), cy + (rOuter * sin(ang)).toFloat(),
|
||||||
|
tick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center percentage.
|
||||||
|
val hasLabel = !labelText.isNullOrEmpty()
|
||||||
|
val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = CENTER_TEXT_COLOR
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
textSize = sizePx * 0.27f
|
||||||
|
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
}
|
||||||
|
val vfm = valuePaint.fontMetrics
|
||||||
|
val valueBaselineShift = -(vfm.ascent + vfm.descent) / 2f
|
||||||
|
val valueY = cy + valueBaselineShift - (if (hasLabel) sizePx * 0.07f else 0f)
|
||||||
|
canvas.drawText(centerText, cx, valueY, valuePaint)
|
||||||
|
|
||||||
|
if (hasLabel) {
|
||||||
|
val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = LABEL_TEXT_COLOR
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
textSize = sizePx * 0.105f
|
||||||
|
letterSpacing = 0.12f
|
||||||
|
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
}
|
||||||
|
canvas.drawText(labelText!!.uppercase(), cx, cy + sizePx * 0.17f, labelPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bmp
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central source of the two accent colors used by the bars/rings (session + weekly).
|
||||||
|
*
|
||||||
|
* Default = the Claude brand palette. When the user enables Material You *and* the device is
|
||||||
|
* Android 12+ (where the wallpaper-derived `system_accent*` palette exists), the fills switch to
|
||||||
|
* the dynamic system accents so the widget matches the rest of the home screen. On older devices
|
||||||
|
* the toggle is a no-op and the brand colors are always used.
|
||||||
|
*/
|
||||||
|
object ThemeColors {
|
||||||
|
|
||||||
|
const val SESSION_DEFAULT = 0xFFCC785C.toInt() // Claude orange
|
||||||
|
const val WEEKLY_DEFAULT = 0xFF7B8FCC.toInt() // Claude periwinkle
|
||||||
|
|
||||||
|
private fun dynamicAvailable(prefs: PreferencesManager): Boolean =
|
||||||
|
prefs.isMaterialYou() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
fun sessionFill(context: Context, prefs: PreferencesManager): Int =
|
||||||
|
if (dynamicAvailable(prefs)) context.getColor(android.R.color.system_accent1_300)
|
||||||
|
else SESSION_DEFAULT
|
||||||
|
|
||||||
|
fun weeklyFill(context: Context, prefs: PreferencesManager): Int =
|
||||||
|
if (dynamicAvailable(prefs)) context.getColor(android.R.color.system_accent2_300)
|
||||||
|
else WEEKLY_DEFAULT
|
||||||
|
}
|
||||||
@@ -54,17 +54,15 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
|
|
||||||
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
|
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
|
||||||
|
|
||||||
if (orgUsageData?.hasRateLimitData == true) {
|
// NOTE: message-count data from the org endpoint (orgUsageData) is a FALLBACK only — do
|
||||||
prefs.resetAuthFailCount()
|
// NOT return here. The /usage utilization endpoint below is the preferred signal and the
|
||||||
return@withContext base.copy(
|
// one the weekly bar + in-app history chart depend on, so it must be attempted whenever we
|
||||||
messagesUsed = orgUsageData.messagesUsed,
|
// have an org id. Returning early here was why the history chart stayed empty: any account
|
||||||
messagesLimit = orgUsageData.messagesLimit,
|
// whose org JSON carried a message limit short-circuited before /usage, so
|
||||||
messagesRemaining = orgUsageData.messagesRemaining,
|
// fiveHourUtilization/weeklyUtilization never got populated and recordHistory() had nothing
|
||||||
resetAtEpoch = orgUsageData.resetAtEpoch
|
// to store. The org message data is used only if /usage yields nothing (fallback below).
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: /usage endpoint — returns utilization percentages
|
// Step 2: /usage endpoint — returns utilization percentages (preferred signal)
|
||||||
try {
|
try {
|
||||||
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
||||||
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
|
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
|
||||||
@@ -87,7 +85,19 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "/usage failed: ${e.message}")
|
if (BuildConfig.DEBUG) Log.w(TAG, "/usage failed: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// /usage gave no utilization → fall back to the message-count data the org endpoint
|
||||||
|
// already returned (this is the early-return that used to live before Step 2).
|
||||||
|
if (orgUsageData?.hasRateLimitData == true) {
|
||||||
|
prefs.resetAuthFailCount()
|
||||||
|
return@withContext base.copy(
|
||||||
|
messagesUsed = orgUsageData.messagesUsed,
|
||||||
|
messagesLimit = orgUsageData.messagesLimit,
|
||||||
|
messagesRemaining = orgUsageData.messagesRemaining,
|
||||||
|
resetAtEpoch = orgUsageData.resetAtEpoch
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: fallback endpoints (message-count style)
|
// Step 3: fallback endpoints (message-count style)
|
||||||
|
|||||||
@@ -31,10 +31,16 @@ class UsageUpdateWorker(
|
|||||||
val animJob = launch { rotateRefreshIcon() }
|
val animJob = launch { rotateRefreshIcon() }
|
||||||
try {
|
try {
|
||||||
val data = UsageRepository(prefs).fetchUsage()
|
val data = UsageRepository(prefs).fetchUsage()
|
||||||
// Preserve last-good data so a failed/partial fetch never blanks the widget.
|
// We passed the isLoggedIn() guard above, so if the session is gone now the
|
||||||
prefs.saveUsageData(data.mergedWith(prefs.getUsageData()))
|
// cookie just expired this cycle — tell the user once (next cycle returns early).
|
||||||
prefs.recordHistory(data) // history records only fresh readings
|
if (!prefs.isLoggedIn()) {
|
||||||
Notifier.checkAndNotify(context, prefs, data)
|
Notifier.notifySessionExpired(context)
|
||||||
|
} else {
|
||||||
|
// Preserve last-good data so a failed/partial fetch never blanks the widget.
|
||||||
|
prefs.saveUsageData(data.mergedWith(prefs.getUsageData()))
|
||||||
|
prefs.recordHistory(data) // history records only fresh readings
|
||||||
|
Notifier.checkAndNotify(context, prefs, data)
|
||||||
|
}
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
animJob.cancel()
|
animJob.cancel()
|
||||||
animJob.join()
|
animJob.join()
|
||||||
|
|||||||
@@ -24,9 +24,14 @@ class PreferencesManager(context: Context) {
|
|||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCookies(): String? = try {
|
fun getCookies(): String? {
|
||||||
securePrefs.getString(KEY_COOKIES, null)
|
// Cookies are never written in fallback (plaintext) mode — make that invariant explicit on
|
||||||
} catch (_: Exception) { null }
|
// the read side too, so any future write that bypasses the guard still can't surface here.
|
||||||
|
if (usingFallbackPrefs) return null
|
||||||
|
return try {
|
||||||
|
securePrefs.getString(KEY_COOKIES, null)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
fun clearSession() {
|
fun clearSession() {
|
||||||
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
|
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
|
||||||
@@ -89,22 +94,27 @@ class PreferencesManager(context: Context) {
|
|||||||
// ── Usage history (for the in-app chart) ─────────────────────────────────
|
// ── Usage history (for the in-app chart) ─────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a history point if [data] carries a real utilization reading.
|
* Append a history point if [data] carries a real reading.
|
||||||
|
* The session line uses [UsageData.sessionReadingPct] (utilization preferred, message-count
|
||||||
|
* progress as fallback) so accounts that only expose message counts still build history.
|
||||||
* De-duplicates rapid double-fires (manual refresh + background worker landing
|
* De-duplicates rapid double-fires (manual refresh + background worker landing
|
||||||
* together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and
|
* together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and
|
||||||
* prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS].
|
* prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS].
|
||||||
*/
|
*/
|
||||||
fun recordHistory(data: UsageData) {
|
fun recordHistory(data: UsageData) {
|
||||||
if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return
|
val sessionPct = data.sessionReadingPct
|
||||||
|
if (sessionPct < 0f && data.weeklyUtilization < 0f) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val history = getHistory().toMutableList()
|
val history = getHistory().toMutableList()
|
||||||
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) {
|
// Throttle to at most one point per MIN_HISTORY_GAP_MS by SKIPPING a too-soon reading.
|
||||||
history.removeAt(history.size - 1) // collapse near-simultaneous readings
|
// (Previously we deleted the last point and re-added in place — but the foreground loop
|
||||||
}
|
// refreshes every 30s, well inside this 2-min window, so history could never grow past a
|
||||||
|
// single point while the app was open and the chart stayed on "Collecting history…".)
|
||||||
|
if (!shouldRecordHistory(history.lastOrNull()?.epochMs, now, MIN_HISTORY_GAP_MS)) return
|
||||||
history.add(
|
history.add(
|
||||||
UsageSnapshot(
|
UsageSnapshot(
|
||||||
epochMs = now,
|
epochMs = now,
|
||||||
sessionPct = data.fiveHourUtilization,
|
sessionPct = sessionPct,
|
||||||
weeklyPct = data.weeklyUtilization
|
weeklyPct = data.weeklyUtilization
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -122,11 +132,53 @@ class PreferencesManager(context: Context) {
|
|||||||
} catch (e: Exception) { emptyList() }
|
} catch (e: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Widget style ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Home-screen widget visual style: [STYLE_BARS] (default) or [STYLE_RINGS]. */
|
||||||
|
fun getWidgetStyle(): String = prefs.getString(KEY_WIDGET_STYLE, STYLE_BARS) ?: STYLE_BARS
|
||||||
|
fun setWidgetStyle(style: String) = prefs.edit().putString(KEY_WIDGET_STYLE, style).apply()
|
||||||
|
|
||||||
// ── Notification settings ────────────────────────────────────────────────
|
// ── Notification settings ────────────────────────────────────────────────
|
||||||
|
|
||||||
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
|
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
|
||||||
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
|
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
|
||||||
|
|
||||||
|
// ── Material You (dynamic color) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** When on (and Android 12+), bars/rings use the wallpaper-derived system accents. */
|
||||||
|
fun isMaterialYou(): Boolean = prefs.getBoolean(KEY_MATERIAL_YOU, false)
|
||||||
|
fun setMaterialYou(v: Boolean) = prefs.edit().putBoolean(KEY_MATERIAL_YOU, v).apply()
|
||||||
|
|
||||||
|
// ── Settings backup / restore ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the user-tunable settings (NOT the session cookie or usage data) to JSON, so a
|
||||||
|
* reinstall doesn't lose preferences. Cookies are deliberately excluded — they're sensitive and
|
||||||
|
* device-bound, so a backup file stays safe to store anywhere.
|
||||||
|
*/
|
||||||
|
fun exportSettings(): String = gson.toJson(
|
||||||
|
mapOf(
|
||||||
|
"version" to 1,
|
||||||
|
KEY_NOTIFY_ENABLED to isNotifyEnabled(),
|
||||||
|
KEY_WIDGET_STYLE to getWidgetStyle(),
|
||||||
|
KEY_MATERIAL_YOU to isMaterialYou()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Apply a settings JSON produced by [exportSettings]. Returns true if it parsed and applied. */
|
||||||
|
fun importSettings(json: String): Boolean = try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val map = gson.fromJson(json, Map::class.java) as Map<String, Any?>
|
||||||
|
val e = prefs.edit()
|
||||||
|
(map[KEY_NOTIFY_ENABLED] as? Boolean)?.let { e.putBoolean(KEY_NOTIFY_ENABLED, it) }
|
||||||
|
(map[KEY_MATERIAL_YOU] as? Boolean)?.let { e.putBoolean(KEY_MATERIAL_YOU, it) }
|
||||||
|
(map[KEY_WIDGET_STYLE] as? String)?.let {
|
||||||
|
if (it == STYLE_BARS || it == STYLE_RINGS) e.putString(KEY_WIDGET_STYLE, it)
|
||||||
|
}
|
||||||
|
e.apply()
|
||||||
|
true
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
|
* Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
|
||||||
* cleared when usage drops back below it — so each level fires once per window.
|
* cleared when usage drops back below it — so each level fires once per window.
|
||||||
@@ -145,11 +197,27 @@ class PreferencesManager(context: Context) {
|
|||||||
private const val KEY_HISTORY = "usage_history"
|
private const val KEY_HISTORY = "usage_history"
|
||||||
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
|
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
|
||||||
private const val KEY_AUTH_FAILS = "auth_fail_count"
|
private const val KEY_AUTH_FAILS = "auth_fail_count"
|
||||||
|
private const val KEY_WIDGET_STYLE = "widget_style"
|
||||||
|
private const val KEY_MATERIAL_YOU = "material_you"
|
||||||
|
|
||||||
private const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
|
const val STYLE_BARS = "bars"
|
||||||
|
const val STYLE_RINGS = "rings"
|
||||||
|
|
||||||
|
internal const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
|
||||||
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
|
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
|
||||||
private const val MAX_HISTORY_POINTS = 600
|
private const val MAX_HISTORY_POINTS = 600
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure throttle decision for [recordHistory]: should a new point be appended?
|
||||||
|
* Returns false only when a previous point exists ([lastEpochMs] != null) AND the gap to
|
||||||
|
* [now] is below [minGapMs]; true otherwise (including the first-ever point, lastEpochMs == null).
|
||||||
|
* No Android dependencies — kept separate so the throttle rule is unit-testable.
|
||||||
|
*/
|
||||||
|
internal fun shouldRecordHistory(lastEpochMs: Long?, now: Long, minGapMs: Long): Boolean {
|
||||||
|
if (lastEpochMs == null) return true
|
||||||
|
return now - lastEpochMs >= minGapMs
|
||||||
|
}
|
||||||
|
|
||||||
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
||||||
return try {
|
return try {
|
||||||
buildEncryptedPrefs(context)
|
buildEncryptedPrefs(context)
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ data class UsageData(
|
|||||||
|
|
||||||
val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask)
|
val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session utilization as a 0-100 reading for the history chart, preferring the /usage
|
||||||
|
* utilization endpoint and falling back to message-count progress. Returns -1f when there is
|
||||||
|
* no usable reading — so message-only accounts (no utilization endpoint) still build history
|
||||||
|
* instead of leaving the chart permanently on "Collecting history…".
|
||||||
|
*/
|
||||||
|
val sessionReadingPct: Float get() = when {
|
||||||
|
fiveHourUtilization >= 0f -> fiveHourUtilization
|
||||||
|
messagesLimit > 0 && effectiveUsed >= 0 ->
|
||||||
|
((effectiveUsed.toFloat() / messagesLimit.toFloat()) * 100f).coerceIn(0f, 100f)
|
||||||
|
else -> -1f
|
||||||
|
}
|
||||||
|
|
||||||
// The best reset time we have (utilization endpoint is preferred)
|
// The best reset time we have (utilization endpoint is preferred)
|
||||||
val effectiveResetEpoch: Long get() = when {
|
val effectiveResetEpoch: Long get() = when {
|
||||||
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
|
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
|
||||||
|
|||||||
@@ -291,6 +291,90 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Widget style card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="RING WIDGET STYLE"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchRingStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Show usage as circular rings instead of bars on the full-size home-screen widget."
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Material You card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="MATERIAL YOU COLORS"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchMaterialYou"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Use your wallpaper-derived system accent colors for the bars/rings (Android 12+). Off = Claude brand colors."
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Notifications card -->
|
<!-- Notifications card -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -333,6 +417,60 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Backup & Restore card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="BACKUP & RESTORE"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Save your settings (widget style, Material You, alerts) to a file so a reinstall doesn't lose them. The Claude session cookie is never included."
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="14dp">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnExportSettings"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:text="Export"
|
||||||
|
style="?attr/materialButtonOutlinedStyle" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnImportSettings"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:text="Import"
|
||||||
|
style="?attr/materialButtonOutlinedStyle" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btnRefresh"
|
android:id="@+id/btnRefresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_root"
|
android:id="@+id/widget_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
android:src="@android:drawable/ic_menu_rotate"
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent"
|
||||||
android:tint="#999999"
|
android:tint="#999999"
|
||||||
|
tools:ignore="UseAppTint"
|
||||||
android:contentDescription="Refresh" />
|
android:contentDescription="Refresh" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Ring style of the full (4x2) widget. Header + footer mirror widget_layout.xml (same view
|
||||||
|
IDs) so ClaudeUsageWidget can attach the same click intents and peak/refresh state; the middle
|
||||||
|
swaps the two horizontal bars for two circular RingRenderer gauges side by side. -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Claude Pro"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/img_peak"
|
||||||
|
android:layout_width="16dp"
|
||||||
|
android:layout_height="16dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:src="@drawable/ic_claude_burst"
|
||||||
|
android:contentDescription="Peak hours" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_peak"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#CC785C"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_refresh"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
|
android:background="@android:color/transparent"
|
||||||
|
android:tint="#999999"
|
||||||
|
tools:ignore="UseAppTint"
|
||||||
|
android:contentDescription="Refresh" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
android:background="#2A2A2A" />
|
||||||
|
|
||||||
|
<!-- Two ring gauges side by side — each fills its half so the ring grows as large as the
|
||||||
|
placed widget allows (limited by the available height), instead of a fixed 86dp. -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center"
|
||||||
|
android:weightSum="2">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ring_session"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:adjustViewBounds="false"
|
||||||
|
android:contentDescription="Session usage ring" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_session_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#999999"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ring_weekly"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:adjustViewBounds="false"
|
||||||
|
android:contentDescription="Weekly usage ring" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_weekly_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#999999"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_status"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_updated"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-JVM tests for the history-throttle decision extracted from PreferencesManager.recordHistory.
|
||||||
|
* No Android Context — exercises only PreferencesManager.shouldRecordHistory.
|
||||||
|
*/
|
||||||
|
class HistoryThrottleTest {
|
||||||
|
|
||||||
|
private val gap = PreferencesManager.MIN_HISTORY_GAP_MS
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsTrueWhenNoPreviousPoint() {
|
||||||
|
// First-ever reading (lastEpochMs == null) is always recorded.
|
||||||
|
assertTrue(PreferencesManager.shouldRecordHistory(null, now = 1_000_000L, minGapMs = gap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsFalseWhenWithinGap() {
|
||||||
|
val last = 1_000_000L
|
||||||
|
// One millisecond before the gap elapses → throttled.
|
||||||
|
val now = last + gap - 1L
|
||||||
|
assertFalse(PreferencesManager.shouldRecordHistory(last, now, gap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsTrueExactlyAtGapBoundary() {
|
||||||
|
val last = 1_000_000L
|
||||||
|
// Exactly at the gap (>= boundary is inclusive) → recorded.
|
||||||
|
val now = last + gap
|
||||||
|
assertTrue(PreferencesManager.shouldRecordHistory(last, now, gap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsTrueAfterGap() {
|
||||||
|
val last = 1_000_000L
|
||||||
|
val now = last + gap + 1L
|
||||||
|
assertTrue(PreferencesManager.shouldRecordHistory(last, now, gap))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-JVM tests for PaceCalc.compute. The clock is injected via the `now` parameter, so these are
|
||||||
|
* deterministic and require no Android framework.
|
||||||
|
*/
|
||||||
|
class PaceCalcTest {
|
||||||
|
|
||||||
|
private val window = PaceCalc.SESSION_WINDOW_MS // 5h
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsPaceForValidFutureReset() {
|
||||||
|
// Place "now" halfway through the window (elapsedFraction = 0.5, within [0.03, 1.0)).
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch - window / 2
|
||||||
|
val pace = PaceCalc.compute(usedPct = 25f, resetEpoch = resetEpoch, windowMs = window, now = now)
|
||||||
|
|
||||||
|
assertNotNull(pace)
|
||||||
|
val p = pace!!
|
||||||
|
// markerPct mirrors elapsedFraction*100 → ~50, and must stay in 0..100.
|
||||||
|
assertTrue("markerPct in range", p.markerPct in 0..100)
|
||||||
|
assertEquals50(p.markerPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markerPctAlwaysInRangeNearWindowEnd() {
|
||||||
|
// Near the end of the window (95% elapsed, still < 1.0) marker approaches 100, never exceeds.
|
||||||
|
// Kept off the exact boundary so Float rounding of (window-elapsed)/window can't tip it to 1.0.
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch - (window * 5L / 100L) // 95% elapsed
|
||||||
|
val pace = PaceCalc.compute(usedPct = 80f, resetEpoch = resetEpoch, windowMs = window, now = now)
|
||||||
|
|
||||||
|
assertNotNull(pace)
|
||||||
|
assertTrue(pace!!.markerPct in 0..100)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsNullForPastReset() {
|
||||||
|
// Reset already passed → elapsedFraction >= 1.0 → null.
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch + 60_000L
|
||||||
|
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = resetEpoch, windowMs = window, now = now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsNullTooEarlyInWindow() {
|
||||||
|
// Less than 3% elapsed → not enough signal → null.
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch - window + (window * 0.01f).toLong() // 1% elapsed
|
||||||
|
assertNull(PaceCalc.compute(usedPct = 5f, resetEpoch = resetEpoch, windowMs = window, now = now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsNullForNonPositiveReset() {
|
||||||
|
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = 0L, windowMs = window))
|
||||||
|
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = -1L, windowMs = window))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsNullForNegativeUsedPct() {
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch - window / 2
|
||||||
|
assertNull(PaceCalc.compute(usedPct = -1f, resetEpoch = resetEpoch, windowMs = window, now = now))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertEquals50(markerPct: Int) {
|
||||||
|
// elapsedFraction 0.5 → (0.5*100).toInt() == 50.
|
||||||
|
org.junit.Assert.assertEquals(50, markerPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.GregorianCalendar
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-JVM tests for PeakHours.isPeak. The clock is injected via `now`, and PeakHours evaluates the
|
||||||
|
* window in America/Los_Angeles regardless of the device timezone — so we build each instant in PT
|
||||||
|
* explicitly, making these deterministic without touching the system clock.
|
||||||
|
*/
|
||||||
|
class PeakHoursTest {
|
||||||
|
|
||||||
|
private val pt: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
|
||||||
|
|
||||||
|
/** Absolute epoch millis for the given wall-clock time in PT on a fixed reference date. */
|
||||||
|
private fun ptInstant(year: Int, month0: Int, day: Int, hour: Int): Long {
|
||||||
|
val cal = GregorianCalendar(pt)
|
||||||
|
cal.clear()
|
||||||
|
cal.set(year, month0, day, hour, 0, 0)
|
||||||
|
return cal.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeOnWeekdayInsideWindow() {
|
||||||
|
// 2026-06-10 is a Wednesday. 8 AM PT is inside 5–11 AM.
|
||||||
|
assertTrue(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveAtStartBoundaryIsInclusiveButBeforeIsNot() {
|
||||||
|
// 5 AM PT is inside (inclusive start); 4 AM PT is outside.
|
||||||
|
assertTrue(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 5)))
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 4)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveAtEndBoundaryExclusive() {
|
||||||
|
// 11 AM PT is the exclusive end → not peak.
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 11)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveOnWeekendEvenInsideHours() {
|
||||||
|
// 2026-06-13 is a Saturday, 2026-06-14 is a Sunday. 8 AM PT, but weekend → not peak.
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 13, 8)))
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 14, 8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveOnWeekdayEvening() {
|
||||||
|
// 8 PM PT Wednesday → outside the morning window.
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 20)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import me.khodak.claudeusage.data.UsageData
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-JVM tests for the UsageData data class: derived metrics and the mergedWith fallback logic.
|
||||||
|
* UsageData has no Android dependencies, so these run on the plain JVM.
|
||||||
|
*/
|
||||||
|
class UsageDataTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentMessagesPath() {
|
||||||
|
// messagesLimit > 0 and effectiveUsed derived from messagesUsed → 25/100 = 25%.
|
||||||
|
val d = UsageData(messagesUsed = 25, messagesLimit = 100)
|
||||||
|
assertEquals(25, d.progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentMessagesPathCoercedTo100() {
|
||||||
|
// Used above limit must clamp to 100, never exceed.
|
||||||
|
val d = UsageData(messagesUsed = 150, messagesLimit = 100)
|
||||||
|
assertEquals(100, d.progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentUtilizationPathWhenNoMessages() {
|
||||||
|
// No messages limit → fall through to utilization, truncated to Int and coerced.
|
||||||
|
val d = UsageData(fiveHourUtilization = 42.9f)
|
||||||
|
assertEquals(42, d.progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentUtilizationCoercedTo100() {
|
||||||
|
val d = UsageData(fiveHourUtilization = 130f)
|
||||||
|
assertEquals(100, d.progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentZeroWhenNoReadings() {
|
||||||
|
assertEquals(0, UsageData().progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveUsedPrefersMessagesUsed() {
|
||||||
|
assertEquals(30, UsageData(messagesUsed = 30, messagesLimit = 100).effectiveUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveUsedDerivedFromRemaining() {
|
||||||
|
// No messagesUsed, but remaining + limit present → limit - remaining.
|
||||||
|
val d = UsageData(messagesRemaining = 40, messagesLimit = 100)
|
||||||
|
assertEquals(60, d.effectiveUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveUsedUnknownIsNegativeOne() {
|
||||||
|
assertEquals(-1, UsageData().effectiveUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveRemainingPrefersRemaining() {
|
||||||
|
assertEquals(40, UsageData(messagesRemaining = 40, messagesLimit = 100).effectiveRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveRemainingDerivedFromUsed() {
|
||||||
|
val d = UsageData(messagesUsed = 70, messagesLimit = 100)
|
||||||
|
assertEquals(30, d.effectiveRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveRemainingUnknownIsNegativeOne() {
|
||||||
|
assertEquals(-1, UsageData().effectiveRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasAnyReadingTrueWithUtilization() {
|
||||||
|
assertTrue(UsageData(fiveHourUtilization = 10f).hasAnyReading)
|
||||||
|
assertTrue(UsageData(weeklyUtilization = 5f).hasAnyReading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasAnyReadingTrueWithRateLimitData() {
|
||||||
|
// hasRateLimitData true when messagesLimit > 0.
|
||||||
|
assertTrue(UsageData(messagesLimit = 100).hasAnyReading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasAnyReadingFalseWhenEmpty() {
|
||||||
|
assertFalse(UsageData().hasAnyReading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionReadingPctPrefersUtilization() {
|
||||||
|
// Utilization present → used verbatim (kept as Float, not truncated), even alongside messages.
|
||||||
|
val d = UsageData(fiveHourUtilization = 42.5f, messagesUsed = 10, messagesLimit = 100)
|
||||||
|
assertEquals(42.5f, d.sessionReadingPct, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionReadingPctFallsBackToMessages() {
|
||||||
|
// No utilization → derive from message counts so message-only accounts still get history.
|
||||||
|
val d = UsageData(messagesUsed = 30, messagesLimit = 120)
|
||||||
|
assertEquals(25f, d.sessionReadingPct, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionReadingPctMessagesCoercedTo100() {
|
||||||
|
val d = UsageData(messagesUsed = 150, messagesLimit = 100)
|
||||||
|
assertEquals(100f, d.sessionReadingPct, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionReadingPctNegativeWhenNoReading() {
|
||||||
|
// No utilization and no message data → -1f, so recordHistory skips the point.
|
||||||
|
assertEquals(-1f, UsageData().sessionReadingPct, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergedWithEmptyFetchKeepsPreviousMetrics() {
|
||||||
|
// (a) previous has a reading; this fetch is empty (all defaults) → previous metrics kept.
|
||||||
|
val previous = UsageData(
|
||||||
|
fiveHourUtilization = 55f,
|
||||||
|
utilizationResetAtEpoch = 1_000L,
|
||||||
|
weeklyUtilization = 20f,
|
||||||
|
weeklyResetAtEpoch = 2_000L,
|
||||||
|
messagesLimit = 100,
|
||||||
|
messagesUsed = 40
|
||||||
|
)
|
||||||
|
val emptyFetch = UsageData(isLoggedIn = true)
|
||||||
|
val merged = emptyFetch.mergedWith(previous)
|
||||||
|
|
||||||
|
assertEquals(55f, merged.fiveHourUtilization, 0f)
|
||||||
|
assertEquals(20f, merged.weeklyUtilization, 0f)
|
||||||
|
assertEquals(100, merged.messagesLimit)
|
||||||
|
assertEquals(40, merged.messagesUsed)
|
||||||
|
assertEquals(1_000L, merged.utilizationResetAtEpoch)
|
||||||
|
assertEquals(2_000L, merged.weeklyResetAtEpoch)
|
||||||
|
// Login context from the fresh (empty) attempt is carried forward.
|
||||||
|
assertTrue(merged.isLoggedIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergedWithPartialFetchUnionsBothWindows() {
|
||||||
|
// (b) previous only had five-hour; this fetch only has weekly → result has both.
|
||||||
|
val previous = UsageData(
|
||||||
|
fiveHourUtilization = 33f,
|
||||||
|
utilizationResetAtEpoch = 500L
|
||||||
|
)
|
||||||
|
val partial = UsageData(
|
||||||
|
weeklyUtilization = 77f,
|
||||||
|
weeklyResetAtEpoch = 900L
|
||||||
|
)
|
||||||
|
val merged = partial.mergedWith(previous)
|
||||||
|
|
||||||
|
assertEquals(33f, merged.fiveHourUtilization, 0f)
|
||||||
|
assertEquals(500L, merged.utilizationResetAtEpoch)
|
||||||
|
assertEquals(77f, merged.weeklyUtilization, 0f)
|
||||||
|
assertEquals(900L, merged.weeklyResetAtEpoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergedWithNullPreviousReturnsThis() {
|
||||||
|
// (c) previous == null → returns this unchanged.
|
||||||
|
val fetch = UsageData(fiveHourUtilization = 12f)
|
||||||
|
assertSame(fetch, fetch.mergedWith(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergedWithPreviousWithoutReadingReturnsThis() {
|
||||||
|
// previous has no usable reading → this is returned unchanged.
|
||||||
|
val fetch = UsageData(fiveHourUtilization = 12f)
|
||||||
|
assertSame(fetch, fetch.mergedWith(UsageData()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
# Release signing credentials for local builds.
|
||||||
|
# Copy to `keystore.properties` (which is gitignored) and fill in the real values.
|
||||||
|
# These are NEVER committed. CI injects the same values from Gitea Actions secrets.
|
||||||
|
#
|
||||||
|
# cp keystore.properties.example keystore.properties
|
||||||
|
#
|
||||||
|
# The keystore file itself (app/claude-widget-release.keystore) is also gitignored.
|
||||||
|
# Signing identity is unchanged from older builds — same keystore, same alias.
|
||||||
|
|
||||||
|
storePassword=CHANGE_ME
|
||||||
|
keyPassword=CHANGE_ME
|
||||||
|
keyAlias=claudewidget
|
||||||
Binary file not shown.
Reference in New Issue
Block a user