Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0f466f50 | |||
| 1d89b2631c | |||
| b15dcf16d7 | |||
| d6d7daa30f | |||
| 4f9edd5e63 | |||
| 838b10f2fd | |||
| f7444a06eb | |||
| 6f3c5e6ea1 | |||
| 895a4ff3cd | |||
| e2747597e2 | |||
| 6934017519 | |||
| ee68b11ad0 | |||
| 695c54f03c | |||
| 3dc0448942 | |||
| 8d1cf21966 | |||
| b3b69dd2b2 |
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="icon.png" width="108" alt="Claude Usage Widget">
|
||||
</p>
|
||||
|
||||
# Claude Usage Widget
|
||||
|
||||
Android home screen widget that shows your Claude Pro usage at a glance.
|
||||
@@ -6,6 +10,11 @@ Android home screen widget that shows your Claude Pro usage at a glance.
|
||||
|
||||
- **SESSION** bar — current 5-hour window utilization with reset time
|
||||
- **WEEKLY** bar — 7-day rolling usage with reset time
|
||||
- **Pace marker** — a colored tick on each bar shows where you *should be* right now to finish at
|
||||
exactly 100% by reset. Tick color grades your projection: green (way under budget) → teal →
|
||||
yellow → orange → red → purple (burning way too fast), with an "X% over/under pace" label.
|
||||
- **Peak-hours indicator** — a Claude burst icon that lights up 🔥 during Anthropic's peak window
|
||||
(5–11 AM Pacific, Mon–Fri), when tokens burn faster, with a countdown to the window close.
|
||||
- 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
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "me.khodak.claudeusage"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 5
|
||||
versionName = "1.4"
|
||||
versionCode = 14
|
||||
versionName = "1.13"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -44,6 +44,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ClaudeUsage"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
<activity
|
||||
@@ -43,6 +44,14 @@
|
||||
android:name=".AlarmReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name="androidx.work.impl.background.systemalarm.RescheduleReceiver"
|
||||
android:exported="false"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package me.khodak.claudeusage
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
|
||||
/**
|
||||
* Draws a usage bar as a Bitmap: rounded track + rounded fill + an optional vertical pace tick.
|
||||
* Used both in the home-screen widget (setImageViewBitmap) and the in-app card, so the two render
|
||||
* identically. Bitmaps are rendered at a fixed width and stretched horizontally via ImageView
|
||||
* scaleType=fitXY; height is fixed so there is no vertical distortion.
|
||||
*/
|
||||
object BarRenderer {
|
||||
|
||||
private const val TRACK_COLOR = 0xFF252525.toInt()
|
||||
|
||||
fun render(
|
||||
usedPct: Int,
|
||||
markerPct: Int?,
|
||||
fillColor: Int,
|
||||
markerColor: Int?,
|
||||
wPx: Int = 500,
|
||||
hPx: Int = 14,
|
||||
cornerPx: Float = 7f
|
||||
): Bitmap {
|
||||
val bmp = Bitmap.createBitmap(wPx, hPx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
// Track
|
||||
paint.color = TRACK_COLOR
|
||||
val full = RectF(0f, 0f, wPx.toFloat(), hPx.toFloat())
|
||||
canvas.drawRoundRect(full, cornerPx, cornerPx, paint)
|
||||
|
||||
// Fill
|
||||
val pct = usedPct.coerceIn(0, 100)
|
||||
if (pct > 0) {
|
||||
paint.color = fillColor
|
||||
val fillW = (wPx * pct / 100f).coerceAtLeast(cornerPx * 2)
|
||||
val fill = RectF(0f, 0f, fillW, hPx.toFloat())
|
||||
canvas.drawRoundRect(fill, cornerPx, cornerPx, paint)
|
||||
}
|
||||
|
||||
// Pace marker — a single clean tick showing "where you should be right now".
|
||||
// One color (no tiers); rounded ends to match the bar.
|
||||
if (markerPct != null && markerColor != null) {
|
||||
val m = markerPct.coerceIn(0, 100)
|
||||
val tickW = (wPx * 0.016f).coerceIn(6f, 10f)
|
||||
var x = wPx * m / 100f
|
||||
x = x.coerceIn(tickW / 2f, wPx - tickW / 2f)
|
||||
paint.color = markerColor
|
||||
val tick = RectF(x - tickW / 2f, 0f, x + tickW / 2f, hPx.toFloat())
|
||||
canvas.drawRoundRect(tick, tickW / 2f, tickW / 2f, paint)
|
||||
}
|
||||
|
||||
return bmp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.khodak.claudeusage
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import me.khodak.claudeusage.data.PreferencesManager
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
if (!PreferencesManager(context).isLoggedIn()) return
|
||||
UsageUpdateWorker.schedulePeriodicRefresh(context)
|
||||
UsageUpdateWorker.triggerImmediateRefresh(context)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
ids.forEach { updateWidget(context, manager, it) }
|
||||
if (PreferencesManager(context).isLoggedIn()) {
|
||||
UsageUpdateWorker.schedulePeriodicRefresh(context)
|
||||
UsageUpdateWorker.triggerImmediateRefresh(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,14 +87,15 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
|
||||
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
||||
applyPeak(v, showText = false)
|
||||
if (!prefs.isLoggedIn()) {
|
||||
v.setTextViewText(R.id.tv_session_value, "—")
|
||||
v.setTextViewText(R.id.tv_session_label, "Not signed in")
|
||||
v.setTextViewText(R.id.tv_weekly_value, "—")
|
||||
v.setTextViewText(R.id.tv_weekly_label, "")
|
||||
v.setTextViewText(R.id.tv_status, "")
|
||||
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
|
||||
return v
|
||||
}
|
||||
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
|
||||
@@ -103,14 +105,14 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
hasUtilization -> {
|
||||
val pct = apiData!!.fiveHourUtilization.toInt()
|
||||
v.setTextViewText(R.id.tv_session_value, "$pct%")
|
||||
v.setProgressBar(R.id.progress_bar, 100, pct, false)
|
||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null))
|
||||
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
||||
}
|
||||
hasApiMessages -> {
|
||||
val rem = apiData!!.effectiveRemaining
|
||||
val lim = apiData.messagesLimit
|
||||
v.setTextViewText(R.id.tv_session_value, "$rem/$lim")
|
||||
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
|
||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
|
||||
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
||||
}
|
||||
sessionStart > 0 -> {
|
||||
@@ -118,25 +120,26 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
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.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
||||
v.setTextViewText(R.id.tv_session_label, "active")
|
||||
}
|
||||
else -> {
|
||||
v.setTextViewText(R.id.tv_session_value, "—")
|
||||
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
||||
v.setTextViewText(R.id.tv_session_label, "")
|
||||
}
|
||||
}
|
||||
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
||||
if (hasWeekly) {
|
||||
val wPct = apiData!!.weeklyUtilization.toInt()
|
||||
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
||||
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
|
||||
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
|
||||
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
|
||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null))
|
||||
v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch))
|
||||
} else {
|
||||
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
||||
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
|
||||
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
|
||||
v.setTextViewText(R.id.tv_weekly_label, "")
|
||||
}
|
||||
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
|
||||
@@ -156,6 +159,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
|
||||
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
||||
applyPeak(v, showText = true)
|
||||
|
||||
// ── Not logged in ────────────────────────────────────────────────
|
||||
if (!prefs.isLoggedIn()) {
|
||||
@@ -164,7 +168,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
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)
|
||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -177,14 +182,14 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
hasUtilization -> {
|
||||
val pct = apiData!!.fiveHourUtilization.toInt()
|
||||
v.setTextViewText(R.id.tv_session_value, "$pct%")
|
||||
v.setProgressBar(R.id.progress_bar, 100, pct, false)
|
||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null))
|
||||
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
||||
}
|
||||
hasApiMessages -> {
|
||||
val rem = apiData!!.effectiveRemaining
|
||||
val lim = apiData.messagesLimit
|
||||
v.setTextViewText(R.id.tv_session_value, "$rem / $lim")
|
||||
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
|
||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
|
||||
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
||||
}
|
||||
sessionStart > 0 -> {
|
||||
@@ -193,12 +198,12 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
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.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
||||
v.setTextViewText(R.id.tv_session_label, "session active")
|
||||
}
|
||||
else -> {
|
||||
v.setTextViewText(R.id.tv_session_value, "—")
|
||||
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
||||
v.setTextViewText(R.id.tv_session_label, "")
|
||||
}
|
||||
}
|
||||
@@ -207,13 +212,14 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
||||
if (hasWeekly) {
|
||||
val wPct = apiData!!.weeklyUtilization.toInt()
|
||||
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
||||
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
|
||||
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
|
||||
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
|
||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null))
|
||||
v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch))
|
||||
} else {
|
||||
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
||||
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
|
||||
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
|
||||
v.setTextViewText(R.id.tv_weekly_label, "active this week")
|
||||
}
|
||||
|
||||
@@ -237,6 +243,20 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
return v
|
||||
}
|
||||
|
||||
private const val SESSION_FILL = 0xFFCC785C.toInt()
|
||||
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
||||
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color pace marker (weekly only)
|
||||
|
||||
/** Tints the header burst icon and (optionally) the PEAK text by current peak state. */
|
||||
private fun applyPeak(v: RemoteViews, showText: Boolean) {
|
||||
val peak = PeakHours.isPeak()
|
||||
v.setInt(R.id.img_peak, "setColorFilter",
|
||||
if (peak) 0xFFCC785C.toInt() else 0xFF666666.toInt())
|
||||
if (showText) {
|
||||
v.setTextViewText(R.id.tv_peak, if (peak) "🔥 PEAK" else "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatReset(epochMs: Long): String {
|
||||
if (epochMs <= 0) return ""
|
||||
val now = System.currentTimeMillis()
|
||||
@@ -250,6 +270,15 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Weekly reset shown with the weekday name ("Resets Friday 3:00 PM"), never "tomorrow". */
|
||||
private fun formatResetDay(epochMs: Long): String {
|
||||
if (epochMs <= 0) return ""
|
||||
if (epochMs <= System.currentTimeMillis()) return "Resets soon"
|
||||
val day = SimpleDateFormat("EEEE", Locale.US).format(Date(epochMs))
|
||||
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
|
||||
return "Resets $day $timeStr"
|
||||
}
|
||||
|
||||
private fun formatTime(ms: Long) =
|
||||
SimpleDateFormat("h:mm a", Locale.US).format(Date(ms))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.view.View
|
||||
import android.webkit.*
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.khodak.claudeusage.data.PreferencesManager
|
||||
@@ -72,8 +72,8 @@ class LoginActivity : AppCompatActivity() {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
databaseEnabled = false
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(false)
|
||||
// Standard Android Chrome UA — less suspicious than desktop
|
||||
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
|
||||
@@ -145,7 +145,7 @@ class LoginActivity : AppCompatActivity() {
|
||||
UsageUpdateWorker.triggerImmediateRefresh(this)
|
||||
UsageUpdateWorker.schedulePeriodicRefresh(this)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val data = UsageRepository(prefs).fetchUsage()
|
||||
prefs.saveUsageData(data)
|
||||
|
||||
@@ -68,7 +68,10 @@ class MainActivity : AppCompatActivity() {
|
||||
val cached = prefs.getUsageData()
|
||||
updateUI(cached)
|
||||
if (prefs.isLoggedIn()) {
|
||||
refreshUsage()
|
||||
val staleMs = 5 * 60 * 1000L
|
||||
if ((cached?.lastUpdated ?: 0L) < System.currentTimeMillis() - staleMs) {
|
||||
refreshUsage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +82,7 @@ class MainActivity : AppCompatActivity() {
|
||||
val data = try {
|
||||
repo.fetchUsage()
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
prefs.getUsageData()?.copy(errorMessage = "Network error")
|
||||
?: UsageData(errorMessage = "Network error")
|
||||
}
|
||||
@@ -100,17 +104,37 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!loggedIn || data == null) return
|
||||
|
||||
binding.progressBar.progress = data.progressPercent
|
||||
// ── Peak-hours row ───────────────────────────────────────────────────
|
||||
val peak = PeakHours.state()
|
||||
binding.imgPeak.setColorFilter(if (peak.active) PEAK_ON else PEAK_OFF)
|
||||
binding.tvPeak.setTextColor(if (peak.active) PEAK_ON else 0xFFAAAAAA.toInt())
|
||||
binding.tvPeak.text = if (peak.active)
|
||||
"🔥 Peak hours — ${peak.endsInLabel} · ${peak.windowLabel}"
|
||||
else
|
||||
"Off-peak · ${peak.windowLabel}"
|
||||
|
||||
// ── Session (5-hour) bar — no pace marker ────────────────────────────
|
||||
binding.barSession.setImageBitmap(
|
||||
BarRenderer.render(data.progressPercent, null, SESSION_FILL, null)
|
||||
)
|
||||
|
||||
// ── Weekly (7-day) bar — single-color pace marker ────────────────────
|
||||
if (data.weeklyUtilization >= 0f) {
|
||||
val wPct = data.weeklyUtilization.toInt()
|
||||
binding.progressBarWeekly.progress = wPct
|
||||
val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
||||
binding.barWeekly.setImageBitmap(
|
||||
BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, if (weeklyPace != null) MARKER_COLOR else null)
|
||||
)
|
||||
binding.tvWeeklyUsage.text = "$wPct% this week"
|
||||
} else {
|
||||
binding.progressBarWeekly.progress = 0
|
||||
binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null))
|
||||
binding.tvWeeklyUsage.text = "—"
|
||||
}
|
||||
|
||||
// Pace text removed per design — bars carry the signal.
|
||||
binding.tvSessionPace.visibility = View.GONE
|
||||
binding.tvWeeklyPace.visibility = View.GONE
|
||||
|
||||
binding.tvUsage.text = when {
|
||||
data.fiveHourUtilization >= 0f -> {
|
||||
val pct = data.fiveHourUtilization.toInt()
|
||||
@@ -126,7 +150,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
binding.tvReset.text = formatReset(data.effectiveResetEpoch)
|
||||
binding.tvWeeklyReset.text = formatReset(data.weeklyResetAtEpoch)
|
||||
binding.tvWeeklyReset.text = formatResetDay(data.weeklyResetAtEpoch)
|
||||
binding.tvUpdated.text = if (data.lastUpdated > 0)
|
||||
"Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}"
|
||||
else ""
|
||||
@@ -147,4 +171,21 @@ class MainActivity : AppCompatActivity() {
|
||||
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
|
||||
}
|
||||
}
|
||||
|
||||
/** Weekly reset shown with the weekday name ("Resets Friday 3:00 PM"), never "tomorrow". */
|
||||
private fun formatResetDay(epochMs: Long): String {
|
||||
if (epochMs <= 0) return ""
|
||||
if (epochMs <= System.currentTimeMillis()) return "Resets soon"
|
||||
val day = SimpleDateFormat("EEEE", Locale.US).format(Date(epochMs))
|
||||
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
|
||||
return "Resets $day $timeStr"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SESSION_FILL = 0xFFCC785C.toInt()
|
||||
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
||||
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color weekly pace marker
|
||||
private const val PEAK_ON = 0xFFCC785C.toInt()
|
||||
private const val PEAK_OFF = 0xFF666666.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package me.khodak.claudeusage
|
||||
|
||||
/**
|
||||
* Pace projection mirroring hamed-elfayome/Claude-Usage-Tracker's PaceStatus.
|
||||
*
|
||||
* Idea: if you keep your current burn rate, where do you land at reset?
|
||||
* projected = (usedPct / 100) / elapsedFraction
|
||||
* The "where you should be right now" marker sits at elapsedFraction*100 on the bar —
|
||||
* i.e. the position that finishes at exactly 100% by reset.
|
||||
*/
|
||||
object PaceCalc {
|
||||
|
||||
const val SESSION_WINDOW_MS = 5L * 60 * 60 * 1000 // 5 hours
|
||||
const val WEEKLY_WINDOW_MS = 7L * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
// System-style tier colors (ARGB).
|
||||
private const val GREEN = 0xFF34C759.toInt()
|
||||
private const val TEAL = 0xFF30B0C7.toInt()
|
||||
private const val YELLOW = 0xFFFFCC00.toInt()
|
||||
private const val ORANGE = 0xFFFF9500.toInt()
|
||||
private const val RED = 0xFFFF3B30.toInt()
|
||||
private const val PURPLE = 0xFFAF52DE.toInt()
|
||||
|
||||
data class Pace(
|
||||
val markerPct: Int, // where you "should be" now (0..100)
|
||||
val projected: Float, // projected end-of-period fraction (1.0 == exactly 100%)
|
||||
val tierColor: Int, // ARGB color for the tier
|
||||
val label: String // short interpretation, e.g. "sustainable pace"
|
||||
)
|
||||
|
||||
/**
|
||||
* @param usedPct current utilization 0..100
|
||||
* @param resetEpoch epoch millis when this window resets (period end)
|
||||
* @param windowMs total length of the window
|
||||
* @return Pace, or null if we can't meaningfully project yet
|
||||
*/
|
||||
fun compute(
|
||||
usedPct: Float,
|
||||
resetEpoch: Long,
|
||||
windowMs: Long,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): Pace? {
|
||||
if (resetEpoch <= 0L || usedPct < 0f) return null
|
||||
val start = resetEpoch - windowMs
|
||||
val elapsed = now - start
|
||||
val elapsedFraction = elapsed.toFloat() / windowMs.toFloat()
|
||||
// Match reference: need >=3% elapsed and period not complete.
|
||||
if (elapsedFraction < 0.03f || elapsedFraction >= 1.0f) return null
|
||||
|
||||
val markerPct = (elapsedFraction * 100f).toInt().coerceIn(0, 100)
|
||||
if (usedPct == 0f) {
|
||||
return Pace(markerPct, 0f, GREEN, "way under budget")
|
||||
}
|
||||
val projected = (usedPct / 100f) / elapsedFraction
|
||||
val (color, label) = when {
|
||||
projected < 0.50f -> GREEN to "way under budget"
|
||||
projected < 0.75f -> TEAL to "sustainable pace"
|
||||
projected < 0.90f -> YELLOW to "starting to push it"
|
||||
projected < 1.00f -> ORANGE to "will likely hit limit"
|
||||
projected < 1.20f -> RED to "on track to exceed"
|
||||
else -> PURPLE to "burning way too fast"
|
||||
}
|
||||
return Pace(markerPct, projected, color, label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package me.khodak.claudeusage
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Anthropic peak usage window, mirroring hamed-elfayome/Claude-Usage-Tracker's PeakHoursService:
|
||||
* 5:00–11:00 AM America/Los_Angeles, Monday–Friday. Tokens burn faster during this window.
|
||||
*/
|
||||
object PeakHours {
|
||||
|
||||
private const val PEAK_START_HOUR = 5 // 5 AM PT inclusive
|
||||
private const val PEAK_END_HOUR = 11 // 11 AM PT exclusive
|
||||
private val PT: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
|
||||
|
||||
data class PeakState(
|
||||
val active: Boolean,
|
||||
val endsInLabel: String, // e.g. "ends in 2h 14m" (only meaningful when active)
|
||||
val windowLabel: String // e.g. "5–11 AM PT · 8 AM–2 PM your time"
|
||||
)
|
||||
|
||||
fun isPeak(now: Long = System.currentTimeMillis()): Boolean {
|
||||
val cal = GregorianCalendar(PT).apply { timeInMillis = now }
|
||||
val dow = cal.get(Calendar.DAY_OF_WEEK) // Sun=1 .. Sat=7
|
||||
val weekday = dow in Calendar.MONDAY..Calendar.FRIDAY
|
||||
val hour = cal.get(Calendar.HOUR_OF_DAY)
|
||||
return weekday && hour in PEAK_START_HOUR until PEAK_END_HOUR
|
||||
}
|
||||
|
||||
/** Epoch millis of 11:00 AM PT on the current PT day (peak window close). */
|
||||
fun peakEndEpoch(now: Long = System.currentTimeMillis()): Long {
|
||||
val cal = GregorianCalendar(PT).apply {
|
||||
timeInMillis = now
|
||||
set(Calendar.HOUR_OF_DAY, PEAK_END_HOUR)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
return cal.timeInMillis
|
||||
}
|
||||
|
||||
fun state(now: Long = System.currentTimeMillis()): PeakState {
|
||||
val active = isPeak(now)
|
||||
val endsIn = if (active) {
|
||||
val rem = peakEndEpoch(now) - now
|
||||
val h = TimeUnit.MILLISECONDS.toHours(rem)
|
||||
val m = TimeUnit.MILLISECONDS.toMinutes(rem) % 60
|
||||
if (h > 0) "ends in ${h}h ${m}m" else "ends in ${m}m"
|
||||
} else ""
|
||||
return PeakState(active, endsIn, localWindowString(now))
|
||||
}
|
||||
|
||||
/** "5–11 AM PT · 8 AM–2 PM your time" — peak window translated to the device timezone. */
|
||||
fun localWindowString(now: Long = System.currentTimeMillis()): String {
|
||||
val local = TimeZone.getDefault()
|
||||
if (local.id == PT.id) return "5–11 AM PT"
|
||||
val startLocal = ptHourToLocal(PEAK_START_HOUR, now, local)
|
||||
val endLocal = ptHourToLocal(PEAK_END_HOUR, now, local)
|
||||
val fmt = SimpleDateFormat("h a", Locale.US).apply { timeZone = local }
|
||||
val s = fmt.format(Date(startLocal)).replace(" ", " ")
|
||||
val e = fmt.format(Date(endLocal)).replace(" ", " ")
|
||||
return "5–11 AM PT · $s–$e your time"
|
||||
}
|
||||
|
||||
private fun ptHourToLocal(ptHour: Int, now: Long, local: TimeZone): Long {
|
||||
val cal = GregorianCalendar(PT).apply {
|
||||
timeInMillis = now
|
||||
set(Calendar.HOUR_OF_DAY, ptHour)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
return cal.timeInMillis // absolute instant; formatting in `local` renders local wall time
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package me.khodak.claudeusage
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.khodak.claudeusage.BuildConfig
|
||||
import me.khodak.claudeusage.data.PreferencesManager
|
||||
import me.khodak.claudeusage.data.UsageData
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -67,13 +68,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
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 (BuildConfig.DEBUG) Log.d(TAG, "GET $usageUrl → $code")
|
||||
if (code == 401 || code == 403) {
|
||||
prefs.clearSession()
|
||||
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
|
||||
}
|
||||
val body = resp.body?.string() ?: ""
|
||||
debugBuf.append("$usageUrl\n→ $code: ${body.take(400)}\n\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n→ $code: ${body.take(400)}\n\n")
|
||||
val utilData = tryParseUtilizationBody(body)
|
||||
if (utilData != null) {
|
||||
return@withContext base.copy(
|
||||
@@ -98,7 +99,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
val req = buildRequest(url, cookies)
|
||||
val resp = client.newCall(req).execute()
|
||||
val code = resp.code
|
||||
Log.d(TAG, "GET $url → $code")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url → $code")
|
||||
|
||||
if (code == 401 || code == 403) {
|
||||
prefs.clearSession()
|
||||
@@ -107,8 +108,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
|
||||
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)}")
|
||||
if (BuildConfig.DEBUG) {
|
||||
debugBuf.append("$url\n→ $code: ${body.take(300)}\n\n")
|
||||
Log.d(TAG, "Body: ${body.take(300)}")
|
||||
}
|
||||
|
||||
val parsed = tryParseUsageBody(body, rateLimitData)
|
||||
if (parsed.hasRateLimitData) {
|
||||
@@ -121,8 +124,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Endpoint $url failed: ${e.message}")
|
||||
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
|
||||
if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +134,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
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 (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me\n→ ${resp.code}: ${body.take(400)}\n\n")
|
||||
if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) {
|
||||
val json = JSONObject(body)
|
||||
val parsed = tryParseOrgForUsage(json)
|
||||
@@ -145,7 +148,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
|
||||
}
|
||||
|
||||
// Step 4: try page HTML for __NEXT_DATA__
|
||||
@@ -169,7 +172,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
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")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("HTML: no __NEXT_DATA__ found\n")
|
||||
return null
|
||||
}
|
||||
val jsonStart = start + marker.length
|
||||
@@ -177,10 +180,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
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")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
|
||||
tryExtractFromNextData(nextData)
|
||||
} catch (e: Exception) {
|
||||
debugBuf.append("HTML scrape error: ${e.message}\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("HTML scrape error: ${e.message}\n")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -200,7 +203,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
obj = (obj as? JSONObject)?.opt(key)
|
||||
}
|
||||
val o = obj as? JSONObject ?: continue
|
||||
debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
|
||||
val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
|
||||
if (usage.hasRateLimitData) return usage
|
||||
}
|
||||
@@ -210,9 +213,9 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
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}")
|
||||
if (BuildConfig.DEBUG) 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 (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/organizations\n→ ${resp.code}: ${body.take(400)}\n\n")
|
||||
if (body.isBlank() || body.startsWith("<")) return Pair(null, null)
|
||||
val arr = JSONArray(body)
|
||||
val org = arr.optJSONObject(0) ?: return Pair(null, null)
|
||||
@@ -332,7 +335,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
.header("Accept", "application/json, */*")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Referer", "https://claude.ai/")
|
||||
.header("Cookie", cookies)
|
||||
.header("Cookie", cookies.replace("\r", "").replace("\n", ""))
|
||||
.get()
|
||||
.build()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.khodak.claudeusage.data.PreferencesManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UsageUpdateWorker(
|
||||
private val context: Context,
|
||||
@@ -33,7 +34,7 @@ class UsageUpdateWorker(
|
||||
prefs.saveUsageData(data)
|
||||
} catch (_: Exception) {}
|
||||
animJob.cancel()
|
||||
animJob.join() // wait for the minimum-rotation finally block to finish
|
||||
animJob.join()
|
||||
}
|
||||
|
||||
pushWidgetUpdate()
|
||||
@@ -43,23 +44,25 @@ class UsageUpdateWorker(
|
||||
private suspend fun rotateRefreshIcon() {
|
||||
val manager = AppWidgetManager.getInstance(context)
|
||||
val ids = manager.getAppWidgetIds(ComponentName(context, ClaudeUsageWidget::class.java))
|
||||
var totalDegrees = 0f
|
||||
val startMs = System.currentTimeMillis()
|
||||
val msPerRotation = 800L // one full rotation every 0.8 seconds
|
||||
|
||||
fun angleAt(now: Long) = ((now - startMs) % msPerRotation) * 360f / msPerRotation
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
ClaudeUsageWidget.currentRotation = (ClaudeUsageWidget.currentRotation + 12f) % 360f
|
||||
totalDegrees += 12f
|
||||
ClaudeUsageWidget.currentRotation = angleAt(System.currentTimeMillis())
|
||||
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
|
||||
delay(33) // 30 fps, one full rotation per second
|
||||
delay(16) // aim for ~60fps; IPC speed sets the real ceiling
|
||||
}
|
||||
} finally {
|
||||
// Even if the fetch finishes early, complete at least one full 360°
|
||||
// Finish the current rotation cleanly — run until at least one full spin
|
||||
withContext(NonCancellable) {
|
||||
while (totalDegrees < 360f) {
|
||||
ClaudeUsageWidget.currentRotation = (ClaudeUsageWidget.currentRotation + 6f) % 360f
|
||||
totalDegrees += 6f
|
||||
val minEndMs = startMs + msPerRotation
|
||||
while (System.currentTimeMillis() < minEndMs) {
|
||||
ClaudeUsageWidget.currentRotation = angleAt(System.currentTimeMillis())
|
||||
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
|
||||
delay(33)
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,21 +78,39 @@ class UsageUpdateWorker(
|
||||
|
||||
companion object {
|
||||
private const val WORK_ONE_SHOT = "claude_oneshot"
|
||||
private const val WORK_PERIODIC = "claude_periodic"
|
||||
private const val ALARM_CODE = 1001
|
||||
private const val INTERVAL_MS = 5 * 60 * 1000L
|
||||
|
||||
fun schedulePeriodicRefresh(context: Context) {
|
||||
// 5-min alarm for fast updates when the device is active/awake
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
am.setAndAllowWhileIdle(
|
||||
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
SystemClock.elapsedRealtime() + INTERVAL_MS,
|
||||
alarmIntent(context)
|
||||
)
|
||||
// WorkManager periodic as a Doze/background backup (Android 16 reliability).
|
||||
// WorkManager uses JobScheduler which survives Doze; AlarmManager may be batched
|
||||
// up to 75 min in Doze mode. KEEP = don't reset the 15-min timer on every alarm.
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_PERIODIC,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<UsageUpdateWorker>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelPeriodicRefresh(context: Context) =
|
||||
fun cancelPeriodicRefresh(context: Context) {
|
||||
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
|
||||
.cancel(alarmIntent(context))
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_PERIODIC)
|
||||
}
|
||||
|
||||
fun triggerImmediateRefresh(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
|
||||
@@ -10,35 +10,26 @@ 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 var usingFallbackPrefs = false
|
||||
private val securePrefs = createSecurePrefs(context, onFallback = { usingFallbackPrefs = true })
|
||||
|
||||
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
fun saveCookies(cookies: String) {
|
||||
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
|
||||
// Plaintext backup — survives EncryptedSharedPreferences key rotation on reinstall
|
||||
prefs.edit().putString(KEY_COOKIES_BACKUP, cookies).apply()
|
||||
// Never store cookies in plain-text fallback prefs
|
||||
if (usingFallbackPrefs) return
|
||||
try {
|
||||
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun getCookies(): String? =
|
||||
fun getCookies(): String? = try {
|
||||
securePrefs.getString(KEY_COOKIES, null)
|
||||
?: prefs.getString(KEY_COOKIES_BACKUP, null)
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
fun clearSession() {
|
||||
securePrefs.edit().clear().apply()
|
||||
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START)
|
||||
.remove(KEY_COOKIES_BACKUP).apply()
|
||||
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
|
||||
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).apply()
|
||||
}
|
||||
|
||||
fun saveOrgId(id: String) = prefs.edit().putString(KEY_ORG_ID, id).apply()
|
||||
@@ -84,11 +75,52 @@ class PreferencesManager(context: Context) {
|
||||
|
||||
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"
|
||||
|
||||
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
||||
return try {
|
||||
buildEncryptedPrefs(context)
|
||||
} catch (e: Exception) {
|
||||
if (isKeyPermanentlyInvalidated(e)) {
|
||||
// Key permanently gone (biometric/PIN changed) — must wipe; user must re-login.
|
||||
try {
|
||||
context.deleteSharedPreferences("claude_secure")
|
||||
buildEncryptedPrefs(context)
|
||||
} catch (_: Exception) {
|
||||
onFallback?.invoke()
|
||||
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
|
||||
}
|
||||
} else {
|
||||
// Transient failure (Keystore busy, cold boot, screen locked during BG work).
|
||||
// Do NOT delete the encrypted file — it will be readable next session.
|
||||
onFallback?.invoke()
|
||||
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEncryptedPrefs(context: Context): android.content.SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context, "claude_secure", masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
private fun isKeyPermanentlyInvalidated(e: Exception): Boolean {
|
||||
var t: Throwable? = e
|
||||
while (t != null) {
|
||||
if (t is android.security.keystore.KeyPermanentlyInvalidatedException) return true
|
||||
t = t.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Claude "sunburst" mark. Drawn in white; tinted at runtime via setColorFilter
|
||||
(calm #666666, peak #CC785C). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="1.7"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="
|
||||
M12,3 L12,7
|
||||
M12,17 L12,21
|
||||
M3,12 L7,12
|
||||
M17,12 L21,12
|
||||
M5.6,5.6 L8.4,8.4
|
||||
M15.6,15.6 L18.4,18.4
|
||||
M18.4,5.6 L15.6,8.4
|
||||
M8.4,15.6 L5.6,18.4
|
||||
M12,4.5 L12,6.2
|
||||
M12,17.8 L12,19.5
|
||||
M4.5,12 L6.2,12
|
||||
M17.8,12 L19.5,12" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,9.6 a2.4,2.4 0 1,0 0.01,0 z" />
|
||||
</vector>
|
||||
@@ -92,6 +92,33 @@
|
||||
android:background="@drawable/widget_background"
|
||||
android:padding="20dp">
|
||||
|
||||
<!-- Peak-hours row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgPeak"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:src="@drawable/ic_claude_burst"
|
||||
android:contentDescription="Peak hours" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPeak"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text=""
|
||||
android:textColor="#AAAAAA"
|
||||
android:textSize="12sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -110,16 +137,24 @@
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
<ImageView
|
||||
android:id="@+id/barSession"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="8dp"
|
||||
android:layout_height="9dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:max="100"
|
||||
android:progress="0"
|
||||
android:progressTint="#CC785C"
|
||||
android:progressBackgroundTint="#3A3A3A" />
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="false"
|
||||
android:contentDescription="Session usage bar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSessionPace"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text=""
|
||||
android:textColor="#AAAAAA"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvReset"
|
||||
@@ -148,16 +183,24 @@
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBarWeekly"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
<ImageView
|
||||
android:id="@+id/barWeekly"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="8dp"
|
||||
android:layout_height="9dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:max="100"
|
||||
android:progress="0"
|
||||
android:progressTint="#7B8FCC"
|
||||
android:progressBackgroundTint="#3A3A3A" />
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="false"
|
||||
android:contentDescription="Weekly usage bar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvWeeklyPace"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text=""
|
||||
android:textColor="#AAAAAA"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvWeeklyReset"
|
||||
|
||||
@@ -23,10 +23,28 @@
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/img_peak"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:src="@drawable/ic_claude_burst"
|
||||
android:contentDescription="Peak hours" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_peak"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:text=""
|
||||
android:textColor="#CC785C"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_refresh"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:src="@android:drawable/ic_menu_rotate"
|
||||
android:background="@android:color/transparent"
|
||||
android:tint="#999999"
|
||||
@@ -54,7 +72,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="SESSION"
|
||||
android:textColor="#555555"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
@@ -69,16 +87,14 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
<ImageView
|
||||
android:id="@+id/bar_session"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="5dp"
|
||||
android:layout_height="6dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:max="100"
|
||||
android:progress="0"
|
||||
android:progressTint="#CC785C"
|
||||
android:progressBackgroundTint="#252525" />
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="false"
|
||||
android:contentDescription="Session usage bar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_session_label"
|
||||
@@ -86,8 +102,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text=""
|
||||
android:textColor="#666666"
|
||||
android:textSize="9sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- 7-day window bar -->
|
||||
<LinearLayout
|
||||
@@ -102,7 +119,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="WEEKLY"
|
||||
android:textColor="#555555"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
@@ -117,16 +134,14 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar_weekly"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
<ImageView
|
||||
android:id="@+id/bar_weekly"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="5dp"
|
||||
android:layout_height="6dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:max="100"
|
||||
android:progress="0"
|
||||
android:progressTint="#7B8FCC"
|
||||
android:progressBackgroundTint="#252525" />
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="false"
|
||||
android:contentDescription="Weekly usage bar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_weekly_label"
|
||||
@@ -134,8 +149,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text=""
|
||||
android:textColor="#666666"
|
||||
android:textSize="9sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- Footer -->
|
||||
<LinearLayout
|
||||
@@ -151,8 +167,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text=""
|
||||
android:textColor="#CC785C"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
|
||||
@@ -161,8 +178,9 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textColor="#444444"
|
||||
android:textSize="9sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -23,10 +23,18 @@
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/img_peak"
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:src="@drawable/ic_claude_burst"
|
||||
android:contentDescription="Peak hours" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_refresh"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:src="@android:drawable/ic_menu_rotate"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="Refresh" />
|
||||
@@ -46,7 +54,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="SESSION"
|
||||
android:textColor="#555555"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
@@ -65,21 +73,20 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text=""
|
||||
android:textColor="#555555"
|
||||
android:textSize="8sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
<ImageView
|
||||
android:id="@+id/bar_session"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp"
|
||||
android:layout_height="5dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:max="100"
|
||||
android:progress="0"
|
||||
android:progressTint="#CC785C"
|
||||
android:progressBackgroundTint="#252525" />
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="false"
|
||||
android:contentDescription="Session usage bar" />
|
||||
|
||||
<!-- 7-DAY row -->
|
||||
<LinearLayout
|
||||
@@ -94,7 +101,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="WEEKLY"
|
||||
android:textColor="#555555"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
@@ -113,21 +120,20 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text=""
|
||||
android:textColor="#555555"
|
||||
android:textSize="8sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar_weekly"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
<ImageView
|
||||
android:id="@+id/bar_weekly"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp"
|
||||
android:layout_height="5dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:max="100"
|
||||
android:progress="0"
|
||||
android:progressTint="#7B8FCC"
|
||||
android:progressBackgroundTint="#252525" />
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="false"
|
||||
android:contentDescription="Weekly usage bar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
@@ -135,8 +141,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text=""
|
||||
android:textColor="#CC785C"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="1" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -11,6 +11,6 @@
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:initialLayout="@layout/widget_layout"
|
||||
android:widgetCategory="home_screen|keyguard"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_description"
|
||||
android:previewLayout="@layout/widget_layout" />
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+2
-4
@@ -1,7 +1,5 @@
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
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
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Gradle start up script for UN*X
|
||||
#
|
||||
#!/usr/bin/env sh
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
@@ -48,16 +69,23 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME"
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH."
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
@@ -81,14 +109,68 @@ 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
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
|
||||
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
|
||||
fi
|
||||
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
||||
Vendored
+84
@@ -0,0 +1,84 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user