310 lines
9.9 KiB
Java
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));
|
|
}
|
|
}
|