feat: ClaudeMeter — Wear OS rate-limit tracker for Claude Code
- wear: Compose for Wear UI, polls api.anthropic.com, shows 5h/7d utilization - mobile: companion app — token input + Data Layer API + WiFi ADB install - ADB pairing via libadb-android (SPAKE2 + TLS), pm install on watch - Build: AGP 8.7.3, Gradle 8.13, Kotlin 1.9.22, compileSdk 35
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.eps.claudemeter"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.eps.claudemeter"
|
||||
minSdk = 30 // Wear OS 3+; Galaxy Watch 6 Classic = API 33
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1"
|
||||
}
|
||||
|
||||
buildFeatures { compose = true }
|
||||
composeOptions { kotlinCompilerExtensionVersion = "1.5.8" }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
|
||||
// Wear Compose
|
||||
implementation("androidx.wear.compose:compose-material:1.3.0")
|
||||
implementation("androidx.wear.compose:compose-foundation:1.3.0")
|
||||
|
||||
// Wearable Data Layer — приём токена с телефона
|
||||
implementation("com.google.android.gms:play-services-wearable:18.1.0")
|
||||
|
||||
// Шифр.хранилище для токена (security — не упрощаем)
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature android:name="android.hardware.type.watch" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="Claude Meter"
|
||||
android:theme="@android:style/Theme.DeviceDefault"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<uses-library
|
||||
android:name="com.google.android.wearable"
|
||||
android:required="false" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.wearable.standalone"
|
||||
android:value="true" />
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Ловит "/token" с телефона даже когда app закрыто -->
|
||||
<service
|
||||
android:name=".TokenReceiverService"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.android.gms.wearable.DATA_CHANGED" />
|
||||
<data android:scheme="wear" android:host="*" android:path="/token" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.eps.claudemeter
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// ponytail: тот же запрос, что у Clawdmeter — POST с max_tokens=1 ради rate-limit заголовков.
|
||||
// HttpURLConnection (stdlib), без OkHttp. Один запрос = один Usage.
|
||||
object ApiPoller {
|
||||
private const val URL_STR = "https://api.anthropic.com/v1/messages"
|
||||
// ponytail: haiku — самый дешёвый, нужен только заголовок
|
||||
private const val BODY =
|
||||
"""{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}"""
|
||||
|
||||
suspend fun poll(token: String): Usage = withContext(Dispatchers.IO) {
|
||||
val conn = (URL(URL_STR).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
setRequestProperty("Authorization", "Bearer $token")
|
||||
setRequestProperty("anthropic-version", "2023-06-01")
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
setRequestProperty("User-Agent", "claude-code/2.1.5")
|
||||
doOutput = true
|
||||
connectTimeout = 20000
|
||||
readTimeout = 20000
|
||||
}
|
||||
try {
|
||||
conn.outputStream.use { it.write(BODY.toByteArray()) }
|
||||
val code = conn.responseCode
|
||||
if (code >= 400) throw Exception("HTTP $code: ${conn.errorStream?.bufferedReader()?.use { it.readText() }?.take(200)}")
|
||||
val h: (String) -> String? = { name -> conn.getHeaderField(name) }
|
||||
Usage(
|
||||
short = pct(h("anthropic-ratelimit-unified-5h-utilization")),
|
||||
shortReset = resetMin(h("anthropic-ratelimit-unified-5h-reset")),
|
||||
weekly = pct(h("anthropic-ratelimit-unified-7d-utilization")),
|
||||
weeklyReset = resetMin(h("anthropic-ratelimit-unified-7d-reset")),
|
||||
status = h("anthropic-ratelimit-unified-5h-status") ?: "unknown",
|
||||
)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pct(s: String?): Int =
|
||||
s?.toFloatOrNull()?.let { (it * 100).roundToInt() } ?: 0
|
||||
|
||||
private fun resetMin(s: String?): Int {
|
||||
val ts = s?.toFloatOrNull()?.toLong() ?: return 0
|
||||
val mins = (ts - System.currentTimeMillis() / 1000) / 60
|
||||
return if (mins > 0) mins.toInt() else 0
|
||||
}
|
||||
}
|
||||
|
||||
data class Usage(
|
||||
val short: Int, // % загрузки 5-часового окна
|
||||
val shortReset: Int, // минуты до сброса
|
||||
val weekly: Int, // % недельного окна
|
||||
val weeklyReset: Int, // минуты до сброса
|
||||
val status: String, // normal / banned / unknown
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.eps.claudemeter
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
import androidx.wear.compose.material.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ponytail: один экран, авто-опрос при запуске + кнопка Refresh. Никакой архитектуры.
|
||||
// Фоновый WorkManager добавим когда v0 зайдёт — YAGNI сейчас.
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
MaterialTheme { Screen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Screen() {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var usage by remember { mutableStateOf<Usage?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
|
||||
fun refresh() {
|
||||
scope.launch {
|
||||
loading = true; error = null
|
||||
try {
|
||||
val token = TokenStore.get(ctx) ?: throw Exception("Нет токена. Отправь с телефона.")
|
||||
usage = ApiPoller.poll(token)
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// авто-обновление при запуске
|
||||
LaunchedEffect(Unit) { refresh() }
|
||||
|
||||
val listState = rememberScalingLazyListState()
|
||||
ScalingLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
item { Text("Claude Meter", style = MaterialTheme.typography.title3) }
|
||||
|
||||
if (loading) item { Text("…", style = MaterialTheme.typography.body1) }
|
||||
|
||||
val u = usage
|
||||
if (u != null) {
|
||||
// ponytail: 5h — короткий слот, 7d — длинный. % и минуты до сброса.
|
||||
item { Metric("5h", u.short, u.shortReset) }
|
||||
item { Metric("7d", u.weekly, u.weeklyReset) }
|
||||
item {
|
||||
Text(
|
||||
"status: ${u.status}",
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = if (u.status == "normal") Color(0xFF4CAF50) else MaterialTheme.colors.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val err = error
|
||||
if (err != null) {
|
||||
item {
|
||||
Text(
|
||||
err,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = Color(0xFFE53935),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Button(onClick = { refresh() }) {
|
||||
Text("Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Metric(label: String, pct: Int, resetMin: Int) {
|
||||
val color = when {
|
||||
pct >= 90 -> Color(0xFFE53935)
|
||||
pct >= 70 -> Color(0xFFFFA000)
|
||||
else -> Color(0xFF4CAF50)
|
||||
}
|
||||
Card(onClick = {}) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("$label $pct%", style = MaterialTheme.typography.title1, color = color)
|
||||
Text("сброс ${resetMin} мин", style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.eps.claudemeter
|
||||
|
||||
import com.google.android.gms.wearable.DataEvent
|
||||
import com.google.android.gms.wearable.DataEventBuffer
|
||||
import com.google.android.gms.wearable.DataMapItem
|
||||
import com.google.android.gms.wearable.WearableListenerService
|
||||
|
||||
// ponytail: ловит "/token" с телефона, сохраняет. Запускается системой, не Activity.
|
||||
class TokenReceiverService : WearableListenerService() {
|
||||
override fun onDataChanged(events: DataEventBuffer) {
|
||||
for (event in events) {
|
||||
if (event.type != DataEvent.TYPE_CHANGED) continue
|
||||
if (event.dataItem.uri.path != "/token") continue
|
||||
val token = DataMapItem.fromDataItem(event.dataItem).dataMap.getString("token")
|
||||
?: continue
|
||||
TokenStore.save(this, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.eps.claudemeter
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
// ponytail: один ключ, одно хранилище. Токен шифруется на устройстве.
|
||||
object TokenStore {
|
||||
private const val FILE = "secret_prefs"
|
||||
private const val KEY = "token"
|
||||
|
||||
private fun prefs(ctx: Context) = EncryptedSharedPreferences.create(
|
||||
ctx,
|
||||
FILE,
|
||||
MasterKey.Builder(ctx).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
fun save(ctx: Context, token: String) =
|
||||
prefs(ctx).edit().putString(KEY, token).apply()
|
||||
|
||||
fun get(ctx: Context): String? = prefs(ctx).getString(KEY, null)
|
||||
}
|
||||
Reference in New Issue
Block a user