Files
morns-keyboard-basic/srcs/juloo.keyboard2/UsageGauge.java
T

310 lines
9.9 KiB
Java

package juloo.keyboard2;
import android.graphics.Color;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.json.JSONObject;
/** Data and rendering logic for the display-only usage gauge. */
public final class UsageGauge
{
// The layout stores this marker in 'indication', so it identifies the key
// without adding a character to the generated compose table.
public static final String KEY_MARKER = "usage_gauge";
/** Long-pressing the gauge key opens this in the browser. */
public static final String USAGE_PAGE_URL = "https://platform.deepseek.com/usage";
public static final long REFRESH_AFTER_MS = 5 * 60 * 1000L;
// The sampler publishes every 15 minutes, so one missed tick must not read as stale: three
// do. A threshold equal to the interval makes the gauge blink to '--' on every cycle.
public static final long STALE_AFTER_MS = 45 * 60 * 1000L;
public static final class Reading
{
public final double h5Pct;
public final double weeklyPct;
public final double spentTodayUsd;
public final double balance;
/** True when the server's spend window opened mid-day: the total is a floor, not the day. */
public final boolean partial;
/** True when the server says the reading it sent is too old to present as current. */
public final boolean serverStale;
Reading(double h5, double weekly, double spent, double balance_)
{
this(h5, weekly, spent, balance_, false, false);
}
Reading(double h5, double weekly, double spent, double balance_, boolean partial_,
boolean serverStale_)
{
h5Pct = h5;
weeklyPct = weekly;
spentTodayUsd = spent;
balance = balance_;
partial = partial_;
serverStale = serverStale_;
}
}
private static volatile Reading _cached;
private static volatile long _cachedAt;
private static volatile boolean _failed;
private static boolean _inFlight;
private UsageGauge() {}
public static boolean isGaugeKey(KeyboardData.Key key)
{
return key != null && KEY_MARKER.equals(key.indication);
}
/** Returns null when any value needed for a trustworthy display is absent or invalid. */
public static Reading parse(String json)
{
try
{
JSONObject root = new JSONObject(json);
JSONObject codex = root.getJSONObject("codex");
JSONObject deepseek = root.getJSONObject("deepseek");
double h5 = requiredNumber(codex.getJSONObject("h5"), "pct");
double weekly = requiredNumber(codex.getJSONObject("weekly"), "pct");
double spent = requiredNumber(deepseek, "spent_today_usd");
double balance = requiredNumber(deepseek, "balance");
if (!finite(h5) || !finite(weekly) || !finite(spent) || !finite(balance)
|| h5 < 0 || weekly < 0 || spent < 0)
return null;
boolean partial = deepseek.optBoolean("partial", false);
boolean serverStale = codex.optBoolean("stale", false)
|| deepseek.optBoolean("stale", false);
return new Reading(h5, weekly, spent, balance, partial, serverStale);
}
catch (RuntimeException e)
{
// The local JVM test runner replaces Android's org.json methods with
// throwing stubs. Keep the same defensive behavior testable there.
return parseJsonFallback(json);
}
catch (Exception e)
{
return null;
}
}
private static Reading parseJsonFallback(String json)
{
try
{
String codex = objectForKey(json, "codex");
String deepseek = objectForKey(json, "deepseek");
double h5 = numberForKey(objectForKey(codex, "h5"), "pct");
double weekly = numberForKey(objectForKey(codex, "weekly"), "pct");
double spent = numberForKey(deepseek, "spent_today_usd");
double balance = numberForKey(deepseek, "balance");
if (!finite(h5) || !finite(weekly) || !finite(spent) || !finite(balance)
|| h5 < 0 || weekly < 0 || spent < 0)
return null;
boolean partial = booleanForKey(deepseek, "partial");
boolean serverStale = booleanForKey(codex, "stale") || booleanForKey(deepseek, "stale");
return new Reading(h5, weekly, spent, balance, partial, serverStale);
}
catch (Exception e)
{
return null;
}
}
private static String objectForKey(String json, String key) throws Exception
{
Matcher matcher = Pattern.compile("\\\"" + Pattern.quote(key)
+ "\\\"\\s*:\\s*\\{").matcher(json);
if (!matcher.find())
throw new Exception("Missing object " + key);
int start = matcher.end() - 1;
int depth = 0;
boolean quoted = false;
boolean escaped = false;
for (int i = start; i < json.length(); i++)
{
char c = json.charAt(i);
if (quoted)
{
if (escaped) escaped = false;
else if (c == '\\') escaped = true;
else if (c == '"') quoted = false;
}
else if (c == '"') quoted = true;
else if (c == '{') depth++;
else if (c == '}' && --depth == 0)
return json.substring(start, i + 1);
}
throw new Exception("Unclosed object " + key);
}
private static double numberForKey(String json, String key) throws Exception
{
Matcher matcher = Pattern.compile("\\\"" + Pattern.quote(key)
+ "\\\"\\s*:\\s*(-?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)(?:[eE][+-]?[0-9]+)?)")
.matcher(json);
if (!matcher.find())
throw new Exception("Missing numeric field " + key);
return Double.parseDouble(matcher.group(1));
}
/** Only a literal true counts: an absent or malformed flag must never claim confidence. */
private static boolean booleanForKey(String json, String key)
{
try
{
int at = json.indexOf("\"" + key + "\"");
if (at < 0)
return false;
int colon = json.indexOf(':', at);
if (colon < 0)
return false;
int i = colon + 1;
while (i < json.length() && Character.isWhitespace(json.charAt(i)))
i++;
return json.startsWith("true", i);
}
catch (Exception e)
{
return false;
}
}
private static double requiredNumber(JSONObject object, String key) throws Exception
{
Object value = object.get(key);
if (!(value instanceof Number))
throw new Exception("Missing numeric field " + key);
return ((Number)value).doubleValue();
}
private static boolean finite(double value)
{
return !Double.isNaN(value) && !Double.isInfinite(value);
}
public static float fillFraction(Reading reading)
{
if (reading == null)
return 0;
return (float)(Math.max(0, Math.min(100,
Math.max(reading.h5Pct, reading.weeklyPct))) / 100.0);
}
public static boolean isStale(long fetchedAt, long now, boolean failed,
String token)
{
return token == null || token.trim().isEmpty() || failed || fetchedAt <= 0
|| now < fetchedAt || now - fetchedAt > STALE_AFTER_MS;
}
public static boolean isLowBalance(Reading reading)
{
return reading == null || reading.balance < 1.00;
}
public static synchronized void fetchIfNeeded(String url, String token,
Runnable onComplete)
{
long now = System.currentTimeMillis();
if (token == null || token.trim().isEmpty() || _inFlight
|| (_cached != null && now - _cachedAt <= REFRESH_AFTER_MS))
return;
_inFlight = true;
Thread worker = new Thread(() -> {
Reading reading = fetch(url, token);
synchronized (UsageGauge.class)
{
// A reading the server itself flags as too old is as unusable as a failed fetch:
// presenting it as current is the exact lie the gauge is built to avoid.
if (reading == null || reading.serverStale)
_failed = true;
else
{
_cached = reading;
_cachedAt = System.currentTimeMillis();
_failed = false;
}
_inFlight = false;
}
if (onComplete != null)
onComplete.run();
}, "UsageGauge");
worker.setDaemon(true);
worker.start();
}
private static Reading fetch(String url, String token)
{
HttpURLConnection connection = null;
try
{
connection = (HttpURLConnection)new URL(url).openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(3000);
connection.setReadTimeout(5000);
connection.setRequestProperty("Authorization", "Bearer " + token);
connection.setRequestProperty("Accept", "application/json");
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK)
return null;
try (InputStream input = connection.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(input,
StandardCharsets.UTF_8)))
{
StringBuilder body = new StringBuilder();
String line;
while ((line = reader.readLine()) != null)
body.append(line);
return parse(body.toString());
}
}
catch (Exception e)
{
return null;
}
finally
{
if (connection != null)
connection.disconnect();
}
}
public static Reading cachedReading()
{
return _cached;
}
public static boolean isStaleNow(String token)
{
return isStale(_cachedAt, System.currentTimeMillis(), _failed, token);
}
public static long cachedAt()
{
return _cachedAt;
}
public static int hueColor(float fraction)
{
float level = Math.max(0, Math.min(1, fraction));
if (level < 0.5f)
return blend(Color.rgb(42, 190, 95), Color.rgb(245, 178, 45), level * 2);
return blend(Color.rgb(245, 178, 45), Color.rgb(235, 55, 55), (level - 0.5f) * 2);
}
private static int blend(int a, int b, float t)
{
return Color.rgb((int)(Color.red(a) + (Color.red(b) - Color.red(a)) * t),
(int)(Color.green(a) + (Color.green(b) - Color.green(a)) * t),
(int)(Color.blue(a) + (Color.blue(b) - Color.blue(a)) * t));
}
}