Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adb389984e | |||
| 33ac02ead4 |
+12
@@ -0,0 +1,12 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build/
|
||||||
|
app/build/
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
@@ -1,3 +1,30 @@
|
|||||||
# claude-usage-widget
|
# Claude Usage Widget
|
||||||
|
|
||||||
Claude Pro usage Android widget
|
Android home screen widget that shows your Claude Pro usage at a glance.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **SESSION** bar — current 5-hour window utilization with reset time
|
||||||
|
- **WEEKLY** bar — 7-day rolling usage with reset time
|
||||||
|
- Tap the widget to open the app; tap ⟳ to force-refresh
|
||||||
|
- Responsive: works as 4×1 (compact) or 4×2 (full)
|
||||||
|
- Auto-refreshes every 5 minutes in the background
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Download `claude-usage-widget.apk` from the [latest release](../../releases/latest)
|
||||||
|
2. On your Android phone: **Settings → Apps → Install unknown apps** → allow your browser/file manager
|
||||||
|
3. Open the downloaded APK and tap Install
|
||||||
|
4. Open the **Claude Usage** app and sign in with your Claude.ai session cookies
|
||||||
|
5. Long-press your home screen → Widgets → Claude Usage → drag to place
|
||||||
|
|
||||||
|
## Sign-in
|
||||||
|
|
||||||
|
The app uses your Claude.ai browser cookies (not a password). In the app, tap **Sign In**, then paste your cookies from a logged-in Claude.ai browser session.
|
||||||
|
|
||||||
|
To get cookies: open claude.ai in Chrome → DevTools (F12) → Application → Cookies → copy the `Cookie` header value.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Android 8.0+ (API 26)
|
||||||
|
- Active Claude Pro subscription
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "me.khodak.claudeusage"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "me.khodak.claudeusage"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 2
|
||||||
|
versionName = "1.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file("claude-widget-release.keystore")
|
||||||
|
storePassword = "ClaudeWidget2026!"
|
||||||
|
keyAlias = "claudewidget"
|
||||||
|
keyPassword = "ClaudeWidget2026!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
|
|
||||||
|
// Background work
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
|
||||||
|
// Secure storage
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-keep class me.khodak.claudeusage.data.** { *; }
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ClaudeUsage"
|
||||||
|
android:usesCleartextTraffic="false">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".LoginActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/login_title" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ClaudeUsageWidget"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
<action android:name="me.khodak.claudeusage.ACTION_REFRESH" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".AlarmReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name="androidx.work.impl.background.systemalarm.RescheduleReceiver"
|
||||||
|
android:exported="false"
|
||||||
|
android:directBootAware="false"
|
||||||
|
tools:node="replace"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"/>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
class AlarmReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
UsageUpdateWorker.triggerImmediateRefresh(context)
|
||||||
|
UsageUpdateWorker.schedulePeriodicRefresh(context) // chain next 5-min alarm
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.SizeF
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import me.khodak.claudeusage.data.UsageData
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class ClaudeUsageWidget : AppWidgetProvider() {
|
||||||
|
|
||||||
|
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
|
||||||
|
ids.forEach { updateWidget(context, manager, it) }
|
||||||
|
if (PreferencesManager(context).isLoggedIn()) {
|
||||||
|
UsageUpdateWorker.schedulePeriodicRefresh(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
super.onReceive(context, intent)
|
||||||
|
if (intent.action == ACTION_REFRESH) {
|
||||||
|
val manager = AppWidgetManager.getInstance(context)
|
||||||
|
val ids = manager.getAppWidgetIds(
|
||||||
|
android.content.ComponentName(context, ClaudeUsageWidget::class.java)
|
||||||
|
)
|
||||||
|
isRefreshing = true
|
||||||
|
ids.forEach { updateWidget(context, manager, it) }
|
||||||
|
UsageUpdateWorker.triggerImmediateRefresh(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_REFRESH = "me.khodak.claudeusage.ACTION_REFRESH"
|
||||||
|
@Volatile internal var isRefreshing = false
|
||||||
|
|
||||||
|
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
|
||||||
|
val prefs = PreferencesManager(context)
|
||||||
|
val apiData = prefs.getUsageData()
|
||||||
|
val views = responsiveViews(context, prefs, apiData, widgetId)
|
||||||
|
manager.updateAppWidget(widgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int, data: UsageData?) {
|
||||||
|
val prefs = PreferencesManager(context)
|
||||||
|
val views = responsiveViews(context, prefs, data, widgetId)
|
||||||
|
manager.updateAppWidget(widgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun responsiveViews(
|
||||||
|
context: Context, prefs: PreferencesManager, apiData: UsageData?, widgetId: Int
|
||||||
|
): RemoteViews {
|
||||||
|
val refreshPi = PendingIntent.getBroadcast(
|
||||||
|
context, widgetId,
|
||||||
|
Intent(context, ClaudeUsageWidget::class.java).apply { action = ACTION_REFRESH },
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val openPi = PendingIntent.getActivity(
|
||||||
|
context, widgetId + 1000,
|
||||||
|
Intent(context, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
fun attach(v: RemoteViews): RemoteViews {
|
||||||
|
v.setOnClickPendingIntent(R.id.btn_refresh, refreshPi)
|
||||||
|
v.setOnClickPendingIntent(R.id.widget_root, openPi)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
RemoteViews(mapOf(
|
||||||
|
SizeF(50f, 50f) to attach(buildSmallViews(context, prefs, apiData)),
|
||||||
|
SizeF(50f, 120f) to attach(buildViews(context, prefs, apiData))
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
attach(buildViews(context, prefs, apiData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
|
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
||||||
|
if (!prefs.isLoggedIn()) {
|
||||||
|
v.setTextViewText(R.id.tv_session_value, "—")
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "Not signed in")
|
||||||
|
v.setTextViewText(R.id.tv_weekly_value, "—")
|
||||||
|
v.setTextViewText(R.id.tv_weekly_label, "")
|
||||||
|
v.setTextViewText(R.id.tv_status, "")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
|
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
|
||||||
|
val hasApiMessages = apiData != null && apiData.effectiveRemaining >= 0 && apiData.messagesLimit > 0
|
||||||
|
val sessionStart = prefs.getSessionStart()
|
||||||
|
when {
|
||||||
|
hasUtilization -> {
|
||||||
|
val pct = apiData!!.fiveHourUtilization.toInt()
|
||||||
|
v.setTextViewText(R.id.tv_session_value, "$pct%")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, pct, false)
|
||||||
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
||||||
|
}
|
||||||
|
hasApiMessages -> {
|
||||||
|
val rem = apiData!!.effectiveRemaining
|
||||||
|
val lim = apiData.messagesLimit
|
||||||
|
v.setTextViewText(R.id.tv_session_value, "$rem/$lim")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
|
||||||
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
||||||
|
}
|
||||||
|
sessionStart > 0 -> {
|
||||||
|
val elapsedMs = System.currentTimeMillis() - sessionStart
|
||||||
|
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
|
||||||
|
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
|
||||||
|
v.setTextViewText(R.id.tv_session_value, if (h > 0) "${h}h${m}m" else "${m}m")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "active")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
v.setTextViewText(R.id.tv_session_value, "—")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
||||||
|
if (hasWeekly) {
|
||||||
|
val wPct = apiData!!.weeklyUtilization.toInt()
|
||||||
|
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
|
||||||
|
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
|
||||||
|
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
|
||||||
|
} else {
|
||||||
|
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
||||||
|
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
|
||||||
|
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||||
|
v.setTextViewText(R.id.tv_weekly_label, "")
|
||||||
|
}
|
||||||
|
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
|
||||||
|
val status = when {
|
||||||
|
isRefreshing -> "Refreshing…"
|
||||||
|
apiData?.isRateLimited == true -> "Rate limited · ${formatReset(resetEpoch)}"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
val updatedMs = apiData?.lastUpdated ?: 0L
|
||||||
|
v.setTextViewText(R.id.tv_status,
|
||||||
|
if (status.isNotBlank()) status else if (updatedMs > 0) formatTime(updatedMs) else "")
|
||||||
|
v.setInt(R.id.btn_refresh, "setColorFilter",
|
||||||
|
if (isRefreshing) 0xFFCC785C.toInt() else 0xFF999999.toInt())
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
|
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
||||||
|
|
||||||
|
// ── Not logged in ────────────────────────────────────────────────
|
||||||
|
if (!prefs.isLoggedIn()) {
|
||||||
|
v.setTextViewText(R.id.tv_session_value, "—")
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "Not signed in")
|
||||||
|
v.setTextViewText(R.id.tv_weekly_value, "—")
|
||||||
|
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
|
||||||
|
v.setTextViewText(R.id.tv_status, "")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5-hour window bar ────────────────────────────────────────────
|
||||||
|
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
|
||||||
|
val hasApiMessages = apiData != null && apiData.effectiveRemaining >= 0 && apiData.messagesLimit > 0
|
||||||
|
val sessionStart = prefs.getSessionStart()
|
||||||
|
|
||||||
|
when {
|
||||||
|
hasUtilization -> {
|
||||||
|
val pct = apiData!!.fiveHourUtilization.toInt()
|
||||||
|
v.setTextViewText(R.id.tv_session_value, "$pct%")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, pct, false)
|
||||||
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
||||||
|
}
|
||||||
|
hasApiMessages -> {
|
||||||
|
val rem = apiData!!.effectiveRemaining
|
||||||
|
val lim = apiData.messagesLimit
|
||||||
|
v.setTextViewText(R.id.tv_session_value, "$rem / $lim")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
|
||||||
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
||||||
|
}
|
||||||
|
sessionStart > 0 -> {
|
||||||
|
val elapsedMs = System.currentTimeMillis() - sessionStart
|
||||||
|
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
|
||||||
|
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
|
||||||
|
val display = if (h > 0) "${h}h ${m}m" else if (m > 0) "${m}m" else "Just started"
|
||||||
|
v.setTextViewText(R.id.tv_session_value, display)
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "session active")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
v.setTextViewText(R.id.tv_session_value, "—")
|
||||||
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
|
v.setTextViewText(R.id.tv_session_label, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7-day usage bar ──────────────────────────────────────────────
|
||||||
|
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
||||||
|
if (hasWeekly) {
|
||||||
|
val wPct = apiData!!.weeklyUtilization.toInt()
|
||||||
|
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
|
||||||
|
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
|
||||||
|
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
|
||||||
|
} else {
|
||||||
|
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
||||||
|
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
|
||||||
|
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||||
|
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())
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(ms: Long) =
|
||||||
|
SimpleDateFormat("h:mm a", Locale.US).format(Date(ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.webkit.*
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import me.khodak.claudeusage.databinding.ActivityLoginBinding
|
||||||
|
|
||||||
|
class LoginActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var binding: ActivityLoginBinding
|
||||||
|
private lateinit var prefs: PreferencesManager
|
||||||
|
private var loginHandled = false
|
||||||
|
|
||||||
|
// JS injected before page scripts run — hides WebView fingerprints
|
||||||
|
private val antiDetectionJs = """
|
||||||
|
(function() {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
window.chrome = { runtime: {} };
|
||||||
|
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3] });
|
||||||
|
Object.defineProperty(navigator, 'languages', { get: () => ['en-US','en'] });
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
prefs = PreferencesManager(this)
|
||||||
|
|
||||||
|
setupTabs()
|
||||||
|
setupWebView()
|
||||||
|
setupCookiePanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupTabs() {
|
||||||
|
binding.tabBrowser.setOnClickListener {
|
||||||
|
binding.panelBrowser.visibility = View.VISIBLE
|
||||||
|
binding.panelCookie.visibility = View.GONE
|
||||||
|
binding.tabBrowser.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFFCC785C.toInt())
|
||||||
|
binding.tabBrowser.setTextColor(0xFFFFFFFF.toInt())
|
||||||
|
binding.tabCookie.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFF2A2A2A.toInt())
|
||||||
|
binding.tabCookie.setTextColor(0xFF888888.toInt())
|
||||||
|
}
|
||||||
|
binding.tabCookie.setOnClickListener {
|
||||||
|
binding.panelBrowser.visibility = View.GONE
|
||||||
|
binding.panelCookie.visibility = View.VISIBLE
|
||||||
|
binding.tabCookie.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFFCC785C.toInt())
|
||||||
|
binding.tabCookie.setTextColor(0xFFFFFFFF.toInt())
|
||||||
|
binding.tabBrowser.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFF2A2A2A.toInt())
|
||||||
|
binding.tabBrowser.setTextColor(0xFF888888.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun setupWebView() {
|
||||||
|
CookieManager.getInstance().removeAllCookies(null)
|
||||||
|
CookieManager.getInstance().flush()
|
||||||
|
|
||||||
|
binding.btnDone.setOnClickListener { attemptCookieCapture(force = true) }
|
||||||
|
|
||||||
|
with(binding.webView) {
|
||||||
|
settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
databaseEnabled = true
|
||||||
|
javaScriptCanOpenWindowsAutomatically = true
|
||||||
|
setSupportMultipleWindows(false)
|
||||||
|
// Standard Android Chrome UA — less suspicious than desktop
|
||||||
|
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
|
"Chrome/120.0.6099.230 Mobile Safari/537.36"
|
||||||
|
}
|
||||||
|
CookieManager.getInstance().setAcceptCookie(true)
|
||||||
|
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||||
|
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
|
val url = request.url.toString()
|
||||||
|
if (url.startsWith("market://") || url.startsWith("intent://")) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
|
||||||
|
// Inject before page scripts
|
||||||
|
view.evaluateJavascript(antiDetectionJs, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String) {
|
||||||
|
// Re-inject after page load
|
||||||
|
view.evaluateJavascript(antiDetectionJs, null)
|
||||||
|
|
||||||
|
if (url.contains("claude.ai")) {
|
||||||
|
binding.btnDone.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
if (loginHandled) return
|
||||||
|
val onMain = url.startsWith("https://claude.ai") &&
|
||||||
|
!url.contains("/login") && !url.contains("/auth") &&
|
||||||
|
!url.contains("/sign") && !url.contains("/verify")
|
||||||
|
if (onMain) attemptCookieCapture(force = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadUrl("https://claude.ai/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupCookiePanel() {
|
||||||
|
binding.btnSaveCookie.setOnClickListener {
|
||||||
|
val cookie = binding.etCookie.text.toString().trim()
|
||||||
|
if (cookie.length < 10) {
|
||||||
|
Toast.makeText(this, "Cookie too short — paste the full string", Toast.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
handleLoginSuccess(cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attemptCookieCapture(force: Boolean) {
|
||||||
|
if (loginHandled) return
|
||||||
|
CookieManager.getInstance().flush()
|
||||||
|
val cookies = CookieManager.getInstance().getCookie("https://claude.ai") ?: ""
|
||||||
|
if (cookies.length > 10) {
|
||||||
|
loginHandled = true
|
||||||
|
handleLoginSuccess(cookies)
|
||||||
|
} else if (force) {
|
||||||
|
Toast.makeText(this, "No session found — finish logging in first, or use the Paste Cookie tab", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleLoginSuccess(cookies: String) {
|
||||||
|
prefs.saveCookies(cookies)
|
||||||
|
prefs.saveSessionStart(System.currentTimeMillis())
|
||||||
|
prefs.markTodayActive()
|
||||||
|
Toast.makeText(this, "Signed in — loading usage…", Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
UsageUpdateWorker.triggerImmediateRefresh(this)
|
||||||
|
UsageUpdateWorker.schedulePeriodicRefresh(this)
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val data = UsageRepository(prefs).fetchUsage()
|
||||||
|
prefs.saveUsageData(data)
|
||||||
|
val mgr = AppWidgetManager.getInstance(this@LoginActivity)
|
||||||
|
val ids = mgr.getAppWidgetIds(ComponentName(this@LoginActivity, ClaudeUsageWidget::class.java))
|
||||||
|
ids.forEach { id -> ClaudeUsageWidget.updateWidget(this@LoginActivity, mgr, id, data) }
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBackPressed() {
|
||||||
|
if (binding.panelBrowser.visibility == View.VISIBLE && binding.webView.canGoBack())
|
||||||
|
binding.webView.goBack()
|
||||||
|
else super.onBackPressed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
binding.webView.destroy()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
val cached = prefs.getUsageData()
|
||||||
|
updateUI(cached)
|
||||||
|
if (prefs.isLoggedIn()) {
|
||||||
|
refreshUsage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshUsage() {
|
||||||
|
binding.btnRefresh.isEnabled = false
|
||||||
|
binding.progressIndicator.visibility = View.VISIBLE
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val data = try {
|
||||||
|
repo.fetchUsage()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
prefs.getUsageData()?.copy(errorMessage = "Network error")
|
||||||
|
?: UsageData(errorMessage = "Network error")
|
||||||
|
}
|
||||||
|
prefs.saveUsageData(data)
|
||||||
|
updateUI(data)
|
||||||
|
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
|
||||||
|
binding.tvDebugInfo.text = repo.lastDebugInfo
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
binding.progressBar.progress = data.progressPercent
|
||||||
|
|
||||||
|
if (data.weeklyUtilization >= 0f) {
|
||||||
|
val wPct = data.weeklyUtilization.toInt()
|
||||||
|
binding.progressBarWeekly.progress = wPct
|
||||||
|
binding.tvWeeklyUsage.text = "$wPct% this week"
|
||||||
|
} else {
|
||||||
|
binding.progressBarWeekly.progress = 0
|
||||||
|
binding.tvWeeklyUsage.text = "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = formatReset(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
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import me.khodak.claudeusage.data.UsageData
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class UsageRepository(private val prefs: PreferencesManager) {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.followRedirects(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val desktopUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
private val debugBuf = StringBuilder()
|
||||||
|
val lastDebugInfo: String get() = debugBuf.toString()
|
||||||
|
|
||||||
|
suspend fun fetchUsage(): UsageData = withContext(Dispatchers.IO) {
|
||||||
|
debugBuf.clear()
|
||||||
|
val cookies = prefs.getCookies()
|
||||||
|
?: return@withContext UsageData(errorMessage = "Not logged in")
|
||||||
|
|
||||||
|
prefs.markTodayActive()
|
||||||
|
|
||||||
|
val base = UsageData(
|
||||||
|
isLoggedIn = true,
|
||||||
|
sessionStartEpoch = prefs.getSessionStart(),
|
||||||
|
weeklyActiveDaysMask = prefs.getWeeklyMask(),
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 1: get org ID
|
||||||
|
var orgId = prefs.getOrgId()
|
||||||
|
var orgUsageData: UsageData? = null
|
||||||
|
val (fetchedId, fetchedUsage) = fetchOrgInfo(cookies)
|
||||||
|
if (orgId == null && fetchedId != null) {
|
||||||
|
orgId = fetchedId
|
||||||
|
prefs.saveOrgId(orgId)
|
||||||
|
}
|
||||||
|
orgUsageData = fetchedUsage
|
||||||
|
|
||||||
|
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
|
||||||
|
|
||||||
|
if (orgUsageData?.hasRateLimitData == true) {
|
||||||
|
return@withContext base.copy(
|
||||||
|
messagesUsed = orgUsageData.messagesUsed,
|
||||||
|
messagesLimit = orgUsageData.messagesLimit,
|
||||||
|
messagesRemaining = orgUsageData.messagesRemaining,
|
||||||
|
resetAtEpoch = orgUsageData.resetAtEpoch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: /usage endpoint — returns utilization percentages
|
||||||
|
try {
|
||||||
|
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
||||||
|
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
|
||||||
|
val code = resp.code
|
||||||
|
Log.d(TAG, "GET $usageUrl → $code")
|
||||||
|
if (code == 401 || code == 403) {
|
||||||
|
prefs.clearSession()
|
||||||
|
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
|
||||||
|
}
|
||||||
|
val body = resp.body?.string() ?: ""
|
||||||
|
debugBuf.append("$usageUrl\n→ $code: ${body.take(400)}\n\n")
|
||||||
|
val utilData = tryParseUtilizationBody(body)
|
||||||
|
if (utilData != null) {
|
||||||
|
return@withContext base.copy(
|
||||||
|
fiveHourUtilization = utilData.fiveHourUtilization,
|
||||||
|
weeklyUtilization = utilData.weeklyUtilization,
|
||||||
|
utilizationResetAtEpoch = utilData.utilizationResetAtEpoch,
|
||||||
|
weeklyResetAtEpoch = utilData.weeklyResetAtEpoch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "/usage failed: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: fallback endpoints (message-count style)
|
||||||
|
val endpoints = listOf(
|
||||||
|
"https://claude.ai/api/organizations/$orgId/rate_limit_status",
|
||||||
|
"https://claude.ai/api/organizations/$orgId"
|
||||||
|
)
|
||||||
|
|
||||||
|
for (url in endpoints) {
|
||||||
|
try {
|
||||||
|
val req = buildRequest(url, cookies)
|
||||||
|
val resp = client.newCall(req).execute()
|
||||||
|
val code = resp.code
|
||||||
|
Log.d(TAG, "GET $url → $code")
|
||||||
|
|
||||||
|
if (code == 401 || code == 403) {
|
||||||
|
prefs.clearSession()
|
||||||
|
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
|
||||||
|
}
|
||||||
|
|
||||||
|
val rateLimitData = extractRateLimitHeaders(resp.headers)
|
||||||
|
val body = resp.body?.string() ?: ""
|
||||||
|
debugBuf.append("$url\n→ $code: ${body.take(300)}\n\n")
|
||||||
|
Log.d(TAG, "Body: ${body.take(300)}")
|
||||||
|
|
||||||
|
val parsed = tryParseUsageBody(body, rateLimitData)
|
||||||
|
if (parsed.hasRateLimitData) {
|
||||||
|
return@withContext base.copy(
|
||||||
|
messagesUsed = parsed.messagesUsed,
|
||||||
|
messagesLimit = parsed.messagesLimit,
|
||||||
|
messagesRemaining = parsed.messagesRemaining,
|
||||||
|
resetAtEpoch = parsed.resetAtEpoch,
|
||||||
|
isRateLimited = parsed.isRateLimited
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Endpoint $url failed: ${e.message}")
|
||||||
|
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: try /api/me endpoint
|
||||||
|
try {
|
||||||
|
val req = buildRequest("https://claude.ai/api/me", cookies)
|
||||||
|
val resp = client.newCall(req).execute()
|
||||||
|
val body = resp.body?.string() ?: ""
|
||||||
|
debugBuf.append("https://claude.ai/api/me\n→ ${resp.code}: ${body.take(400)}\n\n")
|
||||||
|
if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
val parsed = tryParseOrgForUsage(json)
|
||||||
|
if (parsed?.hasRateLimitData == true) {
|
||||||
|
return@withContext base.copy(
|
||||||
|
messagesUsed = parsed.messagesUsed,
|
||||||
|
messagesLimit = parsed.messagesLimit,
|
||||||
|
messagesRemaining = parsed.messagesRemaining,
|
||||||
|
resetAtEpoch = parsed.resetAtEpoch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: try page HTML for __NEXT_DATA__
|
||||||
|
val htmlData = fetchFromPageHtml(cookies)
|
||||||
|
if (htmlData?.hasRateLimitData == true) {
|
||||||
|
return@withContext base.copy(
|
||||||
|
messagesUsed = htmlData.messagesUsed,
|
||||||
|
messagesLimit = htmlData.messagesLimit,
|
||||||
|
messagesRemaining = htmlData.messagesRemaining,
|
||||||
|
resetAtEpoch = htmlData.resetAtEpoch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchFromPageHtml(cookies: String): UsageData? {
|
||||||
|
return try {
|
||||||
|
val resp = client.newCall(buildRequest("https://claude.ai/", cookies)).execute()
|
||||||
|
val html = resp.body?.string() ?: return null
|
||||||
|
val marker = """<script id="__NEXT_DATA__" type="application/json">"""
|
||||||
|
val start = html.indexOf(marker)
|
||||||
|
if (start < 0) {
|
||||||
|
debugBuf.append("HTML: no __NEXT_DATA__ found\n")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val jsonStart = start + marker.length
|
||||||
|
val jsonEnd = html.indexOf("</script>", jsonStart)
|
||||||
|
if (jsonEnd < 0) return null
|
||||||
|
val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
|
||||||
|
val topKeys = nextData.keys().asSequence().toList()
|
||||||
|
debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
|
||||||
|
tryExtractFromNextData(nextData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
debugBuf.append("HTML scrape error: ${e.message}\n")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryExtractFromNextData(json: JSONObject): UsageData? {
|
||||||
|
// Walk common Next.js page prop paths looking for usage data
|
||||||
|
val paths = listOf(
|
||||||
|
listOf("props", "pageProps", "rateLimits"),
|
||||||
|
listOf("props", "pageProps", "usage"),
|
||||||
|
listOf("props", "pageProps", "account"),
|
||||||
|
listOf("props", "pageProps", "organization"),
|
||||||
|
listOf("props", "pageProps"),
|
||||||
|
)
|
||||||
|
for (path in paths) {
|
||||||
|
var obj: Any? = json
|
||||||
|
for (key in path) {
|
||||||
|
obj = (obj as? JSONObject)?.opt(key)
|
||||||
|
}
|
||||||
|
val o = obj as? JSONObject ?: continue
|
||||||
|
debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
|
||||||
|
val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
|
||||||
|
if (usage.hasRateLimitData) return usage
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
|
||||||
|
return try {
|
||||||
|
val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute()
|
||||||
|
Log.d(TAG, "Orgs → ${resp.code}")
|
||||||
|
val body = resp.body?.string() ?: return Pair(null, null)
|
||||||
|
debugBuf.append("https://claude.ai/api/organizations\n→ ${resp.code}: ${body.take(400)}\n\n")
|
||||||
|
if (body.isBlank() || body.startsWith("<")) return Pair(null, null)
|
||||||
|
val arr = JSONArray(body)
|
||||||
|
val org = arr.optJSONObject(0) ?: return Pair(null, null)
|
||||||
|
val orgId = org.optString("uuid").ifBlank { null }
|
||||||
|
val usageData = tryParseOrgForUsage(org)
|
||||||
|
Pair(orgId, usageData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "fetchOrgInfo: ${e.message}")
|
||||||
|
Pair(null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryParseOrgForUsage(org: JSONObject): UsageData? {
|
||||||
|
val limit = org.optInt("message_limit", -1).takeIf { it > 0 }
|
||||||
|
?: org.optJSONObject("capabilities")?.optInt("message_limit", -1)?.takeIf { it > 0 }
|
||||||
|
?: org.optJSONObject("usage")?.optInt("limit", -1)?.takeIf { it > 0 }
|
||||||
|
?: org.optJSONObject("rate_limit")?.optInt("limit", -1)?.takeIf { it > 0 }
|
||||||
|
?: org.optInt("limit", -1).takeIf { it > 0 }
|
||||||
|
val used = org.optInt("messages_sent", -1).takeIf { it >= 0 }
|
||||||
|
?: org.optJSONObject("usage")?.optInt("messages_sent", -1)?.takeIf { it >= 0 }
|
||||||
|
?: org.optJSONObject("rate_limit")?.optInt("used", -1)?.takeIf { it >= 0 }
|
||||||
|
?: org.optInt("used", -1).takeIf { it >= 0 }
|
||||||
|
val remaining = org.optInt("messages_remaining", -1).takeIf { it >= 0 }
|
||||||
|
?: org.optJSONObject("usage")?.optInt("remaining", -1)?.takeIf { it >= 0 }
|
||||||
|
?: org.optJSONObject("rate_limit")?.optInt("remaining", -1)?.takeIf { it >= 0 }
|
||||||
|
?: org.optInt("remaining", -1).takeIf { it >= 0 }
|
||||||
|
val resetStr = org.optString("usage_reset_at", "")
|
||||||
|
.ifBlank { org.optJSONObject("usage")?.optString("reset_at", "") ?: "" }
|
||||||
|
.ifBlank { org.optJSONObject("rate_limit")?.optString("reset_at", "") ?: "" }
|
||||||
|
if (limit == null && used == null && remaining == null) return null
|
||||||
|
return UsageData(
|
||||||
|
messagesLimit = limit ?: -1,
|
||||||
|
messagesUsed = used ?: -1,
|
||||||
|
messagesRemaining = remaining ?: -1,
|
||||||
|
resetAtEpoch = if (resetStr.isNotBlank()) parseResetTime(resetStr) else -1L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses the /usage endpoint response: {"five_hour":{"utilization":75.0,"resets_at":"..."},...}
|
||||||
|
private fun tryParseUtilizationBody(body: String): UsageData? {
|
||||||
|
if (body.isBlank() || body.startsWith("<")) return null
|
||||||
|
return try {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
val fiveHour = json.optJSONObject("five_hour") ?: return null
|
||||||
|
val utilization = fiveHour.optDouble("utilization", -1.0).toFloat()
|
||||||
|
if (utilization < 0f) return null
|
||||||
|
val resetsAt = fiveHour.optString("resets_at", "")
|
||||||
|
val sevenDay = json.optJSONObject("seven_day")
|
||||||
|
val weeklyUtil = sevenDay?.optDouble("utilization", -1.0)?.toFloat() ?: -1f
|
||||||
|
val weeklyResetsAt = sevenDay?.optString("resets_at", "") ?: ""
|
||||||
|
UsageData(
|
||||||
|
fiveHourUtilization = utilization,
|
||||||
|
weeklyUtilization = weeklyUtil,
|
||||||
|
utilizationResetAtEpoch = if (resetsAt.isNotBlank()) parseResetTime(resetsAt) else -1L,
|
||||||
|
weeklyResetAtEpoch = if (weeklyResetsAt.isNotBlank()) parseResetTime(weeklyResetsAt) else -1L
|
||||||
|
)
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractRateLimitHeaders(headers: okhttp3.Headers): UsageData {
|
||||||
|
val remaining = headers["anthropic-ratelimit-requests-remaining"]?.toIntOrNull()
|
||||||
|
val limit = headers["anthropic-ratelimit-requests-limit"]?.toIntOrNull()
|
||||||
|
val reset = headers["anthropic-ratelimit-requests-reset"]
|
||||||
|
return UsageData(
|
||||||
|
messagesRemaining = remaining ?: -1,
|
||||||
|
messagesLimit = limit ?: -1,
|
||||||
|
resetAtEpoch = parseResetTime(reset)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryParseUsageBody(body: String, base: UsageData): UsageData {
|
||||||
|
if (body.isBlank() || body.startsWith("<")) return base
|
||||||
|
return try {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
val used = json.optInt("messages_sent", -1).takeIf { it >= 0 }
|
||||||
|
?: json.optInt("message_count", -1).takeIf { it >= 0 }
|
||||||
|
?: json.optInt("used", -1).takeIf { it >= 0 }
|
||||||
|
?: -1
|
||||||
|
val limit = json.optInt("messages_limit", -1).takeIf { it >= 0 }
|
||||||
|
?: json.optInt("limit", -1).takeIf { it >= 0 }
|
||||||
|
?: base.messagesLimit
|
||||||
|
val remaining = json.optInt("messages_remaining", -1).takeIf { it >= 0 }
|
||||||
|
?: json.optInt("remaining", -1).takeIf { it >= 0 }
|
||||||
|
?: base.messagesRemaining
|
||||||
|
val reset = json.optString("reset_at", "").ifBlank { json.optString("resets_at", "") }
|
||||||
|
base.copy(
|
||||||
|
messagesUsed = used,
|
||||||
|
messagesLimit = limit,
|
||||||
|
messagesRemaining = remaining,
|
||||||
|
resetAtEpoch = if (reset.isNotBlank()) parseResetTime(reset) else base.resetAtEpoch
|
||||||
|
)
|
||||||
|
} catch (e: Exception) { base }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseResetTime(value: String?): Long {
|
||||||
|
if (value.isNullOrBlank()) return -1L
|
||||||
|
// Normalize: truncate sub-second digits beyond 3 (e.g. microseconds → milliseconds)
|
||||||
|
val normalized = value.replace(Regex("(\\.\\d{3})\\d+"), "$1")
|
||||||
|
val formats = listOf(
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||||||
|
)
|
||||||
|
for (fmt in formats) {
|
||||||
|
try {
|
||||||
|
val sdf = SimpleDateFormat(fmt, Locale.US).also { it.timeZone = TimeZone.getTimeZone("UTC") }
|
||||||
|
return sdf.parse(normalized)?.time ?: continue
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
return value.toLongOrNull()?.let { System.currentTimeMillis() + it * 1000 } ?: -1L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRequest(url: String, cookies: String) = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("User-Agent", desktopUA)
|
||||||
|
.header("Accept", "application/json, */*")
|
||||||
|
.header("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
.header("Referer", "https://claude.ai/")
|
||||||
|
.header("Cookie", cookies)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UsageRepo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.work.*
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class UsageUpdateWorker(
|
||||||
|
private val context: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val prefs = PreferencesManager(context)
|
||||||
|
if (!prefs.isLoggedIn()) return Result.success()
|
||||||
|
|
||||||
|
prefs.markTodayActive()
|
||||||
|
|
||||||
|
// Try API — save result (even on failure, local data is preserved)
|
||||||
|
try {
|
||||||
|
val data = UsageRepository(prefs).fetchUsage()
|
||||||
|
prefs.saveUsageData(data)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// API failed — don't save, keep existing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always push widget update using latest prefs + cached api data
|
||||||
|
pushWidgetUpdate()
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushWidgetUpdate() {
|
||||||
|
ClaudeUsageWidget.isRefreshing = false
|
||||||
|
val manager = AppWidgetManager.getInstance(context)
|
||||||
|
val ids = manager.getAppWidgetIds(ComponentName(context, ClaudeUsageWidget::class.java))
|
||||||
|
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_ONE_SHOT = "claude_oneshot"
|
||||||
|
private const val ALARM_CODE = 1001
|
||||||
|
private const val INTERVAL_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
|
fun schedulePeriodicRefresh(context: Context) {
|
||||||
|
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
am.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||||
|
SystemClock.elapsedRealtime() + INTERVAL_MS,
|
||||||
|
alarmIntent(context)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelPeriodicRefresh(context: Context) =
|
||||||
|
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
|
||||||
|
.cancel(alarmIntent(context))
|
||||||
|
|
||||||
|
fun triggerImmediateRefresh(context: Context) {
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_ONE_SHOT,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
OneTimeWorkRequestBuilder<UsageUpdateWorker>().build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alarmIntent(context: Context) = PendingIntent.getBroadcast(
|
||||||
|
context, ALARM_CODE,
|
||||||
|
Intent(context, AlarmReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package me.khodak.claudeusage.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
class PreferencesManager(context: Context) {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
private val securePrefs = try {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context, "claude_secure", masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun saveCookies(cookies: String) {
|
||||||
|
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
|
||||||
|
// Plaintext backup — survives EncryptedSharedPreferences key rotation on reinstall
|
||||||
|
prefs.edit().putString(KEY_COOKIES_BACKUP, cookies).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCookies(): String? =
|
||||||
|
securePrefs.getString(KEY_COOKIES, null)
|
||||||
|
?: prefs.getString(KEY_COOKIES_BACKUP, null)
|
||||||
|
|
||||||
|
fun clearSession() {
|
||||||
|
securePrefs.edit().clear().apply()
|
||||||
|
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START)
|
||||||
|
.remove(KEY_COOKIES_BACKUP).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveOrgId(id: String) = prefs.edit().putString(KEY_ORG_ID, id).apply()
|
||||||
|
fun getOrgId(): String? = prefs.getString(KEY_ORG_ID, null)
|
||||||
|
|
||||||
|
fun saveSessionStart(epochMs: Long) = prefs.edit().putLong(KEY_SESSION_START, epochMs).apply()
|
||||||
|
fun getSessionStart(): Long = prefs.getLong(KEY_SESSION_START, 0L)
|
||||||
|
|
||||||
|
fun markTodayActive() {
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
val dayBit = 1 shl cal.get(Calendar.DAY_OF_WEEK) - 1
|
||||||
|
val weekKey = getWeekKey(cal)
|
||||||
|
// Reset mask if it's a new week
|
||||||
|
val storedWeek = prefs.getString(KEY_ACTIVE_WEEK, "")
|
||||||
|
val currentMask = if (storedWeek == weekKey) prefs.getInt(KEY_ACTIVE_MASK, 0) else 0
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_ACTIVE_WEEK, weekKey)
|
||||||
|
.putInt(KEY_ACTIVE_MASK, currentMask or dayBit)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getWeeklyMask(): Int {
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
val weekKey = getWeekKey(cal)
|
||||||
|
val storedWeek = prefs.getString(KEY_ACTIVE_WEEK, "")
|
||||||
|
return if (storedWeek == weekKey) prefs.getInt(KEY_ACTIVE_MASK, 0) else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWeekKey(cal: Calendar): String {
|
||||||
|
val year = cal.get(Calendar.YEAR)
|
||||||
|
val week = cal.get(Calendar.WEEK_OF_YEAR)
|
||||||
|
return "$year-$week"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveUsageData(data: UsageData) =
|
||||||
|
prefs.edit().putString(KEY_USAGE_DATA, gson.toJson(data)).apply()
|
||||||
|
|
||||||
|
fun getUsageData(): UsageData? = try {
|
||||||
|
prefs.getString(KEY_USAGE_DATA, null)?.let { gson.fromJson(it, UsageData::class.java) }
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
|
||||||
|
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_COOKIES = "session_cookies"
|
||||||
|
private const val KEY_COOKIES_BACKUP = "session_cookies_backup"
|
||||||
|
private const val KEY_ORG_ID = "org_id"
|
||||||
|
private const val KEY_SESSION_START = "session_start"
|
||||||
|
private const val KEY_USAGE_DATA = "usage_data"
|
||||||
|
private const val KEY_ACTIVE_WEEK = "active_week"
|
||||||
|
private const val KEY_ACTIVE_MASK = "active_mask"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package me.khodak.claudeusage.data
|
||||||
|
|
||||||
|
data class UsageData(
|
||||||
|
val messagesUsed: Int = -1,
|
||||||
|
val messagesLimit: Int = -1,
|
||||||
|
val messagesRemaining: Int = -1,
|
||||||
|
val resetAtEpoch: Long = -1L,
|
||||||
|
val isRateLimited: Boolean = false,
|
||||||
|
val lastUpdated: Long = 0L,
|
||||||
|
val errorMessage: String = "",
|
||||||
|
val isLoggedIn: Boolean = false,
|
||||||
|
val sessionStartEpoch: Long = 0L,
|
||||||
|
val weeklyActiveDaysMask: Int = 0, // bitmask: bit0=Sun, bit1=Mon ... bit6=Sat
|
||||||
|
// From /api/organizations/{id}/usage — utilization as percentage 0-100
|
||||||
|
val fiveHourUtilization: Float = -1f,
|
||||||
|
val weeklyUtilization: Float = -1f,
|
||||||
|
val utilizationResetAtEpoch: Long = -1L,
|
||||||
|
val weeklyResetAtEpoch: Long = -1L
|
||||||
|
) {
|
||||||
|
val hasRateLimitData: Boolean get() =
|
||||||
|
messagesLimit > 0 || messagesRemaining >= 0 || fiveHourUtilization >= 0f
|
||||||
|
|
||||||
|
val hasSessionData: Boolean get() = sessionStartEpoch > 0
|
||||||
|
|
||||||
|
val progressPercent: Int get() = when {
|
||||||
|
messagesLimit > 0 && effectiveUsed >= 0 ->
|
||||||
|
((effectiveUsed.toFloat() / messagesLimit.toFloat()) * 100).toInt().coerceIn(0, 100)
|
||||||
|
fiveHourUtilization >= 0f -> fiveHourUtilization.toInt().coerceIn(0, 100)
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveUsed: Int get() = when {
|
||||||
|
messagesUsed >= 0 -> messagesUsed
|
||||||
|
messagesRemaining >= 0 && messagesLimit > 0 -> messagesLimit - messagesRemaining
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveRemaining: Int get() = when {
|
||||||
|
messagesRemaining >= 0 -> messagesRemaining
|
||||||
|
messagesUsed >= 0 && messagesLimit > 0 -> messagesLimit - messagesUsed
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
|
||||||
|
val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask)
|
||||||
|
|
||||||
|
// The best reset time we have (utilization endpoint is preferred)
|
||||||
|
val effectiveResetEpoch: Long get() = when {
|
||||||
|
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
|
||||||
|
resetAtEpoch > 0 -> resetAtEpoch
|
||||||
|
else -> -1L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
<stroke android:width="1dp" android:color="#CC785C" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Claude-style hexagon "C" icon -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,18 C34.1,18 18,34.1 18,54 C18,73.9 34.1,90 54,90 C73.9,90 90,73.9 90,54 C90,34.1 73.9,18 54,18Z M54,78 C40.7,78 30,67.3 30,54 C30,40.7 40.7,30 54,30 C61.8,30 68.8,33.6 73.4,39.2 L64.9,46.6 C62.4,43.6 58.4,41.6 54,41.6 C47.1,41.6 41.6,47.1 41.6,54 C41.6,60.9 47.1,66.4 54,66.4 C58.4,66.4 62.3,64.4 64.9,61.4 L73.4,68.8 C68.8,74.4 61.8,78 54,78Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
<solid android:color="#1E1E1E" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="#121212">
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:background="#1E1E1E">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/tab_browser"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Browser Login"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:backgroundTint="#CC785C"
|
||||||
|
android:textColor="#FFFFFF" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/tab_cookie"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Paste Cookie"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:backgroundTint="#2A2A2A"
|
||||||
|
android:textColor="#888888" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Browser login panel -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/panel_browser"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:id="@+id/webView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_done"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="I'm logged in — save session"
|
||||||
|
android:backgroundTint="#CC785C"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_margin="12dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Manual cookie panel -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/panel_cookie"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Paste Session Cookie"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="1. Open Chrome on your phone\n2. Go to claude.ai and sign in\n3. Install the "Cookie-Editor" extension in Kiwi Browser, or use Firefox → Settings → Privacy → Cookies\n4. Find and copy the full cookie string for claude.ai\n5. Paste it below"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:text="Simpler: open claude.ai in browser → log in → come back here and tap the Browser Login tab — then tap 'I'm logged in'"
|
||||||
|
android:textColor="#CC785C"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/et_cookie"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="120dp"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:hint="Paste cookie string here…"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textColorHint="#555555"
|
||||||
|
android:background="#1E1E1E"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:gravity="top"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_save_cookie"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Save and connect"
|
||||||
|
android:backgroundTint="#CC785C"
|
||||||
|
android:textColor="#FFFFFF" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true"
|
||||||
|
android:background="#121212">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
|
<!-- Logo / Title -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="32dp"
|
||||||
|
android:text="⬡"
|
||||||
|
android:textColor="#CC785C"
|
||||||
|
android:textSize="48sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Claude Usage"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Home screen widget"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressIndicator"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- LOGGED OUT state -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutLoggedOut"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginTop="48dp"
|
||||||
|
android:gravity="center">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Sign in with your Claude account to track Pro usage on your home screen widget."
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:lineSpacingExtra="4dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnLogin"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="Sign in to Claude"
|
||||||
|
android:backgroundTint="#CC785C"
|
||||||
|
android:textColor="#FFFFFF" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- LOGGED IN state -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutLoggedIn"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginTop="32dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<!-- Usage card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="CURRENT USAGE"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvUsage"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="8dp"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressTint="#CC785C"
|
||||||
|
android:progressBackgroundTint="#3A3A3A" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvReset"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="WEEKLY"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWeeklyUsage"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBarWeekly"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="8dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressTint="#7B8FCC"
|
||||||
|
android:progressBackgroundTint="#3A3A3A" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWeeklyReset"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvUpdated"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvError"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="#FF7070"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnRefresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Refresh Now"
|
||||||
|
android:backgroundTint="#CC785C"
|
||||||
|
android:textColor="#FFFFFF" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWidgetHint"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="Long-press your home screen → Widgets → Claude Usage to add the widget"
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:padding="8dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnLogout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Sign Out"
|
||||||
|
android:backgroundTint="#2A2A2A"
|
||||||
|
android:textColor="#888888" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnDebug"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Show API Debug"
|
||||||
|
android:backgroundTint="#1A1A1A"
|
||||||
|
android:textColor="#555555"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDebugInfo"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="#1A1A1A"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Claude Pro"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_refresh"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
|
android:background="@android:color/transparent"
|
||||||
|
android:tint="#999999"
|
||||||
|
android:contentDescription="Refresh" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Divider — View is not in RemoteViews allowlist on Android 12+; use TextView instead -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:background="#2A2A2A" />
|
||||||
|
|
||||||
|
<!-- 5-hour window bar -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="SESSION"
|
||||||
|
android:textColor="#555555"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_session_value"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_bar"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="5dp"
|
||||||
|
android:layout_marginTop="5dp"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressTint="#CC785C"
|
||||||
|
android:progressBackgroundTint="#252525" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_session_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
|
||||||
|
<!-- 7-day window bar -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginTop="10dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="WEEKLY"
|
||||||
|
android:textColor="#555555"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_weekly_value"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_bar_weekly"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="5dp"
|
||||||
|
android:layout_marginTop="5dp"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressTint="#7B8FCC"
|
||||||
|
android:progressBackgroundTint="#252525" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_weekly_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_status"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#CC785C"
|
||||||
|
android:textSize="9sp"
|
||||||
|
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="#444444"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Claude Pro"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_refresh"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
|
android:background="@android:color/transparent"
|
||||||
|
android:contentDescription="Refresh" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- SESSION row -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginTop="7dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="SESSION"
|
||||||
|
android:textColor="#555555"
|
||||||
|
android:textSize="8sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_session_value"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_session_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#555555"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_bar"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressTint="#CC785C"
|
||||||
|
android:progressBackgroundTint="#252525" />
|
||||||
|
|
||||||
|
<!-- 7-DAY row -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginTop="7dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="WEEKLY"
|
||||||
|
android:textColor="#555555"
|
||||||
|
android:textSize="8sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_weekly_value"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_weekly_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#555555"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_bar_weekly"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressTint="#7B8FCC"
|
||||||
|
android:progressBackgroundTint="#252525" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text=""
|
||||||
|
android:textColor="#CC785C"
|
||||||
|
android:textSize="8sp"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="claude_orange">#CC785C</color>
|
||||||
|
<color name="background_dark">#121212</color>
|
||||||
|
<color name="surface_dark">#1E1E1E</color>
|
||||||
|
<color name="text_primary">#FFFFFF</color>
|
||||||
|
<color name="text_secondary">#888888</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Claude Usage</string>
|
||||||
|
<string name="login_title">Sign in to Claude</string>
|
||||||
|
<string name="widget_description">Shows your Claude Pro message usage</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.ClaudeUsage" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<item name="colorPrimary">@color/claude_orange</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/claude_orange</item>
|
||||||
|
<item name="colorOnPrimary">@color/text_primary</item>
|
||||||
|
<item name="android:windowBackground">@color/background_dark</item>
|
||||||
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="250dp"
|
||||||
|
android:minHeight="55dp"
|
||||||
|
android:targetCellWidth="4"
|
||||||
|
android:targetCellHeight="2"
|
||||||
|
android:minResizeWidth="180dp"
|
||||||
|
android:minResizeHeight="55dp"
|
||||||
|
android:maxResizeWidth="500dp"
|
||||||
|
android:maxResizeHeight="300dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:initialLayout="@layout/widget_layout"
|
||||||
|
android:widgetCategory="home_screen|keyguard"
|
||||||
|
android:description="@string/widget_description"
|
||||||
|
android:previewLayout="@layout/widget_layout" />
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.2.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build + install on emulator or connected device
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh → emulator
|
||||||
|
# ./deploy.sh wifi → phone via ADB WiFi (prompts for IP if not set)
|
||||||
|
# ./deploy.sh logs → stream logs only
|
||||||
|
set -e
|
||||||
|
|
||||||
|
export ANDROID_HOME=~/android-sdk
|
||||||
|
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
|
||||||
|
GRADLE=~/gradle/gradle-8.6/bin/gradle
|
||||||
|
APK=app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
PKG=me.khodak.claudeusage
|
||||||
|
|
||||||
|
# -- connect to phone over WiFi if requested
|
||||||
|
if [[ "$1" == "wifi" ]]; then
|
||||||
|
PHONE_IP="${PHONE_IP:-$(cat .phone-ip 2>/dev/null)}"
|
||||||
|
if [[ -z "$PHONE_IP" ]]; then
|
||||||
|
read -rp "Phone IP address: " PHONE_IP
|
||||||
|
echo "$PHONE_IP" > .phone-ip
|
||||||
|
fi
|
||||||
|
adb connect "$PHONE_IP:5555" 2>&1 | head -3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- logs only
|
||||||
|
if [[ "$1" == "logs" ]]; then
|
||||||
|
echo "Streaming logs (Ctrl-C to stop)..."
|
||||||
|
adb logcat -s UsageRepo:D ClaudeWidget:D UsageWorker:D AlarmReceiver:D | sed 's/.*\(D\|W\|E\) //'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- build
|
||||||
|
echo "Building..."
|
||||||
|
$GRADLE assembleDebug --no-daemon -q
|
||||||
|
echo "Build OK"
|
||||||
|
|
||||||
|
# -- pick target device
|
||||||
|
DEVICES=$(adb devices | awk '/device$/{print $1}')
|
||||||
|
if [[ -z "$DEVICES" ]]; then
|
||||||
|
echo "No device/emulator connected. Start emulator with ./start-emulator.sh or enable WiFi debugging."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Devices: $DEVICES"
|
||||||
|
|
||||||
|
# -- install (keep data = upgrade)
|
||||||
|
echo "Installing..."
|
||||||
|
adb install -r "$APK"
|
||||||
|
echo "Installed."
|
||||||
|
|
||||||
|
# -- launch main activity
|
||||||
|
adb shell am start -n "$PKG/.MainActivity" 2>/dev/null || true
|
||||||
|
|
||||||
|
# -- stream relevant logs
|
||||||
|
echo ""
|
||||||
|
echo "Streaming logs (Ctrl-C to stop)..."
|
||||||
|
adb logcat -c && adb logcat -s UsageRepo:D MainActivity:D | sed 's/.*[VDIWEF]\///'
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
kotlin.code.style=official
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# Gradle start up script for UN*X
|
||||||
|
#
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "ClaudeUsage"
|
||||||
|
include(":app")
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start the emulator headless (no window) — run this first, then use deploy.sh
|
||||||
|
export ANDROID_HOME=~/android-sdk
|
||||||
|
export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH"
|
||||||
|
|
||||||
|
echo "Starting emulator (headless)..."
|
||||||
|
emulator -avd claude_test -no-window -no-audio -no-snapshot -gpu swiftshader_indirect &
|
||||||
|
EMU_PID=$!
|
||||||
|
echo "Emulator PID: $EMU_PID"
|
||||||
|
|
||||||
|
echo "Waiting for device to be ready..."
|
||||||
|
adb wait-for-device
|
||||||
|
adb shell getprop sys.boot_completed | grep -q 1 || \
|
||||||
|
timeout 120 bash -c 'until adb shell getprop sys.boot_completed 2>/dev/null | grep -q 1; do sleep 3; done'
|
||||||
|
echo "Emulator ready!"
|
||||||
Reference in New Issue
Block a user