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:
nyra
2026-09-27 17:38:18 +00:00
parent be6f455b01
commit 08c895aa12
12 changed files with 434 additions and 6 deletions
+1 -1
View File
@@ -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
View File
@@ -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: #, $, \, `, ~
+4 -1
View File
@@ -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>
+7
View File
@@ -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>
+3
View File
@@ -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">
+5
View File
@@ -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
+6
View File
@@ -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();
+3
View File
@@ -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
+79 -3
View File
@@ -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()
{
+264
View File
@@ -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));
}
}
+2
View File
@@ -60,6 +60,8 @@ Useful links.
<key c="j" sw="[" se="]"/>
<key c="k" sw="&quot;" 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"/>
+59
View File
@@ -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"))));
}
}