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:
wenil
2026-06-19 11:43:24 +03:00
commit 4083609b12
19 changed files with 1301 additions and 0 deletions
+44
View File
@@ -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")
}
+40
View File
@@ -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)
}