Add live usage gauge key (GPT gradient + DeepSeek spend) beside L
- UsageGauge: WireGuard-only JSON fetch, cached, background thread, stale-aware - Gauge key: gradient fill rises with the tightest Codex window, %.1f of today's DeepSeek spend, red below a $1.00 balance, flat grey when stale/offline - Display-only: marker lives in the key indication field, no character, touch swallowed - Settings entries; cleartext permitted only for 10.10.10.1 - ComposeKey: composing to U+00A0 keeps the named nbsp key, since adding a layout key perturbs the generated compose table and would otherwise emit the display glyph - 5 new unit tests; all 21 tests pass
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
|
||||
<application android:label="@string/app_name" android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:hardwareAccelerated="false">
|
||||
<application android:label="@string/app_name" android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:hardwareAccelerated="false" android:networkSecurityConfig="@xml/network_security_config">
|
||||
<service android:name="juloo.keyboard2.Keyboard2" android:label="@string/app_name" android:permission="android.permission.BIND_INPUT_METHOD" android:exported="true" android:directBootAware="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.view.InputMethod"/>
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ latin_kbdtuf_tr: These keys are now added automatically, unexpected: f11_placeho
|
||||
latin_kbdtuf_tr: Missing programming keys, missing: loc esc, loc tab
|
||||
latn_colemak: Some keys contain whitespaces, unexpected: ́
|
||||
latn_dvorak: Missing important key, missing: loc capslock
|
||||
latn_neo2: Layout redefines the bottom row but some important keys are missing, missing: loc switch_clipboard
|
||||
latn_neo2: Layout redefines the bottom row but some important keys are missing, missing: alt, loc switch_clipboard, meta, pgdn, pgup, switch_fn
|
||||
latn_qwertz_cz_multifunctional: Layout includes some ASCII punctuation but not all, missing: `
|
||||
latn_qwertz_sk: Layout includes some ASCII punctuation but not all, missing: `
|
||||
latn_qzerty_it: Layout includes some ASCII punctuation but not all, missing: #, $, \, `, ~
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<string name="pref_landscape">In landscape mode</string>
|
||||
<string name="pref_landscape_unfolded">In landscape mode unfolded</string>
|
||||
<string name="pref_category_layout">Layout</string>
|
||||
<string name="pref_usage_gauge_enabled">Usage gauge key</string>
|
||||
<string name="pref_usage_gauge_url">Usage gauge URL</string>
|
||||
<string name="pref_usage_gauge_token">Usage gauge bearer token</string>
|
||||
<string name="pref_label_brightness">Adjust label brightness</string>
|
||||
<string name="pref_keyboard_opacity">Adjust keyboard background opacity</string>
|
||||
<string name="pref_key_opacity">Adjust key opacity</string>
|
||||
@@ -169,4 +172,4 @@
|
||||
<string name="pref_split_layout_never">Never</string>
|
||||
<string name="pref_split_layout_landscape">In landscape orientation</string>
|
||||
<string name="pref_split_layout_wide">On wide screens</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false"/>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.10.10.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<PreferenceCategory android:title="@string/pref_category_layout">
|
||||
<CheckBoxPreference android:key="usage_gauge_enabled" android:title="@string/pref_usage_gauge_enabled" android:defaultValue="true"/>
|
||||
<EditTextPreference android:key="usage_gauge_url" android:title="@string/pref_usage_gauge_url" android:defaultValue="http://10.10.10.1:8771/usage" android:inputType="textUri"/>
|
||||
<EditTextPreference android:key="usage_gauge_token" android:title="@string/pref_usage_gauge_token" android:defaultValue="" android:inputType="textPassword"/>
|
||||
<juloo.keyboard2.prefs.LayoutsPreference/>
|
||||
<PreferenceScreen android:title="@string/pref_extra_keys_title">
|
||||
<PreferenceCategory android:title="@string/pref_extra_keys_custom">
|
||||
|
||||
@@ -43,7 +43,12 @@ public final class ComposeKey
|
||||
new String(states, next + 1, next_length - 1));
|
||||
}
|
||||
else // Character final state.
|
||||
{
|
||||
// Preserve the named-key identity used by compose sequences for NBSP.
|
||||
if (next_header == '\u00a0')
|
||||
return KeyValue.getKeyByName("nbsp");
|
||||
return KeyValue.makeCharKey((char)next_header);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply char [c] to the pending compose sequence. If the application resolves
|
||||
|
||||
@@ -76,6 +76,9 @@ public final class Config
|
||||
public int clipboard_history_duration;
|
||||
public boolean space_bar_auto_complete;
|
||||
public boolean physical_keyboard_hide;
|
||||
public boolean usageGaugeEnabled;
|
||||
public String usageGaugeUrl;
|
||||
public String usageGaugeToken;
|
||||
|
||||
// Dynamically set
|
||||
/** Configuration options implied by the connected editor. */
|
||||
@@ -199,6 +202,9 @@ public final class Config
|
||||
clipboard_history_duration = Integer.parseInt(_prefs.getString("clipboard_history_duration", "5"));
|
||||
space_bar_auto_complete = _prefs.getBoolean("space_bar_auto_complete", false);
|
||||
physical_keyboard_hide = _prefs.getString("physical_keyboard_behavior", "hide").equals("hide");
|
||||
usageGaugeEnabled = _prefs.getBoolean("usage_gauge_enabled", true);
|
||||
usageGaugeUrl = _prefs.getString("usage_gauge_url", "http://10.10.10.1:8771/usage");
|
||||
usageGaugeToken = _prefs.getString("usage_gauge_token", "");
|
||||
float screen_width_dp = dm.widthPixels / dm.density;
|
||||
wide_screen = screen_width_dp >= WIDE_DEVICE_THRESHOLD;
|
||||
split_layout = get_split_layout();
|
||||
|
||||
@@ -277,6 +277,9 @@ public class Keyboard2 extends InputMethodService
|
||||
Logs.debug_startup_input_view(info, _config);
|
||||
_handler.removeCallbacks(_peakIndicatorRefresh);
|
||||
_handler.postDelayed(_peakIndicatorRefresh, 60000);
|
||||
if (_config.usageGaugeEnabled)
|
||||
UsageGauge.fetchIfNeeded(_config.usageGaugeUrl, _config.usageGaugeToken,
|
||||
() -> _handler.post(() -> _keyboard_layout_view.invalidate()));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -5,8 +5,11 @@ import android.content.ContextWrapper;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Insets;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.LinearGradient;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Shader;
|
||||
import android.inputmethodservice.InputMethodService;
|
||||
import android.os.Build.VERSION;
|
||||
import android.util.AttributeSet;
|
||||
@@ -19,6 +22,7 @@ import android.view.WindowManager;
|
||||
import android.view.WindowMetrics;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class Keyboard2View extends View
|
||||
implements View.OnTouchListener, Pointers.IPointerEventHandler
|
||||
@@ -54,6 +58,8 @@ public class Keyboard2View extends View
|
||||
private Theme.Computed _tc;
|
||||
|
||||
private static RectF _tmpRect = new RectF();
|
||||
private static final Paint GAUGE_PAINT = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private static final Path GAUGE_PATH = new Path();
|
||||
|
||||
enum Vertical
|
||||
{
|
||||
@@ -207,7 +213,7 @@ public class Keyboard2View extends View
|
||||
float tx = event.getX(p);
|
||||
float ty = event.getY(p);
|
||||
KeyboardData.Key key = getKeyAtPosition(tx, ty);
|
||||
if (key != null)
|
||||
if (key != null && !UsageGauge.isGaugeKey(key))
|
||||
_pointers.onTouchDown(tx, ty, event.getPointerId(p), key);
|
||||
break;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
@@ -364,20 +370,90 @@ public class Keyboard2View extends View
|
||||
case Normal: tc_key = _tc.key; break;
|
||||
}
|
||||
drawKeyFrame(canvas, x, y, keyW, keyH, tc_key);
|
||||
if (k.keys[0] != null)
|
||||
if (UsageGauge.isGaugeKey(k))
|
||||
drawUsageGauge(canvas, x, y, keyW, keyH, tc_key);
|
||||
else if (k.keys[0] != null)
|
||||
drawLabel(canvas, k.keys[0], keyW / 2f + x, y, keyH, isKeyDown, tc_key);
|
||||
for (int i = 1; i < 9; i++)
|
||||
{
|
||||
if (k.keys[i] != null)
|
||||
drawSubLabel(canvas, k.keys[i], x, y, keyW, keyH, i, isKeyDown, tc_key);
|
||||
}
|
||||
drawIndication(canvas, k, x, y, keyW, keyH, _tc);
|
||||
if (!UsageGauge.isGaugeKey(k))
|
||||
drawIndication(canvas, k, x, y, keyW, keyH, _tc);
|
||||
x += _keyWidth * k.width;
|
||||
}
|
||||
y += row.height * _tc.row_height;
|
||||
}
|
||||
}
|
||||
|
||||
private void drawUsageGauge(Canvas canvas, float x, float y, float keyW,
|
||||
float keyH, Theme.Computed.Key tc)
|
||||
{
|
||||
UsageGauge.Reading reading = UsageGauge.cachedReading();
|
||||
boolean stale = !_config.usageGaugeEnabled
|
||||
|| UsageGauge.isStaleNow(_config.usageGaugeToken);
|
||||
float inset = Math.max(1.f, tc.border_width);
|
||||
RectF bounds = new RectF(x + inset, y + inset, x + keyW - inset, y + keyH - inset);
|
||||
GAUGE_PATH.reset();
|
||||
GAUGE_PATH.addRoundRect(bounds, tc.border_radius, tc.border_radius, Path.Direction.CW);
|
||||
canvas.save();
|
||||
canvas.clipPath(GAUGE_PATH);
|
||||
if (stale || reading == null)
|
||||
{
|
||||
GAUGE_PAINT.setShader(null);
|
||||
GAUGE_PAINT.setColor(0xff55585e);
|
||||
canvas.drawRect(bounds, GAUGE_PAINT);
|
||||
}
|
||||
else
|
||||
{
|
||||
float level = UsageGauge.fillFraction(reading);
|
||||
int hue = UsageGauge.hueColor(level);
|
||||
GAUGE_PAINT.setColor(tc.bg_paint.getColor());
|
||||
canvas.drawRect(bounds, GAUGE_PAINT);
|
||||
if (level > 0)
|
||||
{
|
||||
float fillTop = bounds.bottom - bounds.height() * level;
|
||||
int darker = blendColor(hue, 0xff000000, 0.22f);
|
||||
GAUGE_PAINT.setShader(new LinearGradient(0, bounds.bottom, 0, fillTop,
|
||||
darker, hue, Shader.TileMode.CLAMP));
|
||||
canvas.drawRect(bounds.left, fillTop, bounds.right, bounds.bottom, GAUGE_PAINT);
|
||||
GAUGE_PAINT.setShader(null);
|
||||
}
|
||||
}
|
||||
String label = reading == null ? "—" : String.format(Locale.US, "%.1f", reading.spentTodayUsd);
|
||||
float size = Math.min(_subLabelSize, keyW * 0.40f);
|
||||
GAUGE_PAINT.setTextSize(size);
|
||||
GAUGE_PAINT.setTypeface(android.graphics.Typeface.DEFAULT);
|
||||
GAUGE_PAINT.setTextAlign(Paint.Align.CENTER);
|
||||
int labelColor = stale ? 0xff92949a :
|
||||
(UsageGauge.isLowBalance(reading) ? 0xffe53935 : _theme.labelColor);
|
||||
if (!stale)
|
||||
{
|
||||
int luminance = (android.graphics.Color.red(labelColor) * 299
|
||||
+ android.graphics.Color.green(labelColor) * 587
|
||||
+ android.graphics.Color.blue(labelColor) * 114) / 1000;
|
||||
GAUGE_PAINT.setStyle(Paint.Style.STROKE);
|
||||
GAUGE_PAINT.setStrokeWidth(Math.max(1.f, size * 0.12f));
|
||||
GAUGE_PAINT.setColor(luminance > 140 ? 0xff202020 : 0xfff5f5f5);
|
||||
canvas.drawText(label, bounds.centerX(),
|
||||
(keyH - GAUGE_PAINT.ascent() - GAUGE_PAINT.descent()) / 2f + y, GAUGE_PAINT);
|
||||
GAUGE_PAINT.setStyle(Paint.Style.FILL);
|
||||
}
|
||||
GAUGE_PAINT.setColor(labelColor);
|
||||
canvas.drawText(label, bounds.centerX(),
|
||||
(keyH - GAUGE_PAINT.ascent() - GAUGE_PAINT.descent()) / 2f + y, GAUGE_PAINT);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
private static int blendColor(int from, int to, float amount)
|
||||
{
|
||||
return android.graphics.Color.rgb(
|
||||
(int)(android.graphics.Color.red(from) * (1 - amount) + android.graphics.Color.red(to) * amount),
|
||||
(int)(android.graphics.Color.green(from) * (1 - amount) + android.graphics.Color.green(to) * amount),
|
||||
(int)(android.graphics.Color.blue(from) * (1 - amount) + android.graphics.Color.blue(to) * amount));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetachedFromWindow()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
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";
|
||||
public static final long REFRESH_AFTER_MS = 5 * 60 * 1000L;
|
||||
public static final long STALE_AFTER_MS = 15 * 60 * 1000L;
|
||||
|
||||
public static final class Reading
|
||||
{
|
||||
public final double h5Pct;
|
||||
public final double weeklyPct;
|
||||
public final double spentTodayUsd;
|
||||
public final double balance;
|
||||
|
||||
Reading(double h5, double weekly, double spent, double balance_)
|
||||
{
|
||||
h5Pct = h5;
|
||||
weeklyPct = weekly;
|
||||
spentTodayUsd = spent;
|
||||
balance = balance_;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
return new Reading(h5, weekly, spent, balance);
|
||||
}
|
||||
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;
|
||||
return new Reading(h5, weekly, spent, balance);
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (reading == null)
|
||||
_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));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ Useful links.
|
||||
<key c="j" sw="[" se="]"/>
|
||||
<key c="k" sw=""" se="'"/>
|
||||
<key c="l" sw=";" se=":"/>
|
||||
<!-- The indication is the stable UsageGauge.KEY_MARKER; it adds no key character. -->
|
||||
<key indication="usage_gauge"/>
|
||||
</row>
|
||||
<row>
|
||||
<key role="action" c="shift" nw="loc esc" ne="loc capslock"/>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package juloo.keyboard2;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class UsageGaugeTest
|
||||
{
|
||||
private static final String RESPONSE = "{\"codex\":{\"h5\":{\"pct\":2.0},"
|
||||
+ "\"weekly\":{\"pct\":90.0}},\"deepseek\":{"
|
||||
+ "\"spent_today_usd\":1.25,\"topped_up_today_usd\":9.0,\"balance\":0.99}}";
|
||||
|
||||
@Test
|
||||
public void parsesOnlyTheDisplayedUsageValues()
|
||||
{
|
||||
UsageGauge.Reading reading = UsageGauge.parse(RESPONSE);
|
||||
assertEquals(2.0, reading.h5Pct, 0.0);
|
||||
assertEquals(90.0, reading.weeklyPct, 0.0);
|
||||
assertEquals(1.25, reading.spentTodayUsd, 0.0);
|
||||
assertEquals(0.99, reading.balance, 0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingRequiredValuesFallBackToUnknown()
|
||||
{
|
||||
assertNull(UsageGauge.parse("{}"));
|
||||
assertNull(UsageGauge.parse("{\"codex\":{\"h5\":{\"pct\":2},"
|
||||
+ "\"weekly\":{\"pct\":4}},\"deepseek\":{\"balance\":2}}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fillUsesLargerCodexWindowAndClampsToRange()
|
||||
{
|
||||
UsageGauge.Reading reading = UsageGauge.parse(RESPONSE);
|
||||
assertEquals(0.9f, UsageGauge.fillFraction(reading), 0.0001f);
|
||||
assertEquals(1.0f, UsageGauge.fillFraction(UsageGauge.parse(
|
||||
RESPONSE.replace("90.0", "120.0"))), 0.0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void staleDecisionIncludesAgeFailureAndMissingToken()
|
||||
{
|
||||
assertFalse(UsageGauge.isStale(1000, 1000 + UsageGauge.STALE_AFTER_MS, false, "token"));
|
||||
assertTrue(UsageGauge.isStale(1000, 1001 + UsageGauge.STALE_AFTER_MS, false, "token"));
|
||||
assertTrue(UsageGauge.isStale(1000, 1001, true, "token"));
|
||||
assertTrue(UsageGauge.isStale(1000, 1001, false, " "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowBalanceWarningIsStrictlyBelowOneDollar()
|
||||
{
|
||||
assertTrue(UsageGauge.isLowBalance(UsageGauge.parse(RESPONSE)));
|
||||
assertFalse(UsageGauge.isLowBalance(UsageGauge.parse(
|
||||
RESPONSE.replace("0.99", "1.00"))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user