Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d790cab7a | ||
|
|
af05da4bbe | ||
|
|
e5ea6e0a41 | ||
|
|
c70a6385da | ||
|
|
2ab0cd16d8 | ||
|
|
e760e1096d | ||
|
|
74319714c0 | ||
|
|
3ebeba0619 | ||
|
|
fa094d0cb0 | ||
|
|
61e270bb3d | ||
|
|
5c0ef34196 | ||
|
|
8d9ab0c1da | ||
|
|
17b6bd4400 | ||
|
|
f56ca42b2c | ||
|
|
6eaf2ad46f | ||
|
|
7256639768 | ||
|
|
1ac7207c90 | ||
|
|
9a644f3100 | ||
|
|
45c65ed1b8 | ||
|
|
6aa15b9cc5 | ||
|
|
35a99fd95d | ||
|
|
db2f74a253 | ||
|
|
78c0b016a5 | ||
|
|
4e074b36fb | ||
|
|
b20d76e232 |
Binary file not shown.
+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: alt, loc switch_clipboard, meta, pgdn, pgup, switch_fn
|
||||
latn_neo2: Layout redefines the bottom row but some important keys are missing, missing: alt, loc switch_clipboard, meta, pgdn, pgup, switch_fn, switch_lights, switch_roku
|
||||
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: #, $, \, `, ~
|
||||
|
||||
@@ -15,6 +15,20 @@
|
||||
<attr name="colorLabelPressed" format="color"/> <!-- defaults to colorLabel -->
|
||||
<attr name="colorLabelActivated" format="color"/>
|
||||
<attr name="colorLabelLocked" format="color"/>
|
||||
<!-- The Roku page's four direction keys. They carry FLAG_SECONDARY like every
|
||||
other event key, so without a colour of their own they come out dimmed to
|
||||
secondaryLabelColor. This is the same cyan the keyboard already uses for
|
||||
"the blue" (the space bar's io-stall line, the DeepSeek off-peak indicator),
|
||||
and it is deliberately the same value in every theme. -->
|
||||
<attr name="colorRokuArrow" format="color"/>
|
||||
<!-- The lights page's colour rule: a light that is ON is BLUE, one that is OFF is PINK, and
|
||||
one nobody can see - 'unavailable', 'unknown', or no data at all - is the stale grey.
|
||||
These are the only colours the rule is written with, and colorLightOn takes the very blue
|
||||
the Roku arrows wear instead of repeating its hex, so swapping on and off is a one-line
|
||||
change here and no key holds a colour of its own. -->
|
||||
<attr name="colorLightOn" format="color"/>
|
||||
<attr name="colorLightOff" format="color"/>
|
||||
<attr name="colorLightStale" format="color"/>
|
||||
<attr name="secondaryDimming" format="float"/>
|
||||
<attr name="greyedDimming" format="float"/>
|
||||
<!-- Corner labels -->
|
||||
@@ -61,6 +75,10 @@
|
||||
<item name="clipboard_divider_height">1px</item>
|
||||
<item name="suggestions_background">?attr/colorKeyboard</item>
|
||||
<item name="suggestions_border_radius">?attr/keyBorderRadius</item>
|
||||
<item name="colorRokuArrow">#00E5FF</item>
|
||||
<item name="colorLightOn">?attr/colorRokuArrow</item>
|
||||
<item name="colorLightOff">#FF2D95</item>
|
||||
<item name="colorLightStale">#8a8d93</item>
|
||||
</style>
|
||||
<style name="Dark" parent="BaseTheme">
|
||||
<item name="android:isLightTheme">false</item>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<row height="0.95">
|
||||
<key width="1.5" role="action" key0="ctrl" key1="loc meta" key3="loc switch_clipboard"/>
|
||||
<key width="1.3" role="action" key0="alt" key2="switch_emoji" key3="config"/>
|
||||
<!-- The Alt key's four corner slots (1 top-left, 2 top-right, 3 bottom-left, 4 bottom-right):
|
||||
three are taken - the Roku page, the emoji pane and the config - so the free bottom-right one
|
||||
carries the lights page, drawn with the lamp glyph at 0xE025 (res/xml/lights.xml). Swipe
|
||||
up-right on Alt to open it. -->
|
||||
<key width="1.3" role="action" key0="alt" key1="switch_roku" key2="switch_emoji" key3="config" key4="switch_lights"/>
|
||||
<key width="1.0" role="action" key7="pgup" key8="pgdn"/>
|
||||
<key width="4.5" role="space_bar" key0="space" key7="switch_forward" key8="switch_backward" key5="cursor_left" key6="cursor_right"/>
|
||||
<key width="1.0" role="action" key7="up" key8="down"/>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The lights page. Not a user-selectable layout: it is opened by the switch_lights key (the lamp
|
||||
glyph at 0xE025, in the free bottom-right corner of the Alt key) and left with lights_close.
|
||||
|
||||
This file is only the page's skeleton: one row of three light slots, and the full-width
|
||||
"Keyboard" key that fires Event.SWITCH_TEXT, exactly like roku_close on the Roku page.
|
||||
[Keyboard2.loadLightsLayout] repeats the slot row once per row of lights the lights-proxy
|
||||
reports and replaces both values of every slot - the label with the light's name as the
|
||||
registry gives it, the top-centre corner with the state glyph (the lit lamp at 0xE025 for a
|
||||
light that is on, the unlit one at 0xE026 for everything else) - so one key per light, three to
|
||||
a row, the last row stretched if it is short. Before the proxy has answered, the three
|
||||
placeholder slots below stand as-is: a dash and the unlit lamp in the stale look, because until
|
||||
somebody names the lights there is nothing honest to show. The "light_N" names below exist only
|
||||
so this file parses; [LightsPage] replaces them.
|
||||
|
||||
Like res/xml/roku.xml it is loaded as a plain layout, NOT through LayoutModifier.modify_layout,
|
||||
so the forced config key and the user's extra keys cannot land on it: the page is its own keys
|
||||
and the way back, and nothing else.
|
||||
|
||||
Row height 1.0 (not the Roku page's 0.8) because these keys carry words, not just glyphs. -->
|
||||
<keyboard bottom_row="false">
|
||||
<row height="1.0">
|
||||
<key width="3.3" role="action" key0="light_1"/>
|
||||
<key width="3.3" role="action" key0="light_2"/>
|
||||
<key width="3.3" role="action" key0="light_3"/>
|
||||
</row>
|
||||
<row height="1.0">
|
||||
<key width="9.9" role="action" key0="lights_close"/>
|
||||
</row>
|
||||
</keyboard>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Roku remote page. Not a user-selectable layout: it is opened by the
|
||||
switch_roku key and left with roku_close. Every key above the last row sends
|
||||
an HTTP keypress to the TV (see RokuRemote) instead of typing.
|
||||
|
||||
This page carries nothing but remote keys. It is loaded as a plain layout
|
||||
(Keyboard2.loadRokuLayout), NOT through LayoutModifier.modify_layout, so the
|
||||
forced config key and the configured extra keys do not land on it and do not
|
||||
fill the empty corner slots of these keys. That is why the last row is a
|
||||
full-width "Keyboard" key instead of the config gear: it fires the same
|
||||
Event.SWITCH_TEXT as the switch_text key on other pages and is the way back
|
||||
to the letter keyboard.
|
||||
|
||||
Vol- is also the page's mute: holding it sends mute instead of volume-down
|
||||
(KeyModifier.modify_long_press), so mute is on the page without a slot. -->
|
||||
<keyboard bottom_row="false">
|
||||
<row height="0.8">
|
||||
<key width="3.3" role="action" key0="roku_home"/>
|
||||
<key width="3.3" role="action" key0="roku_back"/>
|
||||
<key width="3.3" role="action" key0="roku_power"/>
|
||||
</row>
|
||||
<row height="0.8">
|
||||
<key shift="3.3" width="3.3" role="action" key0="roku_up"/>
|
||||
</row>
|
||||
<row height="0.8">
|
||||
<key width="3.3" role="action" key0="roku_left"/>
|
||||
<key width="3.3" role="action" key0="roku_select"/>
|
||||
<key width="3.3" role="action" key0="roku_right"/>
|
||||
</row>
|
||||
<row height="0.8">
|
||||
<key shift="3.3" width="3.3" role="action" key0="roku_down"/>
|
||||
</row>
|
||||
<row height="0.8">
|
||||
<key width="3.3" role="action" key0="roku_voldown"/>
|
||||
<key width="3.3" role="action" key0="roku_play"/>
|
||||
<key width="3.3" role="action" key0="roku_volup"/>
|
||||
</row>
|
||||
<row height="1.0">
|
||||
<key width="9.9" role="action" key0="roku_close"/>
|
||||
</row>
|
||||
</keyboard>
|
||||
@@ -15,12 +15,15 @@ import java.net.URL;
|
||||
public final class HostGraph
|
||||
{
|
||||
/** Payload series order. io leads: a stalled disk is the thing worth seeing. */
|
||||
public static final String[] SERIES = { "io", "cpu" };
|
||||
public static final String[] SERIES = { "io", "cpu", "util" };
|
||||
/** A sample older than this is not worth drawing as if it were now. */
|
||||
/** Safety net only: with the 1 Hz re-arm this never trips, but if fetches stop the ribbon
|
||||
blanks rather than freezing mid-thought. */
|
||||
public static final long MAX_AGE_MS = 15000L;
|
||||
private static final int CONNECT_TIMEOUT_MS = 2500;
|
||||
private static final int READ_TIMEOUT_MS = 2500;
|
||||
private static final long MIN_INTERVAL_MS = 2000L;
|
||||
/** Matches the server's 1 Hz sampling; only ever called while the keyboard is visible. */
|
||||
private static final long MIN_INTERVAL_MS = 900L;
|
||||
private static final int DEFAULT_N = 60;
|
||||
|
||||
private static final Object _lock = new Object();
|
||||
@@ -52,8 +55,19 @@ public final class HostGraph
|
||||
}
|
||||
}
|
||||
|
||||
/** The server root, so the ribbon rides the same preference as the usage endpoint. */
|
||||
static String baseOf(String url)
|
||||
{
|
||||
if (url == null)
|
||||
return "";
|
||||
int scheme = url.indexOf("://");
|
||||
int from = scheme < 0 ? 0 : scheme + 3;
|
||||
int slash = url.indexOf('/', from);
|
||||
return slash < 0 ? url : url.substring(0, slash);
|
||||
}
|
||||
|
||||
/** Fetch at most every couple of seconds; call freely while the keyboard is visible. */
|
||||
public static void fetchIfNeeded(String baseUrl, String token)
|
||||
public static void fetchIfNeeded(String baseUrl, String token, Runnable onDone)
|
||||
{
|
||||
long now = System.currentTimeMillis();
|
||||
synchronized (_lock)
|
||||
@@ -75,6 +89,11 @@ public final class HostGraph
|
||||
finally
|
||||
{
|
||||
synchronized (_lock) { _inFlight = false; }
|
||||
if (onDone != null)
|
||||
{
|
||||
try { onDone.run(); }
|
||||
catch (Throwable t) { /* a repaint request must never take the keyboard down */ }
|
||||
}
|
||||
}
|
||||
}, "host-graph");
|
||||
worker.setDaemon(true);
|
||||
|
||||
@@ -112,6 +112,13 @@ public final class KeyModifier
|
||||
return KeyValue.CHANGE_METHOD;
|
||||
case SWITCH_VOICE_TYPING:
|
||||
return KeyValue.VOICE_TYPING_CHOOSER;
|
||||
/* The Roku page's volume-down key: held, it is the TV's mute instead of
|
||||
volume-down. This is the same path the two keys above take, so [Pointers]
|
||||
replaces the pointer's value while the finger is still down and the single
|
||||
[key_up] on release carries the mute - the short press is not sent as well.
|
||||
Mute therefore lives on the page without taking a slot of its own. */
|
||||
case ROKU_VOLDOWN:
|
||||
return KeyValue.ROKU_VOLUME_MUTE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,40 @@ public final class KeyValue implements Comparable<KeyValue>
|
||||
SWITCH_VOICE_TYPING,
|
||||
SWITCH_VOICE_TYPING_CHOOSER,
|
||||
HIDE_SELF,
|
||||
/* The Roku remote page and the keys on it. These are appended: the ordinals are
|
||||
in-process only, so adding at the end never renumbers an existing key. */
|
||||
SWITCH_ROKU,
|
||||
ROKU_HOME,
|
||||
ROKU_BACK,
|
||||
ROKU_UP,
|
||||
ROKU_DOWN,
|
||||
ROKU_LEFT,
|
||||
ROKU_RIGHT,
|
||||
ROKU_SELECT,
|
||||
ROKU_PLAY,
|
||||
ROKU_REWIND,
|
||||
ROKU_FORWARD,
|
||||
ROKU_POWER,
|
||||
ROKU_VOLUP,
|
||||
ROKU_VOLDOWN,
|
||||
ROKU_VOLMUTE,
|
||||
/* The lights page (res/xml/lights.xml) and its keys. Appended for the same reason as the Roku
|
||||
events above: the ordinals only mean anything in-process. LIGHT_1..LIGHT_12 are the page's
|
||||
slots - one per light, in the order the proxy lists them - and SWITCH_LIGHTS is the door to
|
||||
the page itself. */
|
||||
SWITCH_LIGHTS,
|
||||
LIGHT_1,
|
||||
LIGHT_2,
|
||||
LIGHT_3,
|
||||
LIGHT_4,
|
||||
LIGHT_5,
|
||||
LIGHT_6,
|
||||
LIGHT_7,
|
||||
LIGHT_8,
|
||||
LIGHT_9,
|
||||
LIGHT_10,
|
||||
LIGHT_11,
|
||||
LIGHT_12,
|
||||
}
|
||||
|
||||
// Must be evaluated in the reverse order of their values.
|
||||
@@ -494,6 +528,26 @@ public final class KeyValue implements Comparable<KeyValue>
|
||||
return eventKey(symbol, Event.ACTION, FLAG_SMALLER_FONT);
|
||||
}
|
||||
|
||||
/** A key on the lights page: one light, labelled with the name the proxy reported for it, already
|
||||
shortened to fit the key (see [LightsPage.displayName]). The label travels in the key value,
|
||||
so the page shows exactly the names the box gave us, and the state colour is applied when the
|
||||
key is drawn ([LightsPage.colorFor]). */
|
||||
public static KeyValue lightKey(String label, Event ev)
|
||||
{
|
||||
return eventKey(label, ev, FLAG_SMALLER_FONT);
|
||||
}
|
||||
|
||||
/** The state glyph of a lights key, drawn in the corner above the light's name. It carries
|
||||
FLAG_KEY_FONT - it is a glyph, not text - and that is what tells it apart from the name on the
|
||||
same key, so [LightsPage.isStateGlyph] can pick it out. Which of the two lamps is drawn is
|
||||
decided when the key is drawn, not here: a press or a poll changes the state under the key
|
||||
without the page being rebuilt. The value stored is the unlit lamp, so that anything that
|
||||
draws this key without asking for the state (a fallback, a preview) never shows a light on. */
|
||||
public static KeyValue lightGlyphKey(Event ev)
|
||||
{
|
||||
return eventKey(0xE026, ev, FLAG_KEY_FONT);
|
||||
}
|
||||
|
||||
/** Make a key that types a string. A char key is returned for a string of
|
||||
length 1. */
|
||||
public static KeyValue makeStringKey(String str, int flags)
|
||||
@@ -544,6 +598,10 @@ public final class KeyValue implements Comparable<KeyValue>
|
||||
public static final KeyValue CHANGE_METHOD_PREV = eventKey(0xE009, Event.CHANGE_METHOD_PREV, FLAG_SMALLER_FONT);
|
||||
public static final KeyValue CHANGE_METHOD_NEXT = eventKey(0xE009, Event.CHANGE_METHOD_NEXT, FLAG_SMALLER_FONT);
|
||||
public static final KeyValue VOICE_TYPING_CHOOSER = eventKey(0xE015, Event.SWITCH_VOICE_TYPING_CHOOSER, FLAG_SMALLER_FONT);
|
||||
/** The Roku page's mute key. It is also what a long press on the page's volume-down key
|
||||
sends (see [KeyModifier.modify_long_press]), so mute lives on the page without
|
||||
spending a slot on it; both uses share this one key. */
|
||||
public static final KeyValue ROKU_VOLUME_MUTE = eventKey("Mute", Event.ROKU_VOLMUTE, FLAG_SMALLER_FONT);
|
||||
public static final KeyValue COMPOSE_CANCEL = placeholderKey(0xE01A, Placeholder.COMPOSE_CANCEL, FLAG_SECONDARY);
|
||||
|
||||
public static KeyValue getSpecialKeyByName(String name)
|
||||
@@ -652,6 +710,42 @@ public final class KeyValue implements Comparable<KeyValue>
|
||||
case "switch_forward": return eventKey(0xE013, Event.SWITCH_FORWARD, FLAG_SMALLER_FONT);
|
||||
case "switch_backward": return eventKey(0xE014, Event.SWITCH_BACKWARD, FLAG_SMALLER_FONT);
|
||||
case "switch_greekmath": return eventKey("πλ∇¬", Event.SWITCH_GREEKMATH, FLAG_SMALLER_FONT);
|
||||
/* Opens the Roku remote page (res/xml/roku.xml). The glyph is the remote drawn
|
||||
at 0xE024 in the special font. */
|
||||
case "switch_roku": return eventKey(0xE024, Event.SWITCH_ROKU, FLAG_SMALLER_FONT);
|
||||
/* The remote's own keys. Labels only: the key sends an HTTP request instead of
|
||||
typing (see RokuRemote). */
|
||||
case "roku_home": return eventKey("Home", Event.ROKU_HOME, FLAG_SMALLER_FONT);
|
||||
case "roku_back": return eventKey("Back", Event.ROKU_BACK, FLAG_SMALLER_FONT);
|
||||
case "roku_up": return eventKey(0xE005, Event.ROKU_UP, FLAG_SMALLER_FONT);
|
||||
case "roku_down": return eventKey(0xE007, Event.ROKU_DOWN, FLAG_SMALLER_FONT);
|
||||
case "roku_left": return eventKey(0xE008, Event.ROKU_LEFT, FLAG_SMALLER_FONT);
|
||||
case "roku_right": return eventKey(0xE006, Event.ROKU_RIGHT, FLAG_SMALLER_FONT);
|
||||
case "roku_select": return eventKey("OK", Event.ROKU_SELECT, FLAG_SMALLER_FONT);
|
||||
case "roku_play": return eventKey("Play", Event.ROKU_PLAY, FLAG_SMALLER_FONT);
|
||||
case "roku_rewind": return eventKey("Rewind", Event.ROKU_REWIND, FLAG_SMALLER_FONT);
|
||||
case "roku_forward": return eventKey("Forward", Event.ROKU_FORWARD, FLAG_SMALLER_FONT);
|
||||
case "roku_power": return eventKey("Power", Event.ROKU_POWER, FLAG_SMALLER_FONT);
|
||||
/* The TV's volume, same ECP path as the rest of the page (RokuRemote sends the
|
||||
VolumeUp/VolumeDown/VolumeMute keypresses Harley's own web remote uses). */
|
||||
case "roku_volup": return eventKey("Vol+", Event.ROKU_VOLUP, FLAG_SMALLER_FONT);
|
||||
case "roku_voldown": return eventKey("Vol-", Event.ROKU_VOLDOWN, FLAG_SMALLER_FONT);
|
||||
case "roku_volmute": return ROKU_VOLUME_MUTE;
|
||||
/* The way back from the Roku page. The same event as "switch_text", which is
|
||||
unchanged, but labelled: the page has no config key, so this is the only exit
|
||||
and "ABC" does not read as one on a page full of remote keys. */
|
||||
case "roku_close": return eventKey("Keyboard", Event.SWITCH_TEXT, FLAG_SMALLER_FONT);
|
||||
/* The lights page (res/xml/lights.xml). The door is the lamp drawn at 0xE025 in the special
|
||||
font, in a corner slot of the Alt key. The three "light_N" names are the template's slot
|
||||
row: [LightsPage.build] repeats that row once per row of lights and replaces both values of
|
||||
each slot - the label with a light's name and the corner with its state glyph - so the
|
||||
value here only has to exist for the XML to parse. Which lights those slots are is decided
|
||||
by the proxy's answer, never by a name written down here. */
|
||||
case "switch_lights": return eventKey(0xE025, Event.SWITCH_LIGHTS, FLAG_SMALLER_FONT);
|
||||
case "lights_close": return eventKey("Keyboard", Event.SWITCH_TEXT, FLAG_SMALLER_FONT);
|
||||
case "light_1": return eventKey(LightsPage.PLACEHOLDER, Event.LIGHT_1, FLAG_SMALLER_FONT);
|
||||
case "light_2": return eventKey(LightsPage.PLACEHOLDER, Event.LIGHT_2, FLAG_SMALLER_FONT);
|
||||
case "light_3": return eventKey(LightsPage.PLACEHOLDER, Event.LIGHT_3, FLAG_SMALLER_FONT);
|
||||
case "change_method": return CHANGE_METHOD;
|
||||
case "change_method_prev": return CHANGE_METHOD_PREV;
|
||||
case "change_method_next": return CHANGE_METHOD_NEXT;
|
||||
|
||||
@@ -35,6 +35,58 @@ import juloo.keyboard2.suggestions.Suggestions;
|
||||
public class Keyboard2 extends InputMethodService
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener
|
||||
{
|
||||
/** The ribbon follows the server's 1 Hz sampling, so it crawls instead of jumping. */
|
||||
private static final long HOST_GRAPH_REFRESH_MS = 1000L;
|
||||
|
||||
/** Re-armed only while the keyboard is on screen, so hiding it stops the polling dead. */
|
||||
private final Runnable _hostGraphRefresh = new Runnable()
|
||||
{
|
||||
public void run()
|
||||
{
|
||||
if (!_keyboard_layout_view.isShown())
|
||||
return;
|
||||
if (_config.usageGaugeEnabled)
|
||||
HostGraph.fetchIfNeeded(HostGraph.baseOf(_config.usageGaugeUrl), _config.usageGaugeToken,
|
||||
() -> _handler.post(() -> _keyboard_layout_view.invalidate()));
|
||||
_handler.postDelayed(this, HOST_GRAPH_REFRESH_MS);
|
||||
}
|
||||
};
|
||||
/** The lights page (res/xml/lights.xml): a page of its own, opened by the switch_lights key.
|
||||
Non-null means it is the page on screen, and only then is [LightsProxy] asked for anything -
|
||||
once per [LightsProxy.POLL_MS], because a light changes rarely and the usage gauge's 1 Hz
|
||||
would be absurd here. */
|
||||
private KeyboardData _lightsLayout = null;
|
||||
/** The [LightsProxy.version] the open lights page was built from. A different version means a
|
||||
light was added, removed or renamed under the page, so it has to be rebuilt. */
|
||||
private int _lightsVersion = -1;
|
||||
|
||||
/** Re-armed only while the lights page is on screen, so closing it stops the polling dead. */
|
||||
private final Runnable _lightsRefresh = new Runnable()
|
||||
{
|
||||
public void run()
|
||||
{
|
||||
if (_lightsLayout == null || !_keyboard_layout_view.isShown())
|
||||
return;
|
||||
LightsProxy.fetchIfNeeded(false, () -> _handler.post(Keyboard2.this::onLightsLoaded));
|
||||
_handler.postDelayed(this, LightsProxy.POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
/** Called on the main thread when a lights read or a press has finished: repaint, and rebuild
|
||||
the page if the light list itself changed. */
|
||||
void onLightsLoaded()
|
||||
{
|
||||
if (_keyboard_layout_view == null)
|
||||
return;
|
||||
if (_lightsLayout != null && _lightsVersion != LightsProxy.version())
|
||||
{
|
||||
_lightsVersion = LightsProxy.version();
|
||||
_lightsLayout = loadLightsLayout();
|
||||
_keyboard_layout_view.setKeyboard(_lightsLayout);
|
||||
}
|
||||
_keyboard_layout_view.invalidate();
|
||||
}
|
||||
|
||||
/** The view containing the keyboard and candidates view. */
|
||||
private ViewGroup _keyboard_container_view;
|
||||
private Keyboard2View _keyboard_layout_view;
|
||||
@@ -93,6 +145,7 @@ public class Keyboard2 extends InputMethodService
|
||||
{
|
||||
_config.set_current_layout(l);
|
||||
_currentSpecialLayout = null;
|
||||
_lightsLayout = null; // the lights page is not the page any more
|
||||
_keyboard_layout_view.setKeyboard(current_layout());
|
||||
}
|
||||
|
||||
@@ -104,6 +157,9 @@ public class Keyboard2 extends InputMethodService
|
||||
|
||||
void setSpecialLayout(KeyboardData l)
|
||||
{
|
||||
/* Any other page replaces the lights page, and only the lights page polls. */
|
||||
if (l != _lightsLayout)
|
||||
_lightsLayout = null;
|
||||
_currentSpecialLayout = l;
|
||||
_keyboard_layout_view.setKeyboard(l);
|
||||
}
|
||||
@@ -132,6 +188,27 @@ public class Keyboard2 extends InputMethodService
|
||||
KeyboardData.load(getResources(), R.xml.fn));
|
||||
}
|
||||
|
||||
/** The Roku remote: a whole page of keys that send commands instead of typing.
|
||||
Loaded as a plain layout ([loadLayout]), deliberately NOT through
|
||||
[LayoutModifier.modify_layout]: the page is nothing but remote keys, so the
|
||||
config key the modifier force-adds and the extra keys it drops into the free
|
||||
corner slots must stay off it. The page's own 'roku_close' key
|
||||
(Event.SWITCH_TEXT, labelled 'Keyboard') is the way back to the text layout. */
|
||||
KeyboardData loadRokuLayout()
|
||||
{
|
||||
return loadLayout(R.xml.roku);
|
||||
}
|
||||
|
||||
/** The lights page: the skeleton in res/xml/lights.xml with one key per light the proxy last
|
||||
reported (see [LightsPage.build]). Loaded as a plain layout for the same reason as the Roku
|
||||
page: the page is its own keys and the way back, so the config key and the user's extra keys
|
||||
must stay off it. It is rebuilt - not modified - when the light list changes, which is why
|
||||
each light's name and slot live in its key values. */
|
||||
KeyboardData loadLightsLayout()
|
||||
{
|
||||
return LightsPage.build(loadLayout(R.xml.lights), LightsProxy.lights());
|
||||
}
|
||||
|
||||
KeyboardData loadPinentry(int layout_id)
|
||||
{
|
||||
return LayoutModifier.modify_pinentry(KeyboardData.load(getResources(), layout_id),
|
||||
@@ -271,6 +348,7 @@ public class Keyboard2 extends InputMethodService
|
||||
_config.editor_config.refresh(info, getResources());
|
||||
refresh_config();
|
||||
_currentSpecialLayout = refresh_special_layout();
|
||||
_lightsLayout = null; // a fresh input session starts on a text layout
|
||||
_keyboard_layout_view.setKeyboard(current_layout());
|
||||
_keyeventhandler.started(_config);
|
||||
setInputView(_keyboard_container_view);
|
||||
@@ -280,6 +358,11 @@ public class Keyboard2 extends InputMethodService
|
||||
if (_config.usageGaugeEnabled)
|
||||
UsageGauge.fetchIfNeeded(_config.usageGaugeUrl, _config.usageGaugeToken,
|
||||
() -> _handler.post(() -> _keyboard_layout_view.invalidate()));
|
||||
if (_config.usageGaugeEnabled)
|
||||
HostGraph.fetchIfNeeded(HostGraph.baseOf(_config.usageGaugeUrl), _config.usageGaugeToken,
|
||||
() -> _handler.post(() -> _keyboard_layout_view.invalidate()));
|
||||
_handler.removeCallbacks(_hostGraphRefresh);
|
||||
_handler.postDelayed(_hostGraphRefresh, HOST_GRAPH_REFRESH_MS);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -441,6 +524,7 @@ public class Keyboard2 extends InputMethodService
|
||||
|
||||
case SWITCH_TEXT:
|
||||
_currentSpecialLayout = null;
|
||||
_lightsLayout = null; // the way back from the lights page: stop its polling
|
||||
_keyboard_layout_view.setKeyboard(current_layout());
|
||||
break;
|
||||
|
||||
@@ -452,6 +536,43 @@ public class Keyboard2 extends InputMethodService
|
||||
setSpecialLayout(loadFnLayout());
|
||||
break;
|
||||
|
||||
case SWITCH_ROKU:
|
||||
setSpecialLayout(loadRokuLayout());
|
||||
break;
|
||||
|
||||
case SWITCH_LIGHTS:
|
||||
/* Opening the page asks at once - its keys are drawn from the last snapshot and this is
|
||||
the moment to make it current - and then re-arms the slow poll. */
|
||||
_lightsVersion = LightsProxy.version();
|
||||
_lightsLayout = loadLightsLayout();
|
||||
setSpecialLayout(_lightsLayout);
|
||||
LightsProxy.fetchIfNeeded(true, () -> _handler.post(() -> onLightsLoaded()));
|
||||
_handler.removeCallbacks(_lightsRefresh);
|
||||
_handler.postDelayed(_lightsRefresh, LightsProxy.POLL_MS);
|
||||
break;
|
||||
|
||||
/* One key per light. Like the Roku keys, a press only hands the work to a worker thread
|
||||
and returns: the POST waits for Home Assistant to settle, which is seconds, and
|
||||
[handle_event_key] runs on the main thread. A press is never retried (see
|
||||
[LightsProxy.toggle]), and a second press on a light whose toggle is still in flight is
|
||||
refused - that would flip the light back exactly as a retry would. */
|
||||
case LIGHT_1: case LIGHT_2: case LIGHT_3: case LIGHT_4:
|
||||
case LIGHT_5: case LIGHT_6: case LIGHT_7: case LIGHT_8:
|
||||
case LIGHT_9: case LIGHT_10: case LIGHT_11: case LIGHT_12:
|
||||
LightsProxy.toggle(LightsPage.slotOf(ev), () -> _handler.post(() -> onLightsLoaded()));
|
||||
break;
|
||||
|
||||
/* Roku remote commands. handle_event_key runs on the main thread, so these
|
||||
only hand the key to RokuRemote and return: the POST happens on a worker
|
||||
thread and a failure there is nobody's problem but its own. */
|
||||
case ROKU_HOME: case ROKU_BACK:
|
||||
case ROKU_UP: case ROKU_DOWN: case ROKU_LEFT: case ROKU_RIGHT:
|
||||
case ROKU_SELECT: case ROKU_PLAY: case ROKU_REWIND: case ROKU_FORWARD:
|
||||
case ROKU_VOLUP: case ROKU_VOLDOWN: case ROKU_VOLMUTE:
|
||||
case ROKU_POWER:
|
||||
RokuRemote.press(ev);
|
||||
break;
|
||||
|
||||
case SWITCH_EMOJI:
|
||||
if (_emojiPane == null)
|
||||
_emojiPane = (ViewGroup)inflate_view(R.layout.emoji_pane);
|
||||
|
||||
@@ -47,6 +47,8 @@ public class Keyboard2View extends View
|
||||
/** Long-pressing the usage gauge key opens the usage page; a tap stays inert. */
|
||||
private final Handler _gaugeHandler = new Handler(Looper.getMainLooper());
|
||||
private Runnable _gaugeLongPress;
|
||||
/** Its own paint, so the ribbon cannot disturb whatever the key drawing left configured. */
|
||||
private final Paint _ribbonPaint = new Paint();
|
||||
private boolean _gaugePressed;
|
||||
|
||||
private static int _currentWhat = 0;
|
||||
@@ -68,7 +70,12 @@ public class Keyboard2View extends View
|
||||
|
||||
private static RectF _tmpRect = new RectF();
|
||||
private static final Paint GAUGE_PAINT = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
/** The ring around the spend number gets its own paint, so the number's own settings and its
|
||||
halo cannot be disturbed by whatever the ring leaves configured. */
|
||||
private static final Paint GAUGE_RING_PAINT = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private static final Path GAUGE_PATH = new Path();
|
||||
/** Reused for measuring the number. Nothing on the draw path allocates. */
|
||||
private static final Rect _gaugeTextBounds = new Rect();
|
||||
|
||||
enum Vertical
|
||||
{
|
||||
@@ -376,8 +383,13 @@ public class Keyboard2View extends View
|
||||
x += k.shift * _keyWidth;
|
||||
float keyW = _keyWidth * k.width - _tc.horizontal_margin;
|
||||
boolean isKeyDown = _pointers.isKeyDown(k);
|
||||
// A key whose Roku command has been sent wears the pressed look until the TV
|
||||
// answers, so a slow command does not look like a dead key. Same colours as a
|
||||
// real press (key_activated + the activated label), which is the only pair the
|
||||
// theme guarantees to be readable together.
|
||||
boolean isSent = RokuRemote.isLit(k.keys[0], System.currentTimeMillis());
|
||||
Theme.Computed.Key tc_key;
|
||||
if (isKeyDown)
|
||||
if (isKeyDown || isSent)
|
||||
tc_key = _tc.key_activated;
|
||||
else
|
||||
switch (k.role)
|
||||
@@ -389,6 +401,8 @@ public class Keyboard2View extends View
|
||||
case Normal: tc_key = _tc.key; break;
|
||||
}
|
||||
drawKeyFrame(canvas, x, y, keyW, keyH, tc_key);
|
||||
if (k.role == KeyboardData.Key.Role.Space_bar)
|
||||
drawHostRibbon(canvas, x, y, keyW, keyH);
|
||||
if (UsageGauge.isGaugeKey(k))
|
||||
drawUsageGauge(canvas, x, y, keyW, keyH, tc_key);
|
||||
else if (k.keys[0] != null)
|
||||
@@ -450,6 +464,131 @@ public class Keyboard2View extends View
|
||||
}
|
||||
}
|
||||
|
||||
/** Two smooth lanes along the bottom of the space bar: cpu utilisation as a filled neon wave,
|
||||
the disk stall as a cyan line riding the same curve. Columns became a curve because a row
|
||||
of separate bars read as a bar chart; the ask was a colour field, like a picker.
|
||||
*
|
||||
* Nothing at all is drawn without data: a missing ribbon is the honest "cannot reach Utumno",
|
||||
* while a live-but-idle host still draws its baseline, so blank is never read as zero. */
|
||||
/** Cached so the draw path allocates nothing: it only changes when the key geometry does. */
|
||||
/** Where the ribbon reaches the theme's pink; lower starts the colour change sooner. */
|
||||
private static final float RIBBON_PINK_AT = 0.3f;
|
||||
|
||||
/** Reused every frame so the smooth wave never allocates on the draw path. */
|
||||
private final android.graphics.Path _ribbonPath = new android.graphics.Path();
|
||||
private android.graphics.Shader _ribbonShader;
|
||||
private float _ribbonShaderBottom = -1f;
|
||||
private float _ribbonShaderHeight = -1f;
|
||||
|
||||
/** The ribbon's own ramp in the keyboard's colours: orchid violet at rest, through the
|
||||
theme's pink at half, to the hot pink that peaks the space bar glyph when the box is flat out. */
|
||||
private static int ribbonColor(float level)
|
||||
{
|
||||
level = Math.max(0f, Math.min(1f, level));
|
||||
if (level < RIBBON_PINK_AT)
|
||||
// 0xFFC67DFF is the theme's own alternate violet: same family, but its luma is up with
|
||||
// the cyan stall line, so a low-utilisation wave still reads instead of looking dim.
|
||||
return ribbonLerp(0xFFC67DFF, 0xFFFF2D95, level / RIBBON_PINK_AT);
|
||||
return ribbonLerp(0xFFFF2D95, 0xFFFF2FF0, (level - RIBBON_PINK_AT) / (1f - RIBBON_PINK_AT));
|
||||
}
|
||||
|
||||
private static int ribbonLerp(int from, int to, float t)
|
||||
{
|
||||
int r = (int) ((from >> 16 & 0xFF) + (((to >> 16 & 0xFF) - (from >> 16 & 0xFF)) * t));
|
||||
int g = (int) ((from >> 8 & 0xFF) + (((to >> 8 & 0xFF) - (from >> 8 & 0xFF)) * t));
|
||||
int b = (int) ((from & 0xFF) + (((to & 0xFF) - (from & 0xFF)) * t));
|
||||
return 0xFF000000 | r << 16 | g << 8 | b;
|
||||
}
|
||||
|
||||
private void drawHostRibbon(Canvas canvas, float x, float y, float keyW, float keyH)
|
||||
{
|
||||
if (!HostGraph.hasData())
|
||||
return;
|
||||
float[] util = HostGraph.series(2);
|
||||
if (util.length == 0)
|
||||
return;
|
||||
float left = x + _tc.horizontal_margin;
|
||||
float right = x + keyW - _tc.horizontal_margin;
|
||||
float width = right - left;
|
||||
if (width <= 4f)
|
||||
return;
|
||||
float bottom = y + keyH - 1f;
|
||||
// One gradient the whole way up, keyed to *height* rather than to each sample's own value, so
|
||||
// a taller wave reveals more of the ramp. Built once per key geometry.
|
||||
if (_ribbonShader == null || _ribbonShaderBottom != bottom || _ribbonShaderHeight != keyH)
|
||||
{
|
||||
_ribbonShader = new android.graphics.LinearGradient(0f, bottom, 0f, bottom - keyH,
|
||||
new int[] { ribbonColor(0f), ribbonColor(RIBBON_PINK_AT), ribbonColor(1f) },
|
||||
new float[] { 0f, RIBBON_PINK_AT, 1f }, android.graphics.Shader.TileMode.CLAMP);
|
||||
_ribbonShaderBottom = bottom;
|
||||
_ribbonShaderHeight = keyH;
|
||||
}
|
||||
_ribbonPaint.setAntiAlias(true);
|
||||
_ribbonPaint.setShader(_ribbonShader);
|
||||
_ribbonPaint.setStyle(android.graphics.Paint.Style.FILL);
|
||||
buildWave(_ribbonPath, util, left, width, bottom, keyH, true);
|
||||
canvas.drawPath(_ribbonPath, _ribbonPaint);
|
||||
// Second lane: how much of each second was spent *waiting* on the disk. Utilisation says
|
||||
// "busy"; this says "stuck", and only this one tells the two apart.
|
||||
float[] stall = HostGraph.series(0);
|
||||
if (stall.length > 0)
|
||||
{
|
||||
_ribbonPaint.setShader(null);
|
||||
_ribbonPaint.setColor(0xFF00E5FF);
|
||||
_ribbonPaint.setStyle(android.graphics.Paint.Style.STROKE);
|
||||
_ribbonPaint.setStrokeWidth(Math.max(2f, keyH * 0.03f));
|
||||
_ribbonPaint.setStrokeCap(android.graphics.Paint.Cap.ROUND);
|
||||
_ribbonPaint.setStrokeJoin(android.graphics.Paint.Join.ROUND);
|
||||
buildWave(_ribbonPath, stall, left, width, bottom, keyH, false);
|
||||
canvas.drawPath(_ribbonPath, _ribbonPaint);
|
||||
_ribbonPaint.setStyle(android.graphics.Paint.Style.FILL);
|
||||
}
|
||||
// The baseline says "connected". Without it a blank space bar would read as zero load.
|
||||
_ribbonPaint.setStrokeWidth(1f);
|
||||
_ribbonPaint.setColor(0x55FFFFFF);
|
||||
canvas.drawRect(left, bottom - 1f, right, bottom, _ribbonPaint);
|
||||
}
|
||||
|
||||
/** A smooth curve through the samples, filled to the bottom when `area`.
|
||||
*
|
||||
* Quadratic segments whose control points are the samples and whose ends are the midpoints:
|
||||
* that is what removes the corner at each sample and turns a row of columns into one wave. */
|
||||
private static void buildWave(android.graphics.Path path, float[] series, float left, float width,
|
||||
float bottom, float full, boolean area)
|
||||
{
|
||||
int n = series.length;
|
||||
if (n == 0)
|
||||
return;
|
||||
path.reset();
|
||||
float dx = n > 1 ? width / (n - 1) : width;
|
||||
float px = left;
|
||||
float py = bottom - ribbonLevel(series[0]) * full;
|
||||
path.moveTo(px, py);
|
||||
if (area)
|
||||
path.lineTo(px, bottom);
|
||||
for (int i = 1; i < n; i++)
|
||||
{
|
||||
float cx = left + i * dx;
|
||||
float cy = bottom - ribbonLevel(series[i]) * full;
|
||||
if (i < n - 1)
|
||||
path.quadTo(px, py, (px + cx) / 2f, (py + cy) / 2f);
|
||||
else
|
||||
path.quadTo(px, py, cx, cy);
|
||||
px = cx;
|
||||
py = cy;
|
||||
}
|
||||
if (area)
|
||||
{
|
||||
path.lineTo(left + width, bottom);
|
||||
path.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static float ribbonLevel(float v)
|
||||
{
|
||||
return Math.max(0f, Math.min(100f, v)) / 100f;
|
||||
}
|
||||
|
||||
private void drawUsageGauge(Canvas canvas, float x, float y, float keyW,
|
||||
float keyH, Theme.Computed.Key tc)
|
||||
{
|
||||
@@ -496,9 +635,19 @@ public class Keyboard2View extends View
|
||||
(reading.partial ? 0xff92949a : _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;
|
||||
// The ring is the only part of this key that can carry the low-balance warning as a
|
||||
// colour: the number itself is a stroked outline whose colour comes from luminance
|
||||
// so it stays legible over the ramped fill, which means a red label has nowhere
|
||||
// else to show and only flips that outline white.
|
||||
drawGaugeRing(canvas, label, bounds, size, y, keyH,
|
||||
UsageGauge.isLowBalance(reading) ? 0xffe53935 : _theme.subLabelColor);
|
||||
// The outline is derived from the theme's own label colour and deliberately NOT from
|
||||
// labelColor: otherwise a low balance would flip this outline white, which changes how
|
||||
// the number looks. The ring carries the red; the number keeps its normal appearance.
|
||||
int outlineBase = stale || reading.partial ? 0xff92949a : _theme.labelColor;
|
||||
int luminance = (android.graphics.Color.red(outlineBase) * 299
|
||||
+ android.graphics.Color.green(outlineBase) * 587
|
||||
+ android.graphics.Color.blue(outlineBase) * 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);
|
||||
@@ -512,6 +661,52 @@ public class Keyboard2View extends View
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
/** A ring around the spend number, in the theme's alternate violet.
|
||||
*
|
||||
* [GAUGE_PAINT] must already carry the label's size and typeface, and [bounds], [y], [keyH] the
|
||||
* key's own geometry: the radius, the centre and the baseline are all measured from those rather
|
||||
* than assumed, so the ring wraps the glyphs the device's font actually draws.
|
||||
*
|
||||
* The radius is the ink box's half-diagonal plus the gap, so the gap is exact at the ink's
|
||||
* corners - the furthest ink from the centre - and larger everywhere else. Centring on the ink
|
||||
* box's own middle rather than on the line box matters for digits, which have no descender: the
|
||||
* line box would pull the ring down by half a descent and eat the gap above the number.
|
||||
*
|
||||
* The dark casing under the coloured stroke is the trick the number's own halo already uses, and
|
||||
* for the same reason: the fill behind it runs from cyan or violet through amber to red, and a
|
||||
* lone violet stroke would disappear into part of that ramp. */
|
||||
private void drawGaugeRing(Canvas canvas, String label, RectF bounds, float size,
|
||||
float y, float keyH, int ringColor)
|
||||
{
|
||||
GAUGE_PAINT.getTextBounds(label, 0, label.length(), _gaugeTextBounds);
|
||||
float halfW = _gaugeTextBounds.width() / 2f;
|
||||
float halfH = _gaugeTextBounds.height() / 2f;
|
||||
// Align.CENTER centres the advance, not the ink: recentre on the ink box's own middle.
|
||||
float cx = bounds.centerX() - GAUGE_PAINT.measureText(label) / 2f
|
||||
+ (_gaugeTextBounds.left + _gaugeTextBounds.right) / 2f;
|
||||
// The number's own baseline, plus the ink box's own middle: see the comment above.
|
||||
float cy = (keyH - GAUGE_PAINT.ascent() - GAUGE_PAINT.descent()) / 2f + y
|
||||
+ (_gaugeTextBounds.top + _gaugeTextBounds.bottom) / 2f;
|
||||
float gap = Math.max(1.5f, size * 0.08f);
|
||||
float stroke = Math.max(1.5f, size * 0.07f);
|
||||
float casing = Math.max(1f, size * 0.02f);
|
||||
float radius = (float)Math.sqrt(halfW * halfW + halfH * halfH) + gap + casing + stroke / 2f;
|
||||
// A ring squeezed inwards would cut across the digits it frames, which is worse than no ring,
|
||||
// so refuse it rather than clamp it; either way keep a margin off the key's own edge. A circle
|
||||
// wide enough to enclose a five-character total needs more width than the key has.
|
||||
float room = Math.min(Math.min(cx - bounds.left, bounds.right - cx),
|
||||
Math.min(cy - bounds.top, bounds.bottom - cy)) - Math.max(1f, size * 0.03f);
|
||||
if (radius + stroke / 2f + casing > room)
|
||||
return;
|
||||
GAUGE_RING_PAINT.setStyle(Paint.Style.STROKE);
|
||||
GAUGE_RING_PAINT.setStrokeWidth(stroke + 2f * casing);
|
||||
GAUGE_RING_PAINT.setColor(0xff202020);
|
||||
canvas.drawCircle(cx, cy, radius, GAUGE_RING_PAINT);
|
||||
GAUGE_RING_PAINT.setStrokeWidth(stroke);
|
||||
GAUGE_RING_PAINT.setColor(ringColor);
|
||||
canvas.drawCircle(cx, cy, radius, GAUGE_RING_PAINT);
|
||||
}
|
||||
|
||||
private static int blendColor(int from, int to, float amount)
|
||||
{
|
||||
return android.graphics.Color.rgb(
|
||||
@@ -557,6 +752,22 @@ public class Keyboard2View extends View
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
/** True for the four direction keys of the Roku page (res/xml/roku.xml), matched by
|
||||
EVENT and never by glyph: 0xE005-0xE008 are ordinary arrow glyphs that the default
|
||||
layouts and other pages also use, and those must keep their own colours.
|
||||
[KeyValue.getEvent()] is only valid for its own kind, so the kind is checked first,
|
||||
the same guard [RokuRemote.isLit] uses. */
|
||||
private static boolean isRokuDirection(KeyValue k)
|
||||
{
|
||||
if (k.getKind() != KeyValue.Kind.Event)
|
||||
return false;
|
||||
switch (k.getEvent())
|
||||
{
|
||||
case ROKU_UP: case ROKU_DOWN: case ROKU_LEFT: case ROKU_RIGHT: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private int labelColor(KeyValue k, boolean isKeyDown, boolean sublabel)
|
||||
{
|
||||
if (isKeyDown)
|
||||
@@ -570,6 +781,21 @@ public class Keyboard2View extends View
|
||||
}
|
||||
return _theme.pressedColor;
|
||||
}
|
||||
/* The Roku page's four direction keys are event keys, and every event key carries
|
||||
FLAG_SECONDARY, which dims them to secondaryLabelColor - a muted pink on the
|
||||
Orchid theme, which reads as "disabled" on the one page where they are the main
|
||||
controls. They get the theme's blue accent instead. Only these four events are
|
||||
affected: no other key on any page or layout changes colour. A press is answered
|
||||
above, and [drawLabel]'s RokuRemote.isLit() still overrides this, so neither the
|
||||
pressed look nor the sent acknowledgement changes. */
|
||||
if (isRokuDirection(k))
|
||||
return _theme.rokuArrowColor;
|
||||
/* The lights page's keys are event keys too, and every event key carries FLAG_SECONDARY, which
|
||||
would dim them all to the same secondary grey - useless on the one page whose whole point is
|
||||
telling one light's state from another's. They get the state's colour instead, from the one
|
||||
mapping in [LightsProxy.colorFor]. */
|
||||
if (LightsPage.isLightKey(k))
|
||||
return LightsPage.colorFor(k, _theme);
|
||||
if (k.hasFlagsAny(KeyValue.FLAG_SECONDARY | KeyValue.FLAG_GREYED))
|
||||
{
|
||||
if (k.hasFlagsAny(KeyValue.FLAG_GREYED))
|
||||
@@ -598,8 +824,24 @@ public class Keyboard2View extends View
|
||||
String glyph = kv.getString();
|
||||
if (glyph != null && glyph.length() == 1 && glyph.charAt(0) == 0xE01D)
|
||||
color = _theme.labelColor;
|
||||
/* A lights key shows the light's name until a press has something to say. The answer to that
|
||||
press - a refusal, or that Home Assistant could not be read back - is then the honest thing
|
||||
to put on the key, in the stale colour, until the box reports a state for that light again. */
|
||||
String note = LightsPage.noteFor(kv, System.currentTimeMillis());
|
||||
if (note != null)
|
||||
{
|
||||
glyph = note;
|
||||
color = _theme.lightStaleColor;
|
||||
}
|
||||
// A Roku key wears the theme's activated colours for a moment after it is pressed:
|
||||
// the command goes out on a worker thread and a press against a sleeping TV takes
|
||||
// ~5 s to be answered, so without this a slow press looks like a dead key and gets
|
||||
// pressed twice. The state is one enum reference and one long in RokuRemote, written
|
||||
// by the worker thread and only read from the draw path.
|
||||
if (RokuRemote.isLit(kv, System.currentTimeMillis()))
|
||||
color = _theme.activatedColor;
|
||||
Paint p = tc.label_paint(kv.hasFlagsAny(KeyValue.FLAG_KEY_FONT), color, textSize);
|
||||
canvas.drawText(kv.getString(), x, (keyH - p.ascent() - p.descent()) / 2f + y, p);
|
||||
canvas.drawText(glyph, x, (keyH - p.ascent() - p.descent()) / 2f + y, p);
|
||||
}
|
||||
|
||||
private void drawSubLabel(Canvas canvas, KeyValue kv, float x, float y,
|
||||
@@ -612,7 +854,23 @@ public class Keyboard2View extends View
|
||||
if (kv == null)
|
||||
return;
|
||||
float textSize = scaleTextSize(kv, false);
|
||||
Paint p = tc.sublabel_paint(kv.hasFlagsAny(KeyValue.FLAG_KEY_FONT), labelColor(kv, isKeyDown, true), textSize, a);
|
||||
int color = labelColor(kv, isKeyDown, true);
|
||||
// Corner badges drawn with an icon glyph. labelColor() dims every key carrying
|
||||
// FLAG_SECONDARY to secondaryLabelColor, and every eventKey carries it, so the
|
||||
// emoji badge (0xE01D, Alt's top-right corner), the Roku badge (0xE024) and the
|
||||
// lights badge (0xE025, Alt's bottom-right corner) would all come out dim next to
|
||||
// the theme's own labels. drawLabel() has the same test for 0xE01D, but drawLabel()
|
||||
// is only ever called with k.keys[0]: a corner badge lives in k.keys[2] and is drawn
|
||||
// here, so that copy never runs.
|
||||
if (kv.getKind() == KeyValue.Kind.Event)
|
||||
{
|
||||
String badge = kv.getString();
|
||||
if (badge != null && badge.length() == 1
|
||||
&& (badge.charAt(0) == 0xE01D || badge.charAt(0) == 0xE024
|
||||
|| badge.charAt(0) == 0xE025))
|
||||
color = _theme.labelColor;
|
||||
}
|
||||
Paint p = tc.sublabel_paint(kv.hasFlagsAny(KeyValue.FLAG_KEY_FONT), color, textSize, a);
|
||||
float subPadding = _config.keyPadding;
|
||||
if (v == Vertical.CENTER)
|
||||
y += (keyH - p.ascent() - p.descent()) / 2f;
|
||||
@@ -622,7 +880,13 @@ public class Keyboard2View extends View
|
||||
x += keyW / 2f;
|
||||
else
|
||||
x += (a == Paint.Align.LEFT) ? subPadding : keyW - subPadding;
|
||||
String label = kv.getString();
|
||||
/* A lights key's state glyph is chosen as it is drawn, not when the page was built: a press
|
||||
or a poll changes the state under the key without the page being rebuilt, so what is stored
|
||||
in the key value is only a marker. [LightsPage.glyphFor] returns shared Strings, so this
|
||||
allocates nothing. */
|
||||
String label = LightsPage.isStateGlyph(kv)
|
||||
? LightsPage.glyphFor(LightsProxy.stateAt(LightsPage.slotOf(kv.getEvent())))
|
||||
: kv.getString();
|
||||
int label_len = label.length();
|
||||
// Limit the label of string keys to 3 characters
|
||||
if (label_len > 3 && kv.getKind() == KeyValue.Kind.String)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package juloo.keyboard2;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The lights page (res/xml/lights.xml): one key per light, plus the way back.
|
||||
*
|
||||
* The page is opened by the switch_lights key (the lamp glyph at 0xE025, in the free bottom-right
|
||||
* corner of the Alt key) and left by lights_close, which fires the same Event.SWITCH_TEXT as
|
||||
* roku_close. Like the Roku page it is loaded as a plain layout (Keyboard2.loadLightsLayout), NOT
|
||||
* through LayoutModifier.modify_layout, so the forced config key and the user's extra keys never
|
||||
* land on it: this page is its own keys and nothing else.
|
||||
*
|
||||
* The skeleton - a row of three slots and the full-width 'Keyboard' key - lives in the XML. This
|
||||
* class turns that skeleton into the live page: one key per light the proxy reported, three to a
|
||||
* row, with a short last row stretched to the full width. Each key carries two values:
|
||||
*
|
||||
* key0 the light's name, exactly as the registry gave it (shortened only when it would run
|
||||
* over the key), coloured by the light's state;
|
||||
* key7 the state glyph, in the special font: the lit lamp (0xE025) for a light that is on, the
|
||||
* unlit one (0xE026) for everything else. The glyph is chosen when the key is *drawn*, not
|
||||
* when the page is built, because a press and a poll change the state under the key
|
||||
* without the page being rebuilt.
|
||||
*
|
||||
* Before the proxy has answered, the template's three slots stand with a dash and the unlit lamp in
|
||||
* the stale look: nobody has said which lights exist, and an unlit lamp never claims otherwise.
|
||||
*
|
||||
* [build], [displayName], [glyphFor] and [colorFor] are pure, so the unit tests drive the real page
|
||||
* logic.
|
||||
*/
|
||||
public final class LightsPage
|
||||
{
|
||||
/** The most lights the page can show: the page's events are LIGHT_1..LIGHT_12. A whitelist
|
||||
longer than this is reported on the last key rather than silently dropped. */
|
||||
public static final int MAX_LIGHTS = 12;
|
||||
/** The template's slot row holds three keys; the page repeats it. */
|
||||
public static final int SLOTS_PER_ROW = 3;
|
||||
/** The template's corner slot for the state glyph. */
|
||||
public static final int GLYPH_SUB_INDEX = 7;
|
||||
/** A key is a third of the keyboard wide; a longer name would run over its neighbours. The name
|
||||
itself is whatever the registry says, never a mapping kept here. */
|
||||
public static final int MAX_NAME_CHARS = 16;
|
||||
/** Drawn on a slot before the proxy has named a light for it. */
|
||||
public static final String PLACEHOLDER = "—";
|
||||
private static final String ELLIPSIS = "…";
|
||||
/** The two glyphs, as the Strings the draw path hands to [android.graphics.Canvas]: kept here so
|
||||
drawing a key allocates nothing. */
|
||||
private static final String LIT_GLYPH = String.valueOf((char)0xE025);
|
||||
private static final String UNLIT_GLYPH = String.valueOf((char)0xE026);
|
||||
|
||||
private LightsPage() {}
|
||||
|
||||
// ------------------------------------------------------------------ the page
|
||||
|
||||
/** The live page: the template's row of slots repeated once per row of lights, then the
|
||||
template's 'Keyboard' row, untouched. [lights] may be null or empty, in which case the slots
|
||||
stand as placeholders. Never mutates the template (KeyboardData.load caches it). */
|
||||
public static KeyboardData build(KeyboardData template, LightsProxy.Light[] lights)
|
||||
{
|
||||
if (template == null || template.rows.size() < 2)
|
||||
return template;
|
||||
LightsProxy.Light[] list = lights == null ? new LightsProxy.Light[0] : lights;
|
||||
int shown = Math.min(list.length, MAX_LIGHTS);
|
||||
List<KeyboardData.Row> rows = new ArrayList<KeyboardData.Row>();
|
||||
if (shown == 0)
|
||||
rows.add(slotRow(template, list, 0, 0));
|
||||
else
|
||||
for (int first = 0; first < shown; first += SLOTS_PER_ROW)
|
||||
rows.add(slotRow(template, list, first, shown));
|
||||
rows.add(template.rows.get(template.rows.size() - 1)); // the 'Keyboard' key
|
||||
return template.with_rows(rows);
|
||||
}
|
||||
|
||||
/** One row of up to three light keys, built from the template's slot keys so the geometry lives
|
||||
in the XML. A short row is stretched to the template row's width. */
|
||||
private static KeyboardData.Row slotRow(KeyboardData template,
|
||||
LightsProxy.Light[] lights, int first, int shown)
|
||||
{
|
||||
KeyboardData.Row tpl = template.rows.get(0);
|
||||
ArrayList<KeyboardData.Key> keys = new ArrayList<KeyboardData.Key>();
|
||||
for (int i = 0; i < tpl.keys.size(); i++)
|
||||
{
|
||||
int slot = first + i + 1; // 1-based: LIGHT_<slot>
|
||||
if (shown != 0 && slot > shown)
|
||||
break;
|
||||
String label = PLACEHOLDER;
|
||||
if (shown != 0)
|
||||
{
|
||||
LightsProxy.Light light = lights[slot - 1];
|
||||
label = displayName(light.name);
|
||||
if (slot == shown && lights.length > MAX_LIGHTS)
|
||||
{
|
||||
// Say what did not fit rather than dropping it silently, and make room for it: the count
|
||||
// is the last thing on the key and must never be the part that gets cut.
|
||||
String more = " +" + (lights.length - MAX_LIGHTS);
|
||||
label = ellipsize(label, MAX_NAME_CHARS - more.length()) + more;
|
||||
}
|
||||
}
|
||||
KeyValue.Event ev = eventFor(slot);
|
||||
keys.add(tpl.keys.get(i)
|
||||
.withKeyValue(0, KeyValue.lightKey(label, ev))
|
||||
.withKeyValue(GLYPH_SUB_INDEX, KeyValue.lightGlyphKey(ev)));
|
||||
}
|
||||
KeyboardData.Row row = new KeyboardData.Row(keys, tpl.height, tpl.shift);
|
||||
return keys.size() == tpl.keys.size() ? row : row.updateWidth(tpl.keysWidth);
|
||||
}
|
||||
|
||||
/** The name as it fits on a key. The label is the registry's own name - there is deliberately no
|
||||
mapping from a light to a shorter label anywhere in the keyboard - cut with a visible ellipsis
|
||||
only when it would run over the neighbouring keys. Pure. */
|
||||
public static String displayName(String name)
|
||||
{
|
||||
return ellipsize(name, MAX_NAME_CHARS);
|
||||
}
|
||||
|
||||
/** Cut a line to [max] characters, marking the cut. Pure. */
|
||||
public static String ellipsize(String text, int max)
|
||||
{
|
||||
if (text == null)
|
||||
return "";
|
||||
String s = text.trim();
|
||||
if (s.length() <= max)
|
||||
return s;
|
||||
return s.substring(0, max - 1).trim() + ELLIPSIS;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the keys
|
||||
|
||||
/** True for a key of the lights page. [KeyValue.getEvent()] is only valid for its own kind, so
|
||||
the kind is checked first - the same guard RokuRemote.isLit uses. */
|
||||
public static boolean isLightKey(KeyValue k)
|
||||
{
|
||||
return k != null && k.getKind() == KeyValue.Kind.Event && slotOf(k.getEvent()) > 0;
|
||||
}
|
||||
|
||||
/** True for the state-glyph value of a lights key. It is the value drawn in the special font
|
||||
(FLAG_KEY_FONT), which is exactly what tells it apart from the light's name. */
|
||||
public static boolean isStateGlyph(KeyValue k)
|
||||
{
|
||||
return isLightKey(k) && k.hasFlagsAny(KeyValue.FLAG_KEY_FONT);
|
||||
}
|
||||
|
||||
/** The glyph for a state: the lamp with its beams for a light that is on, the unlit lamp for
|
||||
everything else - 'off', 'unavailable', 'unknown' and no data at all. Only ON is ever drawn
|
||||
lit, so an unreachable light cannot look switched on. Pure, and the returned Strings are
|
||||
shared, so the draw path allocates nothing. */
|
||||
public static String glyphFor(LightsProxy.State state)
|
||||
{
|
||||
return state == LightsProxy.State.ON ? LIT_GLYPH : UNLIT_GLYPH;
|
||||
}
|
||||
|
||||
/** 1..MAX_LIGHTS for a lights key's event, 0 for anything else. */
|
||||
public static int slotOf(KeyValue.Event ev)
|
||||
{
|
||||
if (ev == null)
|
||||
return 0;
|
||||
switch (ev)
|
||||
{
|
||||
case LIGHT_1: return 1;
|
||||
case LIGHT_2: return 2;
|
||||
case LIGHT_3: return 3;
|
||||
case LIGHT_4: return 4;
|
||||
case LIGHT_5: return 5;
|
||||
case LIGHT_6: return 6;
|
||||
case LIGHT_7: return 7;
|
||||
case LIGHT_8: return 8;
|
||||
case LIGHT_9: return 9;
|
||||
case LIGHT_10: return 10;
|
||||
case LIGHT_11: return 11;
|
||||
case LIGHT_12: return 12;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** The event of slot [slot] (1-based), or null when the page has no such slot. */
|
||||
public static KeyValue.Event eventFor(int slot)
|
||||
{
|
||||
switch (slot)
|
||||
{
|
||||
case 1: return KeyValue.Event.LIGHT_1;
|
||||
case 2: return KeyValue.Event.LIGHT_2;
|
||||
case 3: return KeyValue.Event.LIGHT_3;
|
||||
case 4: return KeyValue.Event.LIGHT_4;
|
||||
case 5: return KeyValue.Event.LIGHT_5;
|
||||
case 6: return KeyValue.Event.LIGHT_6;
|
||||
case 7: return KeyValue.Event.LIGHT_7;
|
||||
case 8: return KeyValue.Event.LIGHT_8;
|
||||
case 9: return KeyValue.Event.LIGHT_9;
|
||||
case 10: return KeyValue.Event.LIGHT_10;
|
||||
case 11: return KeyValue.Event.LIGHT_11;
|
||||
case 12: return KeyValue.Event.LIGHT_12;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The colour a lights key's label and glyph are drawn in. The rule itself is in one place
|
||||
([LightsProxy.colorFor]); all this does is fetch the three theme colours and the state. */
|
||||
public static int colorFor(KeyValue k, Theme theme)
|
||||
{
|
||||
return LightsProxy.colorFor(LightsProxy.stateAt(slotOf(k.getEvent())), theme.lightOnColor,
|
||||
theme.lightOffColor, theme.lightStaleColor);
|
||||
}
|
||||
|
||||
/** What a lights key says: the answer to the last press on this light while that answer is still
|
||||
fresh (a refusal, or that the state could not be read back), and null otherwise - in which
|
||||
case the key shows the light's name in its state colour. */
|
||||
public static String noteFor(KeyValue k, long now)
|
||||
{
|
||||
if (!isLightKey(k))
|
||||
return null;
|
||||
LightsProxy.Light light = LightsProxy.lightAt(slotOf(k.getEvent()));
|
||||
return light == null ? null : light.noteOrNull(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
package juloo.keyboard2;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** The lights page's data: the whitelisted lights, their states, and one press at a time.
|
||||
*
|
||||
* The endpoint is Harley's lights-proxy on this host's wg0 address - the same
|
||||
* 10.10.10.1 that RokuRemote uses, already allowed by res/xml/network_security_config.xml. It is
|
||||
* the only thing that talks to Home Assistant, so the whitelist, the polarity rules and the
|
||||
* honesty rules live there and this class only has to report what it was told.
|
||||
*
|
||||
* Three rules, and they are the whole design:
|
||||
* - every fetch happens on a worker thread with a short timeout, and failure is quiet: no VPN,
|
||||
* no proxy, a non-2xx or a junk payload all end as "no data", which the page draws as the
|
||||
* stale look. Never a crash, and never a guessed state.
|
||||
* - a light is 'on', 'off', 'unavailable' or 'unknown', and only the first two are states the
|
||||
* page may draw as a state. The other two - and no data at all - draw as stale, because a
|
||||
* light nobody can reach must never look like a light that is switched off.
|
||||
* - a toggle is sent once and never retried, and the answer is believed rather than a
|
||||
* prediction. The proxy reports what Home Assistant said after the light settled as 'state'
|
||||
* (null when it could not be read back) and what it expects as 'optimistic_state'; the
|
||||
* prediction is never displayed.
|
||||
*
|
||||
* A light changes rarely, so the page reads at most once per POLL_MS while it is open. That is
|
||||
* deliberately nothing like the usage gauge's 1 Hz: polling a lamp harder buys no truth.
|
||||
*/
|
||||
public final class LightsProxy
|
||||
{
|
||||
/** Harley's lights-proxy, on the WireGuard address. */
|
||||
static final String ENDPOINT = "http://10.10.10.1:8772";
|
||||
|
||||
/** A light changes rarely: how often the page re-reads while it is visible. */
|
||||
static final long POLL_MS = 15000L;
|
||||
/** Opening the page or pressing a light may ask sooner than the poll, but not twice in a moment. */
|
||||
static final long FORCE_FLOOR_MS = 2000L;
|
||||
/** A snapshot older than this is never drawn as a live state, however the fetch went. */
|
||||
static final long MAX_AGE_MS = 120000L;
|
||||
/** How long the answer to a press stays on its key: long enough to read, short enough that an
|
||||
old sentence cannot pass for the current state. */
|
||||
static final long NOTE_MS = 60000L;
|
||||
private static final int CONNECT_TIMEOUT_MS = 4000;
|
||||
private static final int READ_TIMEOUT_MS = 5000;
|
||||
/** A toggle waits for Home Assistant to settle (the proxy sleeps ~2.5 s, then reads back with
|
||||
its own 4 s timeout); timing out sooner would drop an answer that was still on its way. */
|
||||
private static final int TOGGLE_READ_TIMEOUT_MS = 12000;
|
||||
/** A burst of presses must not spawn unbounded threads. The same bound RokuRemote keeps. */
|
||||
private static final int MAX_IN_FLIGHT = 4;
|
||||
|
||||
/** What the page may show for a light. 'unavailable', 'unknown', a state we do not recognise
|
||||
and no data at all are all UNKNOWN: nobody can see this light. */
|
||||
public enum State { ON, OFF, UNKNOWN }
|
||||
|
||||
/** One light as the proxy last described it, with the answer to the last press on it. All of it
|
||||
is immutable, so the worker thread can publish a whole new array and the draw path needs no
|
||||
lock: it reads one volatile reference and some final fields. */
|
||||
public static final class Light
|
||||
{
|
||||
public final String id;
|
||||
public final String name;
|
||||
public final String entityId;
|
||||
public final State state;
|
||||
/** What the last press on this light said - a refusal code, or that the state could not be
|
||||
read back - or null. Drawn instead of the name while it is fresh. */
|
||||
public final String note;
|
||||
public final long noteAt;
|
||||
|
||||
Light(String id_, String name_, String entityId_, State state_, String note_, long noteAt_)
|
||||
{
|
||||
id = id_;
|
||||
name = name_;
|
||||
entityId = entityId_;
|
||||
state = state_;
|
||||
note = note_;
|
||||
noteAt = noteAt_;
|
||||
}
|
||||
|
||||
/** The note, or null when there is nothing to say or it has gone stale. */
|
||||
public String noteOrNull(long now)
|
||||
{
|
||||
if (note == null)
|
||||
return null;
|
||||
return (now - noteAt) < NOTE_MS ? note : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The object the proxy answers /lights with, reduced to what the page needs. */
|
||||
private static final Object _lock = new Object();
|
||||
private static volatile Light[] _lights = new Light[0];
|
||||
private static volatile long _fetchedAt = 0L;
|
||||
/** True only after a read that succeeded. A failed read says so at once instead of leaving the
|
||||
last numbers wearing a live face. */
|
||||
private static volatile boolean _fresh = false;
|
||||
/** Bumped when the *list* changes (a light added, removed or renamed), so the open page knows
|
||||
it has to be rebuilt. States changing never bump it: those are just a repaint. */
|
||||
private static volatile int _version = 0;
|
||||
private static boolean _inFlight = false;
|
||||
private static long _lastAttempt = 0L;
|
||||
/** Bit per slot with a toggle in flight: a second press before the answer would flip the light
|
||||
back, which is the same trap as a retry, so it is refused. */
|
||||
private static int _pendingSlots = 0;
|
||||
private static final AtomicInteger _inFlightCount = new AtomicInteger();
|
||||
|
||||
private LightsProxy() {}
|
||||
|
||||
// ------------------------------------------------------------------ what the page reads
|
||||
|
||||
/** The lights as last read. Never null; possibly empty, which means nobody has answered yet. */
|
||||
public static Light[] lights()
|
||||
{
|
||||
return _lights;
|
||||
}
|
||||
|
||||
/** The slot's light (LIGHT_<slot>), or null when the page has fewer lights than that. */
|
||||
public static Light lightAt(int slot)
|
||||
{
|
||||
Light[] lights = _lights;
|
||||
int i = slot - 1;
|
||||
return (i < 0 || i >= lights.length) ? null : lights[i];
|
||||
}
|
||||
|
||||
/** The state to draw for a slot: UNKNOWN whenever the page has no *current* answer, which is
|
||||
what makes 'unavailable' and a dead proxy look the same - not a state. */
|
||||
public static State stateAt(int slot)
|
||||
{
|
||||
Light light = lightAt(slot);
|
||||
if (light == null || isStaleNow())
|
||||
return State.UNKNOWN;
|
||||
return light.state;
|
||||
}
|
||||
|
||||
/** True when there is nothing to draw a state from: no read has succeeded, it failed, or what
|
||||
we have is too old to call current. */
|
||||
public static boolean isStaleNow()
|
||||
{
|
||||
return !_fresh || (System.currentTimeMillis() - _fetchedAt) >= MAX_AGE_MS;
|
||||
}
|
||||
|
||||
/** Changes whenever the light *list* does. The open page compares it to know when to rebuild. */
|
||||
public static int version()
|
||||
{
|
||||
return _version;
|
||||
}
|
||||
|
||||
/** THE colour rule, and the only place it is written: ON is blue, OFF is pink, and everything
|
||||
else - unavailable, unknown, no data - is the stale grey. The three colours themselves come
|
||||
from the theme (res/values/themes.xml), so swapping on and off later is a one-line change
|
||||
there and no key holds a colour of its own. */
|
||||
public static int colorFor(State state, int onColor, int offColor, int staleColor)
|
||||
{
|
||||
if (state == null)
|
||||
return staleColor; // no light at all: the same look as one nobody can see
|
||||
switch (state)
|
||||
{
|
||||
case ON: return onColor;
|
||||
case OFF: return offColor;
|
||||
default: return staleColor;
|
||||
}
|
||||
}
|
||||
|
||||
/** 'on' and 'off' are states; everything else - including 'unavailable', 'unknown', a missing
|
||||
field and anything we do not recognise - is UNKNOWN, never OFF. Pure, so it is tested. */
|
||||
public static State stateOf(String raw)
|
||||
{
|
||||
if (raw == null)
|
||||
return State.UNKNOWN;
|
||||
String text = raw.trim().toLowerCase(Locale.US);
|
||||
if (text.equals("on"))
|
||||
return State.ON;
|
||||
if (text.equals("off"))
|
||||
return State.OFF;
|
||||
return State.UNKNOWN;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ fetching
|
||||
|
||||
/** Read the lights, at most once per POLL_MS; [force] is for opening the page and for the press
|
||||
that follows, which are allowed to ask sooner but not in a burst. Never blocks the caller and
|
||||
never throws: when a read is actually made, [onDone] runs on that worker thread afterwards. */
|
||||
public static void fetchIfNeeded(boolean force, Runnable onDone)
|
||||
{
|
||||
long now = System.currentTimeMillis();
|
||||
synchronized (_lock)
|
||||
{
|
||||
long floor = force ? FORCE_FLOOR_MS : POLL_MS;
|
||||
if (_inFlight || (now - _lastAttempt) < floor)
|
||||
return;
|
||||
_inFlight = true;
|
||||
_lastAttempt = now;
|
||||
}
|
||||
Thread worker = new Thread(() -> {
|
||||
try
|
||||
{
|
||||
read(ENDPOINT + "/lights");
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
// One answer for every way a read can go wrong.
|
||||
fail();
|
||||
}
|
||||
finally
|
||||
{
|
||||
synchronized (_lock) { _inFlight = false; }
|
||||
runQuietly(onDone);
|
||||
}
|
||||
}, "lights-fetch");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private static void read(String url)
|
||||
{
|
||||
HttpURLConnection connection = null;
|
||||
try
|
||||
{
|
||||
connection = (HttpURLConnection)new URL(url).openConnection();
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(READ_TIMEOUT_MS);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
StringBuilder body = new StringBuilder();
|
||||
try (InputStream input = connection.getInputStream();
|
||||
InputStreamReader reader = new InputStreamReader(input, "UTF-8"))
|
||||
{
|
||||
char[] buf = new char[4096];
|
||||
int n;
|
||||
while ((n = reader.read(buf)) > 0)
|
||||
body.append(buf, 0, n);
|
||||
}
|
||||
Light[] parsed = parseLights(body.toString());
|
||||
if (parsed == null)
|
||||
{
|
||||
fail(); // a payload we cannot read is no data at all
|
||||
return;
|
||||
}
|
||||
publish(parsed);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// No VPN, no route, proxy down, refused, a non-2xx like the no-token 200-with-nothing and
|
||||
// an unreadable stream: one answer for all of them.
|
||||
fail();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (connection != null)
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static void fail()
|
||||
{
|
||||
_fresh = false;
|
||||
}
|
||||
|
||||
private static void publish(Light[] lights)
|
||||
{
|
||||
synchronized (_lock)
|
||||
{
|
||||
if (!sameList(_lights, lights))
|
||||
_version++;
|
||||
/* The notes travel with the lights so a rebuild keeps them, but a real state from the box
|
||||
supersedes the answer to a press: once we know what the light is doing, the last
|
||||
sentence about it is no longer the best thing to show. */
|
||||
Light[] merged = new Light[lights.length];
|
||||
for (int i = 0; i < lights.length; i++)
|
||||
{
|
||||
merged[i] = mergeNote(lights[i], _lights);
|
||||
}
|
||||
_lights = merged;
|
||||
_fetchedAt = System.currentTimeMillis();
|
||||
_fresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh reading of the same light keeps a still-useful note; anything else replaces it. */
|
||||
static Light mergeNote(Light fresh, Light[] previous)
|
||||
{
|
||||
if (fresh.note != null || fresh.state != State.UNKNOWN)
|
||||
return fresh;
|
||||
for (int i = 0; i < previous.length; i++)
|
||||
{
|
||||
Light old = previous[i];
|
||||
if (old.id.equals(fresh.id) && old.note != null)
|
||||
return new Light(fresh.id, fresh.name, fresh.entityId, fresh.state, old.note, old.noteAt);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private static boolean sameList(Light[] a, Light[] b)
|
||||
{
|
||||
if (a.length != b.length)
|
||||
return false;
|
||||
for (int i = 0; i < a.length; i++)
|
||||
if (!a[i].id.equals(b[i].id) || !a[i].name.equals(b[i].name))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ pressing a light
|
||||
|
||||
/** Toggle one light (1-based slot). Sent once, never retried: a retried toggle on a light that
|
||||
did change flips it straight back. [onDone] runs on the worker thread, twice at most - once
|
||||
with the answer to this press, once when the follow-up read lands. */
|
||||
public static void toggle(int slot, Runnable onDone)
|
||||
{
|
||||
Light light = lightAt(slot);
|
||||
if (light == null || !claimSlot(slot))
|
||||
return;
|
||||
final String id = light.id;
|
||||
Thread worker = new Thread(() -> {
|
||||
ToggleResult result = null;
|
||||
try
|
||||
{
|
||||
result = sendToggle(id);
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
result = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseSlot(slot);
|
||||
/* What the press said, painted at once: a refusal or an unreadable read-back is the
|
||||
honest thing to show, and waiting for a poll would hide it for seconds. */
|
||||
reply(slot, result);
|
||||
runQuietly(onDone);
|
||||
/* And then a read: the answer above is about this one light and the page should not keep
|
||||
a stale view of the rest. Floored like a page open, so a burst of presses cannot become
|
||||
a burst of reads, and the proxy caches reads for 9 s anyway. */
|
||||
fetchIfNeeded(true, onDone);
|
||||
}
|
||||
}, "lights-toggle");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
/** Reserves the slot and one in-flight allowance, or refuses. Bounded like RokuRemote, and one
|
||||
press per light at a time: a second press before the answer would flip it back. */
|
||||
private static boolean claimSlot(int slot)
|
||||
{
|
||||
synchronized (_lock)
|
||||
{
|
||||
int bit = 1 << (slot - 1);
|
||||
if ((_pendingSlots & bit) != 0)
|
||||
return false;
|
||||
if (_inFlightCount.incrementAndGet() > MAX_IN_FLIGHT)
|
||||
{
|
||||
_inFlightCount.decrementAndGet();
|
||||
return false;
|
||||
}
|
||||
_pendingSlots |= bit;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void releaseSlot(int slot)
|
||||
{
|
||||
synchronized (_lock)
|
||||
{
|
||||
_pendingSlots &= ~(1 << (slot - 1));
|
||||
}
|
||||
_inFlightCount.decrementAndGet();
|
||||
}
|
||||
|
||||
private static ToggleResult sendToggle(String id) throws Exception
|
||||
{
|
||||
HttpURLConnection connection = null;
|
||||
try
|
||||
{
|
||||
String url = ENDPOINT + "/lights/" + URLEncoder.encode(id, "UTF-8") + "/toggle";
|
||||
connection = (HttpURLConnection)new URL(url).openConnection();
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(TOGGLE_READ_TIMEOUT_MS);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
connection.setDoOutput(true);
|
||||
connection.setFixedLengthStreamingMode(0); // a POST with no body: the proxy ignores it
|
||||
int code = connection.getResponseCode();
|
||||
boolean ok = code >= 200 && code < 300;
|
||||
String body = readAll(ok ? connection.getInputStream() : connection.getErrorStream());
|
||||
/* Both halves matter: a 503 carries the reason (no token yet, a light Home Assistant cannot
|
||||
read) and a 200 carries the read-back. An unreadable body is still an answer. */
|
||||
ToggleResult result = parseToggle(body);
|
||||
return result != null ? result : ToggleResult.unreadable();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (connection != null)
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String readAll(InputStream input) throws Exception
|
||||
{
|
||||
if (input == null)
|
||||
return null;
|
||||
StringBuilder body = new StringBuilder();
|
||||
try (InputStreamReader reader = new InputStreamReader(input, "UTF-8"))
|
||||
{
|
||||
char[] buf = new char[2048];
|
||||
int n;
|
||||
while ((n = reader.read(buf)) > 0)
|
||||
body.append(buf, 0, n);
|
||||
}
|
||||
return body.toString();
|
||||
}
|
||||
|
||||
/** What the press did to the light: the read-back, which is the only thing shown, and what the
|
||||
proxy said when it could not give one. */
|
||||
static void reply(int slot, ToggleResult result)
|
||||
{
|
||||
if (result == null)
|
||||
result = ToggleResult.unreadable();
|
||||
synchronized (_lock)
|
||||
{
|
||||
Light[] current = _lights;
|
||||
int i = slot - 1;
|
||||
if (i < 0 || i >= current.length)
|
||||
return;
|
||||
Light old = current[i];
|
||||
State state = old.state;
|
||||
if (result.accepted)
|
||||
state = result.state == null ? State.UNKNOWN : stateOf(result.state);
|
||||
String note = result.note();
|
||||
if (note != null)
|
||||
note = LightsPage.ellipsize(note, LightsPage.MAX_NAME_CHARS);
|
||||
Light[] next = current.clone();
|
||||
next[i] = new Light(old.id, old.name, old.entityId, state, note,
|
||||
System.currentTimeMillis());
|
||||
_lights = next; // the list itself is unchanged: no rebuild, the view just repaints
|
||||
}
|
||||
}
|
||||
|
||||
private static void runQuietly(Runnable r)
|
||||
{
|
||||
if (r == null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
r.run();
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
// A repaint request must never take the keyboard down.
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ parsing
|
||||
|
||||
/** The lights of a GET /lights payload, or null when it cannot be read: a page that invents an
|
||||
empty list would be claiming there are no lights. Pure, so it is tested. */
|
||||
public static Light[] parseLights(String json)
|
||||
{
|
||||
Object root = Json.parse(json);
|
||||
if (!(root instanceof Map))
|
||||
return null;
|
||||
Object raw = ((Map<?, ?>)root).get("lights");
|
||||
if (!(raw instanceof List))
|
||||
return null;
|
||||
ArrayList<Light> out = new ArrayList<Light>();
|
||||
for (Object item : (List<?>)raw)
|
||||
{
|
||||
if (!(item instanceof Map))
|
||||
continue;
|
||||
Map<?, ?> m = (Map<?, ?>)item;
|
||||
String id = asString(m.get("id"));
|
||||
String name = asString(m.get("name"));
|
||||
if (id == null || name == null || name.trim().isEmpty())
|
||||
continue; // a light with no name has no key on the page
|
||||
String entity = asString(m.get("entity_id"));
|
||||
out.add(new Light(id, name, entity == null ? "" : entity,
|
||||
stateOf(asString(m.get("state"))), null, 0L));
|
||||
}
|
||||
return out.toArray(new Light[out.size()]);
|
||||
}
|
||||
|
||||
/** What one toggle said. [state] is Home Assistant's read-back after the light settled, and the
|
||||
only field the page may display as a state; [optimisticState] is the proxy's prediction and
|
||||
is deliberately not kept here. Pure, so it is tested. */
|
||||
public static final class ToggleResult
|
||||
{
|
||||
public final boolean accepted;
|
||||
public final String state;
|
||||
public final boolean confirmed;
|
||||
public final Boolean changed;
|
||||
public final String warning;
|
||||
public final String code;
|
||||
|
||||
ToggleResult(boolean accepted_, String state_, boolean confirmed_, Boolean changed_,
|
||||
String warning_, String code_)
|
||||
{
|
||||
accepted = accepted_;
|
||||
state = state_;
|
||||
confirmed = confirmed_;
|
||||
changed = changed_;
|
||||
warning = warning_;
|
||||
code = code_;
|
||||
}
|
||||
|
||||
static ToggleResult unreadable()
|
||||
{
|
||||
return new ToggleResult(false, null, false, null, null, "no_answer");
|
||||
}
|
||||
|
||||
/** The one short line the key shows instead of the name, or null when the answer was a state
|
||||
the page can show. It is the proxy's own vocabulary - its error code - or the fact that
|
||||
the state could not be read back; the long prose warning does not fit a key. */
|
||||
public String note()
|
||||
{
|
||||
if (!accepted)
|
||||
return code == null ? "refused" : code;
|
||||
if (state == null || !confirmed)
|
||||
return "unconfirmed";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What a POST /lights/<id>/toggle answered, or null when the body cannot be read. Pure. */
|
||||
public static ToggleResult parseToggle(String json)
|
||||
{
|
||||
Object root = Json.parse(json);
|
||||
if (!(root instanceof Map))
|
||||
return null;
|
||||
Map<?, ?> m = (Map<?, ?>)root;
|
||||
Object accepted = m.get("accepted");
|
||||
String state = asString(m.get("state"));
|
||||
String code = error_code(m);
|
||||
if (accepted == null && state == null && code == null)
|
||||
return null; // a body that says neither yes, nor no, nor what it saw is no answer
|
||||
return new ToggleResult(asBool(accepted), state, asBool(m.get("confirmed")),
|
||||
asBoolean(m.get("changed")), asString(m.get("warning")), code);
|
||||
}
|
||||
|
||||
/** Why a toggle was refused: the service's own error code - from the nested error object its
|
||||
toggle answer documents, or the flat "code" a refusal carries - and only the prose when there
|
||||
is no code at all. A key has room for the vocabulary, not for a sentence. Null when the
|
||||
service did not refuse. */
|
||||
private static String error_code(Map<?, ?> m)
|
||||
{
|
||||
Object err = m.get("error");
|
||||
if (err instanceof Map)
|
||||
{
|
||||
String nested = asString(((Map<?, ?>)err).get("code"));
|
||||
if (nested != null)
|
||||
return nested;
|
||||
}
|
||||
String code = asString(m.get("code"));
|
||||
return code != null ? code : asString(err);
|
||||
}
|
||||
|
||||
static String asString(Object v)
|
||||
{
|
||||
return (v instanceof String) ? (String)v : null;
|
||||
}
|
||||
|
||||
static boolean asBool(Object v)
|
||||
{
|
||||
return (v instanceof Boolean) && ((Boolean)v).booleanValue();
|
||||
}
|
||||
|
||||
static Boolean asBoolean(Object v)
|
||||
{
|
||||
return (v instanceof Boolean) ? (Boolean)v : null;
|
||||
}
|
||||
|
||||
/** A deliberately small JSON reader: objects, arrays, strings, numbers, booleans and null.
|
||||
*
|
||||
* Android's org.json is a stub under the local JVM test runner, which is why HostGraph and this
|
||||
* class parse their own payloads: pure Java, so the tests can drive the real parser over the
|
||||
* real payloads. Only what the lights endpoints answer with is supported, and anything it
|
||||
* cannot read becomes "no data" rather than a guess. */
|
||||
static final class Json
|
||||
{
|
||||
private final String _s;
|
||||
private int _i = 0;
|
||||
|
||||
private Json(String s) { _s = s; }
|
||||
|
||||
/** Map/List/String/Double/Boolean/null, or null when the text cannot be read. */
|
||||
static Object parse(String text)
|
||||
{
|
||||
if (text == null)
|
||||
return null;
|
||||
Json p = new Json(text);
|
||||
try
|
||||
{
|
||||
Object v = p.value();
|
||||
p.ws();
|
||||
return p._i == p._s.length() ? v : null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ws()
|
||||
{
|
||||
while (_i < _s.length() && Character.isWhitespace(_s.charAt(_i)))
|
||||
_i++;
|
||||
}
|
||||
|
||||
private Object value() throws Exception
|
||||
{
|
||||
ws();
|
||||
if (_i >= _s.length())
|
||||
throw new Exception("eof");
|
||||
char c = _s.charAt(_i);
|
||||
switch (c)
|
||||
{
|
||||
case '{': return object();
|
||||
case '[': return array();
|
||||
case '"': return string();
|
||||
case 't': expect("true"); return Boolean.TRUE;
|
||||
case 'f': expect("false"); return Boolean.FALSE;
|
||||
case 'n': expect("null"); return null;
|
||||
default: return number();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> object() throws Exception
|
||||
{
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<String, Object>();
|
||||
_i++; // '{'
|
||||
ws();
|
||||
if (peek() == '}')
|
||||
{
|
||||
_i++;
|
||||
return out;
|
||||
}
|
||||
while (true)
|
||||
{
|
||||
ws();
|
||||
if (peek() != '"')
|
||||
throw new Exception("object key");
|
||||
String key = string();
|
||||
ws();
|
||||
if (peek() != ':')
|
||||
throw new Exception("object colon");
|
||||
_i++;
|
||||
out.put(key, value());
|
||||
ws();
|
||||
char c = peek();
|
||||
_i++;
|
||||
if (c == '}')
|
||||
return out;
|
||||
if (c != ',')
|
||||
throw new Exception("object comma");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> array() throws Exception
|
||||
{
|
||||
List<Object> out = new ArrayList<Object>();
|
||||
_i++; // '['
|
||||
ws();
|
||||
if (peek() == ']')
|
||||
{
|
||||
_i++;
|
||||
return out;
|
||||
}
|
||||
while (true)
|
||||
{
|
||||
out.add(value());
|
||||
ws();
|
||||
char c = peek();
|
||||
_i++;
|
||||
if (c == ']')
|
||||
return out;
|
||||
if (c != ',')
|
||||
throw new Exception("array comma");
|
||||
}
|
||||
}
|
||||
|
||||
private String string() throws Exception
|
||||
{
|
||||
StringBuilder b = new StringBuilder();
|
||||
_i++; // '"'
|
||||
while (true)
|
||||
{
|
||||
if (_i >= _s.length())
|
||||
throw new Exception("string eof");
|
||||
char c = _s.charAt(_i++);
|
||||
if (c == '"')
|
||||
return b.toString();
|
||||
if (c != '\\')
|
||||
{
|
||||
b.append(c);
|
||||
continue;
|
||||
}
|
||||
if (_i >= _s.length())
|
||||
throw new Exception("escape eof");
|
||||
char e = _s.charAt(_i++);
|
||||
switch (e)
|
||||
{
|
||||
case '"': b.append('"'); break;
|
||||
case '\\': b.append('\\'); break;
|
||||
case '/': b.append('/'); break;
|
||||
case 'b': b.append('\b'); break;
|
||||
case 'f': b.append('\f'); break;
|
||||
case 'n': b.append('\n'); break;
|
||||
case 'r': b.append('\r'); break;
|
||||
case 't': b.append('\t'); break;
|
||||
case 'u':
|
||||
if (_i + 4 > _s.length())
|
||||
throw new Exception("unicode escape eof");
|
||||
b.append((char)Integer.parseInt(_s.substring(_i, _i + 4), 16));
|
||||
_i += 4;
|
||||
break;
|
||||
default: throw new Exception("escape");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Double number() throws Exception
|
||||
{
|
||||
int start = _i;
|
||||
while (_i < _s.length() && "+-.eE0123456789".indexOf(_s.charAt(_i)) >= 0)
|
||||
_i++;
|
||||
if (_i == start)
|
||||
throw new Exception("number");
|
||||
return Double.valueOf(_s.substring(start, _i));
|
||||
}
|
||||
|
||||
private char peek() throws Exception
|
||||
{
|
||||
if (_i >= _s.length())
|
||||
throw new Exception("eof");
|
||||
return _s.charAt(_i);
|
||||
}
|
||||
|
||||
private void expect(String word) throws Exception
|
||||
{
|
||||
if (!_s.startsWith(word, _i))
|
||||
throw new Exception("expected " + word);
|
||||
_i += word.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package juloo.keyboard2;
|
||||
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Sends Roku remote commands from the keyboard.
|
||||
*
|
||||
* Three rules, and they are the whole design:
|
||||
* - the request happens on a worker thread. [Keyboard2.Receiver.handle_event_key] runs
|
||||
* on the main thread, so it must return immediately: doing the network work inline
|
||||
* would freeze the keyboard for as long as the TV takes to answer.
|
||||
* - failure is quiet, exactly like [HostGraph]. No TV, no VPN, a refused connection, a
|
||||
* non-2xx from the proxy or a timeout all end as "nothing happened" - never a crash
|
||||
* and never a wedged key.
|
||||
* - the pressed key stays lit for a moment, from two plain fields that only the draw
|
||||
* path reads. A command against a sleeping Roku legitimately takes ~5 s to be
|
||||
* answered (202 Accepted), and a key that looks dead gets pressed twice.
|
||||
*
|
||||
* The endpoint is Harley's proxy on this host's wg0 address: it passes
|
||||
* /roku/keypress/<Key> straight through to the TV's ECP listener on port 8060.
|
||||
* Cleartext for 10.10.10.1 is already allowed by res/xml/network_security_config.xml.
|
||||
*/
|
||||
public final class RokuRemote
|
||||
{
|
||||
private static final String ENDPOINT = "http://10.10.10.1:7777/roku/keypress/";
|
||||
/** The proxy gives the TV 15 s before it gives up; timing out sooner than the
|
||||
proxy would drop an answer that was still on its way. */
|
||||
private static final int CONNECT_TIMEOUT_MS = 15000;
|
||||
private static final int READ_TIMEOUT_MS = 15000;
|
||||
/** How long the key is lit from the press until an answer arrives. */
|
||||
private static final long PRESSED_MS = 700L;
|
||||
/** ... and how long an accepted command keeps it lit. */
|
||||
private static final long SENT_MS = 1300L;
|
||||
/** A burst of presses must not spawn unbounded threads; past this a press is dropped. */
|
||||
private static final int MAX_IN_FLIGHT = 8;
|
||||
|
||||
/** Which key is lit, and when its light goes out. Plain fields, written by the
|
||||
worker thread and read by the draw path, which allocates nothing for them. */
|
||||
private static volatile KeyValue.Event _litEvent = null;
|
||||
private static volatile long _litUntil = 0L;
|
||||
private static final AtomicInteger _inFlight = new AtomicInteger();
|
||||
|
||||
private RokuRemote() {}
|
||||
|
||||
/** True while [ev] should be drawn as just-pressed. Safe on the draw path. */
|
||||
public static boolean isLit(KeyValue.Event ev, long now)
|
||||
{
|
||||
return ev != null && ev == _litEvent && now < _litUntil;
|
||||
}
|
||||
|
||||
/** The same test for a whole key. [KeyValue.getEvent()] is only valid for its own
|
||||
kind, so this is the single place that reads a key's value and it checks the kind
|
||||
first. Allocates nothing: one enum reference and one long. */
|
||||
public static boolean isLit(KeyValue kv, long now)
|
||||
{
|
||||
return kv != null && kv.getKind() == KeyValue.Kind.Event && isLit(kv.getEvent(), now);
|
||||
}
|
||||
|
||||
/** Send one command. Never blocks the caller and never throws. */
|
||||
public static void press(KeyValue.Event ev)
|
||||
{
|
||||
final String key = ecpKey(ev);
|
||||
if (key == null)
|
||||
return;
|
||||
if (_inFlight.incrementAndGet() > MAX_IN_FLIGHT)
|
||||
{
|
||||
_inFlight.decrementAndGet();
|
||||
return; // fail-soft: a dropped press beats unbounded threads
|
||||
}
|
||||
_litEvent = ev;
|
||||
_litUntil = System.currentTimeMillis() + PRESSED_MS;
|
||||
Thread worker = new Thread(() -> send(ev, key), "roku-remote");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private static void send(KeyValue.Event ev, String key)
|
||||
{
|
||||
boolean accepted = false;
|
||||
HttpURLConnection connection = null;
|
||||
try
|
||||
{
|
||||
connection = (HttpURLConnection)new URL(ENDPOINT + key).openConnection();
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(READ_TIMEOUT_MS);
|
||||
int code = connection.getResponseCode();
|
||||
accepted = code >= 200 && code < 300; // 202 once the TV has taken the keypress
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
// No VPN, no route, TV off, proxy down, unreadable: one answer for all of them.
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (connection != null)
|
||||
connection.disconnect();
|
||||
_inFlight.decrementAndGet();
|
||||
// Only light up if this key is still the one on screen: the user may have moved on.
|
||||
if (accepted && ev == _litEvent)
|
||||
_litUntil = System.currentTimeMillis() + SENT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/** The ECP key names, i.e. what goes after /keypress/. */
|
||||
private static String ecpKey(KeyValue.Event ev)
|
||||
{
|
||||
switch (ev)
|
||||
{
|
||||
case ROKU_HOME: return "Home";
|
||||
case ROKU_BACK: return "Back";
|
||||
case ROKU_UP: return "Up";
|
||||
case ROKU_DOWN: return "Down";
|
||||
case ROKU_LEFT: return "Left";
|
||||
case ROKU_RIGHT: return "Right";
|
||||
case ROKU_SELECT: return "Select";
|
||||
case ROKU_PLAY: return "Play";
|
||||
case ROKU_REWIND: return "Rev"; // the ECP's name for rewind
|
||||
case ROKU_FORWARD: return "Fwd"; // ... and for fast forward
|
||||
case ROKU_POWER: return "Power"; // the ECP's power toggle
|
||||
/* Volume: the same names Harley's own web remote posts to /keypress/. They only
|
||||
do something on a Roku TV (the TV's volume keys), which is what this is. */
|
||||
case ROKU_VOLUP: return "VolumeUp";
|
||||
case ROKU_VOLDOWN: return "VolumeDown";
|
||||
case ROKU_VOLMUTE: return "VolumeMute";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,15 @@ public class Theme
|
||||
public final int subLabelColor;
|
||||
public final int secondaryLabelColor;
|
||||
public final int greyedLabelColor;
|
||||
/** Label colour for the Roku page's direction keys (res/values/themes.xml). */
|
||||
public final int rokuArrowColor;
|
||||
/** The lights page's three colours (res/values/themes.xml): blue for a light that is on, pink
|
||||
for one that is off, and the grey that stands for a light nobody can see. The rule that maps
|
||||
a state to one of them is a single function, [LightsProxy.colorFor]; these are the values it
|
||||
is handed. */
|
||||
public final int lightOnColor;
|
||||
public final int lightOffColor;
|
||||
public final int lightStaleColor;
|
||||
|
||||
// Key borders
|
||||
public final float keyBorderRadius;
|
||||
@@ -58,6 +67,10 @@ public class Theme
|
||||
s.getFloat(R.styleable.keyboard_secondaryDimming, 0.25f));
|
||||
greyedLabelColor = adjustLight(labelColor,
|
||||
s.getFloat(R.styleable.keyboard_greyedDimming, 0.5f));
|
||||
rokuArrowColor = s.getColor(R.styleable.keyboard_colorRokuArrow, labelColor);
|
||||
lightOnColor = s.getColor(R.styleable.keyboard_colorLightOn, rokuArrowColor);
|
||||
lightOffColor = s.getColor(R.styleable.keyboard_colorLightOff, 0xFFFF2D95);
|
||||
lightStaleColor = s.getColor(R.styleable.keyboard_colorLightStale, greyedLabelColor);
|
||||
keyBorderRadius = s.getDimension(R.styleable.keyboard_keyBorderRadius, 0);
|
||||
keyBorderWidth = s.getDimension(R.styleable.keyboard_keyBorderWidth, 0);
|
||||
keyBorderWidthActivated = s.getDimension(R.styleable.keyboard_keyBorderWidthActivated, 0);
|
||||
|
||||
@@ -208,7 +208,11 @@ public final class UsageGauge
|
||||
|
||||
public static boolean isLowBalance(Reading reading)
|
||||
{
|
||||
return reading == null || reading.balance < 1.00;
|
||||
// $1.00 or less is low: the balance sitting exactly on the threshold is
|
||||
// already worth flagging, and the API reports it to two decimals so a
|
||||
// strict < would make "1.00" indistinguishable from "just topped up"
|
||||
// for as long as the balance happens to rest on the dollar.
|
||||
return reading == null || reading.balance <= 1.00;
|
||||
}
|
||||
|
||||
public static synchronized void fetchIfNeeded(String url, String token,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Material Design Icons "remote-tv" - https://materialdesignicons.com/icon/remote-tv - Apache License 2.0, Copyright Pictogrammers; geometry scaled to fill the 512x512 box --><path d="M179.2,0C150.784,0 128,22.784 128,51.2V460.8C128,489.216 150.784,512 179.2,512H332.8C361.216,512 384,489.216 384,460.8V51.2C384,22.784 361.216,0 332.8,0H281.6V51.2H230.4V0H179.2M230.4,102.4H281.6V153.6H332.8V204.8H281.6V256H230.4V204.8H179.2V153.6H230.4V102.4M179.2,307.2H230.4V358.4H179.2V307.2M281.6,307.2H332.8V358.4H281.6V307.2M179.2,409.6H230.4V460.8H179.2V409.6M281.6,409.6H332.8V460.8H281.6V409.6Z"/></svg>
|
||||
|
After Width: | Height: | Size: 662 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Hand-drawn ceiling light with three beams, for the lights page key (0xE025). Same style as the other committed glyphs: one path, several solidsubpaths, solid fill, and ink that fills the 512x512 document box so the importer maps it to the whole em square (a glyph whose ink sits in a small part of its viewBox renders small). Mount plate, tapered shade, then three beams fanning down: left, straight, right. --><path d="M128,4 L384,4 L384,44 L128,44 Z M16,80 L496,80 L400,272 L112,272 Z M168,272 L224,272 L152,500 L80,500 Z M236,272 L276,272 L276,500 L236,500 Z M288,272 L344,272 L432,500 L360,500 Z"/></svg>
|
||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- The unlit twin of the lamp at 0xE025 (srcs/special_font/025.svg): the same mount plate and the same tapered shade, drawn from exactly the same subpaths, with no beams below - the lit/unlit pair differs only in the light coming out. Single path, solid fill, in the style of the other committed glyphs. --><path d="M128,4 L384,4 L384,44 L128,44 Z M16,80 L496,80 L400,272 L112,272 Z"/></svg>
|
||||
|
After Width: | Height: | Size: 456 B |
@@ -11,14 +11,16 @@ public class HostGraphTest
|
||||
private static final String GOOD = "{\"ts\": \"2026-09-27T18:30:32+00:00\", \"host\": \"utumno\", "
|
||||
+ "\"version\": \"0.1.0\", \"window_s\": 6, \"series\": ["
|
||||
+ "{\"name\": \"io\", \"unit\": \"%\", \"values\": [0.0, 0.0, 13.6, 2.9, 6.0]}, "
|
||||
+ "{\"name\": \"cpu\", \"unit\": \"%\", \"values\": [0.0, 0.0, 0.1, 0.9, 0.2]}]}";
|
||||
+ "{\"name\": \"cpu\", \"unit\": \"%\", \"values\": [0.0, 0.0, 0.1, 0.9, 0.2]}, "
|
||||
+ "{\"name\": \"util\", \"unit\": \"%\", \"values\": [0.0, 50.0, 4.7, 4.7, 4.3]}]}";
|
||||
|
||||
@Test
|
||||
public void realPayloadParses()
|
||||
{
|
||||
float[][] parsed = HostGraph.parse(GOOD);
|
||||
assertNotNull(parsed);
|
||||
assertEquals(2, parsed.length);
|
||||
assertEquals(3, parsed.length);
|
||||
assertEquals("util", HostGraph.SERIES[2]);
|
||||
assertEquals(5, parsed[0].length);
|
||||
assertEquals(13.6f, parsed[0][2], 0.001f);
|
||||
assertEquals(0.2f, parsed[1][4], 0.001f);
|
||||
@@ -46,26 +48,52 @@ public class HostGraphTest
|
||||
public void emptyValuesAreNoData()
|
||||
{
|
||||
assertNull(HostGraph.parse("{\"series\": [{\"name\": \"io\", \"values\": []}, "
|
||||
+ "{\"name\": \"cpu\", \"values\": [1.0]}]}"));
|
||||
+ "{\"name\": \"cpu\", \"values\": [1.0]}, "
|
||||
+ "{\"name\": \"util\", \"values\": [1.0]}]}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aJunkNumberIsNoData()
|
||||
{
|
||||
assertNull(HostGraph.parse("{\"series\": [{\"name\": \"io\", \"values\": [1.0, abc]}, "
|
||||
+ "{\"name\": \"cpu\", \"values\": [1.0]}]}"));
|
||||
+ "{\"name\": \"cpu\", \"values\": [1.0]}, "
|
||||
+ "{\"name\": \"util\", \"values\": [1.0]}]}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aShortWindowStillParses()
|
||||
{
|
||||
float[][] parsed = HostGraph.parse("{\"series\": [{\"name\": \"io\", \"values\": [3.5]}, "
|
||||
+ "{\"name\": \"cpu\", \"values\": [0.5]}]}");
|
||||
+ "{\"name\": \"cpu\", \"values\": [0.5]}, "
|
||||
+ "{\"name\": \"util\", \"values\": [12.5]}]}");
|
||||
assertNotNull(parsed);
|
||||
assertEquals(1, parsed[0].length);
|
||||
assertEquals(3.5f, parsed[0][0], 0.001f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theGraphRidesTheSamePreferenceAsUsage()
|
||||
{
|
||||
assertEquals("http://10.10.10.1:8771", HostGraph.baseOf("http://10.10.10.1:8771/usage"));
|
||||
assertEquals("http://10.10.10.1:8771", HostGraph.baseOf("http://10.10.10.1:8771"));
|
||||
assertEquals("https://example.test:9000", HostGraph.baseOf("https://example.test:9000/usage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void baseOfSurvivesNonsense()
|
||||
{
|
||||
assertEquals("", HostGraph.baseOf(null));
|
||||
assertEquals("garbage", HostGraph.baseOf("garbage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aServerTooOldToSendUtilisationIsNoData()
|
||||
{
|
||||
// Fails safe rather than loud: a payload missing a series we draw is not trusted.
|
||||
assertNull(HostGraph.parse("{\"series\": [{\"name\": \"io\", \"values\": [1.0]}, "
|
||||
+ "{\"name\": \"cpu\", \"values\": [1.0]}]}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nothingIsDrawnBeforeAnyDataArrives()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package juloo.keyboard2;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/** The lights page: the state parsing the page is driven by ([LightsProxy]), and the page itself
|
||||
([LightsPage]). Android classes cannot be constructed in a plain unit test, so the drawing is
|
||||
covered where it is pure - the colour mapping, the glyph choice, the label - and the theme
|
||||
wiring is checked by reading res/values/themes.xml and the built APK instead. */
|
||||
public class LightsProxyTest
|
||||
{
|
||||
// ------------------------------------------------------------------ parsing a /lights answer
|
||||
|
||||
@Test
|
||||
public void theAnswerIsParsedIntoOneLightPerEntry()
|
||||
{
|
||||
LightsProxy.Light[] lights = LightsProxy.parseLights(
|
||||
"{\"ok\": true, \"lights\": ["
|
||||
+ "{\"id\": \"bedroom\", \"name\": \"Bedroom\", \"entity_id\": \"light.bedroom\","
|
||||
+ " \"state\": \"on\", \"optimistic_state\": null, \"warning\": null},"
|
||||
+ "{\"id\": \"stairs\", \"name\": \"Stairs\", \"entity_id\": \"light.stairs\","
|
||||
+ " \"state\": \"off\", \"optimistic_state\": null, \"warning\": null}"
|
||||
+ "]}");
|
||||
assertNotNull(lights);
|
||||
assertEquals(2, lights.length);
|
||||
assertEquals("bedroom", lights[0].id);
|
||||
assertEquals("Bedroom", lights[0].name);
|
||||
assertEquals("light.bedroom", lights[0].entityId);
|
||||
assertEquals(LightsProxy.State.ON, lights[0].state);
|
||||
assertEquals(LightsProxy.State.OFF, lights[1].state);
|
||||
assertNull("a read-back state needs no note", lights[0].note);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unavailableAndUnknownAreTheirOwnStatesAndNeverOff()
|
||||
{
|
||||
LightsProxy.Light[] lights = LightsProxy.parseLights(
|
||||
"{\"lights\": ["
|
||||
+ "{\"id\": \"a\", \"name\": \"A\", \"state\": \"unavailable\"},"
|
||||
+ "{\"id\": \"b\", \"name\": \"B\", \"state\": \"unknown\"},"
|
||||
+ "{\"id\": \"c\", \"name\": \"C\", \"state\": \" On \"},"
|
||||
+ "{\"id\": \"d\", \"name\": \"D\", \"state\": \"nonsense\"},"
|
||||
+ "{\"id\": \"e\", \"name\": \"E\"},"
|
||||
+ "{\"id\": \"f\", \"name\": \"F\", \"state\": \"OFF\"}"
|
||||
+ "]}");
|
||||
assertNotNull(lights);
|
||||
assertEquals(LightsProxy.State.UNKNOWN, lights[0].state);
|
||||
assertEquals(LightsProxy.State.UNKNOWN, lights[1].state);
|
||||
assertEquals(LightsProxy.State.ON, lights[2].state);
|
||||
assertEquals(LightsProxy.State.UNKNOWN, lights[3].state);
|
||||
assertEquals(LightsProxy.State.UNKNOWN, lights[4].state);
|
||||
assertEquals(LightsProxy.State.OFF, lights[5].state);
|
||||
for (LightsProxy.Light l : lights)
|
||||
assertFalse("nothing unreadable may be reported as switched off",
|
||||
l.state == LightsProxy.State.OFF && !l.id.equals("f"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fieldsTheKeyDoesNotShowNeverBecomeItsLabel()
|
||||
{
|
||||
// A registry entry carries more than the page uses - when it last changed, a read age, and the
|
||||
// service's error code. None of it may take the place of the light's name: while no token is in
|
||||
// place every light is unavailable, and the page would otherwise say "no_token" twelve times
|
||||
// instead of naming the lights. The stale colour already says nobody can see them.
|
||||
LightsProxy.Light[] lights = LightsProxy.parseLights(
|
||||
"{\"lights\": ["
|
||||
+ "{\"id\": \"bedroom\", \"name\": \"Bedroom\", \"state\": \"unavailable\","
|
||||
+ " \"last_changed\": \"2026-09-28T02:00:00Z\", \"age_s\": 42,"
|
||||
+ " \"error\": {\"code\": \"no_token\", \"detail\": \"no ha token configured\"},"
|
||||
+ " \"warning\": \"state could not be confirmed\"}"
|
||||
+ "]}");
|
||||
assertNotNull(lights);
|
||||
assertEquals("Bedroom", lights[0].name);
|
||||
assertNull("the answer to a read is not a note on the key", lights[0].note);
|
||||
assertEquals("an unreachable light is the stale look, never off",
|
||||
LightsProxy.State.UNKNOWN, lights[0].state);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anEntryForALightTheBoxCannotSeeKeepsItsName()
|
||||
{
|
||||
// A light whose entity is unreachable comes back with an error object instead of state.
|
||||
LightsProxy.Light[] lights = LightsProxy.parseLights(
|
||||
"{\"lights\": ["
|
||||
+ "{\"id\": \"tv-room\", \"name\": \"TV room\", \"entity_id\": \"light.tv_room\","
|
||||
+ " \"state\": \"unavailable\", \"error\": {\"code\": \"unreachable\", \"detail\": \"http\"}}"
|
||||
+ "]}");
|
||||
assertNotNull(lights);
|
||||
assertEquals("TV room", lights[0].name);
|
||||
assertEquals(LightsProxy.State.UNKNOWN, lights[0].state);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theAnswerIsReadWhateverTheLightWeightAndEscaping()
|
||||
{
|
||||
// Whitespace and escaped quotes inside a name must not derail the reader; the fields the page
|
||||
// does not use (numbers, booleans, nested objects) are skipped harmlessly.
|
||||
LightsProxy.Light[] lights = LightsProxy.parseLights(
|
||||
"{ \"lights\" : [ {\n \"id\":\"a\",\n \"name\": \"Say \\\"hi\\\"\",\n \"brightness\": 128,\n"
|
||||
+ " \"on\": true,\n \"attrs\": {\"a\": [1, 2, {\"b\": null}]},\n \"state\": \"on\" } ] }");
|
||||
assertNotNull(lights);
|
||||
assertEquals(1, lights.length);
|
||||
assertEquals("Say \"hi\"", lights[0].name);
|
||||
assertEquals(LightsProxy.State.ON, lights[0].state);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anEmptyWhitelistIsNoLightsNotNoAnswer()
|
||||
{
|
||||
LightsProxy.Light[] none = LightsProxy.parseLights("{\"lights\": []}");
|
||||
assertNotNull("an empty list is an answer: the page has nothing to show", none);
|
||||
assertEquals(0, none.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aBrokenOrUnexpectedAnswerIsNoDataNotZeroLights()
|
||||
{
|
||||
assertNull(LightsProxy.parseLights(null));
|
||||
assertNull(LightsProxy.parseLights(""));
|
||||
assertNull(LightsProxy.parseLights("<html>404</html>"));
|
||||
assertNull(LightsProxy.parseLights("[1, 2, 3]"));
|
||||
assertNull(LightsProxy.parseLights("{\"lights\": [{\"id\": \"a\""));
|
||||
assertNull(LightsProxy.parseLights("{\"logs\": []}"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ parsing a toggle answer
|
||||
|
||||
@Test
|
||||
public void aToggleThatWasReadBackReportsTheStateTheBoxGave()
|
||||
{
|
||||
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
|
||||
"{\"ok\": true, \"id\": \"bedroom\", \"accepted\": true, \"state\": \"off\","
|
||||
+ " \"confirmed\": true, \"optimistic_state\": \"off\", \"warning\": null}");
|
||||
assertNotNull(r);
|
||||
assertTrue(r.accepted);
|
||||
assertTrue(r.confirmed);
|
||||
assertEquals("the read-back, as Home Assistant gave it", "off", r.state);
|
||||
assertEquals(LightsProxy.State.OFF, LightsProxy.stateOf(r.state));
|
||||
assertNull("a read-back state needs no warning on the key", r.note());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aToggleTheBoxRefusedIsNotAState()
|
||||
{
|
||||
// The proxy refuses when Home Assistant cannot be reached, or when the token is missing - which
|
||||
// is the state the endpoint is in until a token is put in place. The key must say why, and must
|
||||
// NOT show the light as off.
|
||||
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
|
||||
"{\"ok\": false, \"id\": \"bedroom\", \"accepted\": false, \"state\": null,"
|
||||
+ " \"confirmed\": false, \"optimistic_state\": null, \"retried\": false,"
|
||||
+ " \"error\": {\"code\": \"no_token\", \"detail\": \"no ha token configured\"}}");
|
||||
assertNotNull(r);
|
||||
assertFalse(r.accepted);
|
||||
assertFalse(r.confirmed);
|
||||
assertNull("a refused toggle says nothing about the state", r.state);
|
||||
assertEquals("the service's own reason is what the key says", "no_token", r.note());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theRefusalOfAServiceIsTheReasonTheKeyShows()
|
||||
{
|
||||
// The refusal the running service actually gives, recorded from
|
||||
// POST http://127.0.0.1:8772/lights/no-such-light/toggle (HTTP 404): a flat code and a prose
|
||||
// reason, and no state at all - so the key must show the code, not a guess.
|
||||
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
|
||||
"{\"ts\": \"2026-09-28T03:14:51Z\", \"error\": \"unknown id\", \"code\": \"unknown_id\","
|
||||
+ " \"id\": \"no-such-light\", \"known_ids\": [\"bedroom\"], \"detail\": \"only ids listed"
|
||||
+ " in lights.toml can be toggled\", \"accepted\": false, \"toggled\": false}");
|
||||
assertNotNull(r);
|
||||
assertFalse(r.accepted);
|
||||
assertNull(r.state);
|
||||
assertEquals("unknown_id", r.note());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aToggleNobodyCouldReadBackIsNotClaimedAsSuccess()
|
||||
{
|
||||
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
|
||||
"{\"ok\": true, \"id\": \"bedroom\", \"accepted\": true, \"state\": null,"
|
||||
+ " \"confirmed\": false, \"optimistic_state\": \"on\","
|
||||
+ " \"warning\": \"state could not be confirmed after 10s\"}");
|
||||
assertNotNull(r);
|
||||
assertTrue(r.accepted);
|
||||
assertFalse("nothing was read back, so nothing is confirmed", r.confirmed);
|
||||
assertNull("the prediction is not the state", r.state);
|
||||
assertEquals("the prose is kept, but a key says the short form of it",
|
||||
"state could not be confirmed after 10s", r.warning);
|
||||
assertEquals("unconfirmed", r.note());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aPredictionIsNeverMistakenForAState()
|
||||
{
|
||||
// optimistic_state is what the proxy thinks the light will be; only `state` is read back.
|
||||
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
|
||||
"{\"accepted\": true, \"id\": \"a\", \"optimistic_state\": \"on\", \"state\": null,"
|
||||
+ " \"confirmed\": false}");
|
||||
assertNotNull(r);
|
||||
assertNull(r.state);
|
||||
assertNotNull("an unconfirmed toggle must say something", r.note());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aBrokenToggleAnswerIsNoAnswer()
|
||||
{
|
||||
assertNull(LightsProxy.parseToggle(null));
|
||||
assertNull(LightsProxy.parseToggle(""));
|
||||
assertNull(LightsProxy.parseToggle("nonsense"));
|
||||
assertNull("a body that says neither yes, no, nor what it saw", LightsProxy.parseToggle("{}"));
|
||||
assertNull(LightsProxy.parseToggle("{\"id\": \"a\"}"));
|
||||
assertNull(LightsProxy.parseToggle("{\"ok\": true, \"retried\": false}"));
|
||||
assertNull(LightsProxy.parseToggle("[1, 2]"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the colour rule
|
||||
|
||||
@Test
|
||||
public void theColourRuleIsOnBlueOffPinkAndEverythingElseStale()
|
||||
{
|
||||
// The rule, all of it, in one place. The three colours come from the theme; these are the same
|
||||
// values res/values/themes.xml carries (colorLightOn is the Roku arrows' #00E5FF).
|
||||
final int blue = 0xFF00E5FF, pink = 0xFFFF2D95, grey = 0xFF8A8D93;
|
||||
assertEquals(blue, LightsProxy.colorFor(LightsProxy.State.ON, blue, pink, grey));
|
||||
assertEquals(pink, LightsProxy.colorFor(LightsProxy.State.OFF, blue, pink, grey));
|
||||
assertEquals("an unreachable light is neither colour",
|
||||
grey, LightsProxy.colorFor(LightsProxy.State.UNKNOWN, blue, pink, grey));
|
||||
assertEquals("and neither is one nobody has heard about",
|
||||
grey, LightsProxy.colorFor(null, blue, pink, grey));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theStateGlyphIsTheLitLampOnlyWhenTheLightIsOn()
|
||||
{
|
||||
assertEquals(0xE025, LightsPage.glyphFor(LightsProxy.State.ON).charAt(0));
|
||||
assertEquals(0xE026, LightsPage.glyphFor(LightsProxy.State.OFF).charAt(0));
|
||||
assertEquals("an unreachable light must not look lit",
|
||||
0xE026, LightsPage.glyphFor(LightsProxy.State.UNKNOWN).charAt(0));
|
||||
assertEquals(0xE026, LightsPage.glyphFor(null).charAt(0));
|
||||
assertEquals(1, LightsPage.glyphFor(LightsProxy.State.ON).length());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the labels
|
||||
|
||||
@Test
|
||||
public void theLabelIsTheRegistryNameNotSomethingShorter()
|
||||
{
|
||||
// The short names the keyboard shows are the registry's own; there is no mapping from a light
|
||||
// to a label on this side, so whatever the endpoint returns is what a key says.
|
||||
assertEquals("Bedroom", LightsPage.displayName("Bedroom"));
|
||||
assertEquals("TV room", LightsPage.displayName("TV room"));
|
||||
assertEquals("Stairs", LightsPage.displayName("Stairs"));
|
||||
assertEquals("space around a name is not part of it", "Bedroom",
|
||||
LightsPage.displayName(" Bedroom\n"));
|
||||
assertEquals("", LightsPage.displayName(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aNameTooLongForItsKeyIsCutVisibly()
|
||||
{
|
||||
assertEquals("Basement Stairs Light", LightsPage.ellipsize("Basement Stairs Light", 32));
|
||||
String cut = LightsPage.displayName("Basement Stairs Light");
|
||||
assertTrue("a cut label is marked as cut: " + cut, cut.endsWith("…"));
|
||||
assertEquals(LightsPage.MAX_NAME_CHARS, cut.length());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the page
|
||||
|
||||
@Test
|
||||
public void oneKeyPerLightThreeToARow()
|
||||
{
|
||||
KeyboardData page = LightsPage.build(template(), lights(4));
|
||||
assertEquals("3 lights, 1 stretched to the whole width, and the Keyboard key",
|
||||
3, page.rows.size());
|
||||
assertEquals(3, page.rows.get(0).keys.size());
|
||||
assertEquals(1, page.rows.get(1).keys.size());
|
||||
assertEquals(1, page.rows.get(2).keys.size());
|
||||
assertEquals("a full row of three is the whole width", 9.9f, page.rows.get(0).keysWidth, 0.001f);
|
||||
assertEquals(3.3f, page.rows.get(0).keys.get(0).width, 0.001f);
|
||||
assertEquals("the short row fills the keyboard", 9.9f, page.rows.get(1).keysWidth, 0.001f);
|
||||
assertEquals("one key stretched to the whole width", 9.9f, page.rows.get(1).keys.get(0).width,
|
||||
0.001f);
|
||||
assertEquals("the way back is the template's own key", 9.9f,
|
||||
page.rows.get(2).keys.get(0).width, 0.001f);
|
||||
assertEquals("Keyboard", page.rows.get(2).keys.get(0).keys[0].getString());
|
||||
assertEquals(KeyValue.Event.SWITCH_TEXT, page.rows.get(2).keys.get(0).keys[0].getEvent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyKeyShowsItsLightNameAndItsStateGlyph()
|
||||
{
|
||||
KeyboardData page = LightsPage.build(template(), lights(3));
|
||||
assertEquals("the name the registry gave", "Bedroom",
|
||||
page.rows.get(0).keys.get(0).keys[0].getString());
|
||||
assertEquals("TV room", page.rows.get(0).keys.get(2).keys[0].getString());
|
||||
// The name and the glyph sit on the same key and press the same light; the glyph is the value
|
||||
// drawn in the special font, which is what tells the two apart.
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
KeyboardData.Key k = page.rows.get(0).keys.get(i);
|
||||
KeyValue name = k.keys[0], glyph = k.keys[LightsPage.GLYPH_SUB_INDEX];
|
||||
assertTrue(LightsPage.isLightKey(name));
|
||||
assertTrue(LightsPage.isStateGlyph(glyph));
|
||||
assertFalse("the name is not the glyph", LightsPage.isStateGlyph(name));
|
||||
assertEquals(LightsPage.eventFor(i + 1), name.getEvent());
|
||||
assertEquals("both values press the same light", name.getEvent(), glyph.getEvent());
|
||||
assertEquals(KeyValue.Kind.Event, name.getKind());
|
||||
assertEquals("the stored glyph never claims a light is on",
|
||||
(char)0xE026, glyph.getString().charAt(0));
|
||||
assertEquals("the name is drawn in the ordinary font, not the glyph font",
|
||||
false, name.hasFlagsAny(KeyValue.FLAG_KEY_FONT));
|
||||
}
|
||||
assertEquals(KeyValue.Event.LIGHT_3, page.rows.get(0).keys.get(2).keys[0].getEvent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void withNoAnswerYetThePlaceholdersStand()
|
||||
{
|
||||
KeyboardData page = LightsPage.build(template(), null);
|
||||
assertEquals(2, page.rows.size());
|
||||
assertEquals(3, page.rows.get(0).keys.size());
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
KeyboardData.Key k = page.rows.get(0).keys.get(i);
|
||||
assertEquals(LightsPage.PLACEHOLDER, k.keys[0].getString());
|
||||
assertTrue("a slot before the first answer still carries the unlit lamp",
|
||||
LightsPage.isStateGlyph(k.keys[LightsPage.GLYPH_SUB_INDEX]));
|
||||
}
|
||||
// Nothing has been heard from the proxy, so nothing is ON and the page is stale.
|
||||
assertTrue(LightsProxy.isStaleNow());
|
||||
assertEquals(LightsProxy.State.UNKNOWN, LightsProxy.stateAt(1));
|
||||
assertEquals(LightsPage.glyphFor(LightsProxy.State.UNKNOWN),
|
||||
LightsPage.glyphFor(LightsProxy.stateAt(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildingThePageLeavesTheTemplateAlone()
|
||||
{
|
||||
KeyboardData tpl = template();
|
||||
LightsPage.build(tpl, lights(5));
|
||||
assertEquals("KeyboardData.load caches the template, so it must survive being built from",
|
||||
2, tpl.rows.size());
|
||||
assertEquals(3, tpl.rows.get(0).keys.size());
|
||||
assertEquals("the template's slot still points at its own light", KeyValue.Event.LIGHT_1,
|
||||
tpl.rows.get(0).keys.get(0).keys[0].getEvent());
|
||||
assertEquals(LightsPage.PLACEHOLDER, tpl.rows.get(0).keys.get(0).keys[0].getString());
|
||||
assertNull("and the template's corner is still empty",
|
||||
tpl.rows.get(0).keys.get(0).keys[LightsPage.GLYPH_SUB_INDEX]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void moreLightsThanSlotsIsSaidRatherThanDropped()
|
||||
{
|
||||
LightsProxy.Light[] many = lights(LightsPage.MAX_LIGHTS + 1);
|
||||
KeyboardData page = LightsPage.build(template(), many);
|
||||
assertEquals(5, page.rows.size());
|
||||
String last = page.rows.get(3).keys.get(2).keys[0].getString();
|
||||
assertTrue("the last key must say how many lights it cannot show: " + last, last.endsWith("+1"));
|
||||
assertTrue(last.length() <= LightsPage.MAX_NAME_CHARS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aKeyThePageDoesNotOwnIsNotALightsKey()
|
||||
{
|
||||
KeyValue door = KeyValue.getSpecialKeyByName("switch_lights");
|
||||
KeyValue back = KeyValue.getSpecialKeyByName("lights_close");
|
||||
assertFalse(LightsPage.isLightKey(door));
|
||||
assertFalse(LightsPage.isStateGlyph(door));
|
||||
assertFalse(LightsPage.isLightKey(back));
|
||||
assertFalse(LightsPage.isLightKey(null));
|
||||
assertEquals(0, LightsPage.slotOf(null));
|
||||
assertEquals(0, LightsPage.slotOf(back.getEvent()));
|
||||
assertEquals((char)0xE025, door.getString().charAt(0));
|
||||
assertTrue("the door is drawn in the special font", door.hasFlagsAny(KeyValue.FLAG_KEY_FONT));
|
||||
assertEquals("and the way back is the same key as the Roku page's",
|
||||
KeyValue.Event.SWITCH_TEXT, back.getEvent());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ what a key says
|
||||
|
||||
@Test
|
||||
public void aNoteSurvivesARefreshUntilTheBoxReportsAState()
|
||||
{
|
||||
LightsProxy.Light noted = new LightsProxy.Light("a", "A", "switch.a",
|
||||
LightsProxy.State.UNKNOWN, "no_token", 1000L);
|
||||
LightsProxy.Light[] previous = { noted };
|
||||
// A refresh that still cannot read a state keeps the last answer ...
|
||||
LightsProxy.Light stillUnknown = new LightsProxy.Light("a", "A", "switch.a",
|
||||
LightsProxy.State.UNKNOWN, null, 0L);
|
||||
assertEquals("no_token", LightsProxy.mergeNote(stillUnknown, previous).note);
|
||||
// ... a real state from the box supersedes it ...
|
||||
LightsProxy.Light known = new LightsProxy.Light("a", "A", "switch.a",
|
||||
LightsProxy.State.ON, null, 0L);
|
||||
assertNull(LightsProxy.mergeNote(known, previous).note);
|
||||
// ... a light that is not in the previous list stands on its own ...
|
||||
assertNull(LightsProxy.mergeNote(stillUnknown, new LightsProxy.Light[0]).note);
|
||||
// ... and the sentence expires, so an old answer cannot pass for the current state.
|
||||
assertNotNull(noted.noteOrNull(1000L + LightsProxy.NOTE_MS - 1));
|
||||
assertNull(noted.noteOrNull(1000L + LightsProxy.NOTE_MS));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
/** The page skeleton, built by hand to the same shape as res/xml/lights.xml. */
|
||||
private static KeyboardData template()
|
||||
{
|
||||
List<KeyboardData.Key> slots = new ArrayList<KeyboardData.Key>();
|
||||
for (int i = 1; i <= LightsPage.SLOTS_PER_ROW; i++)
|
||||
slots.add(key(KeyValue.getSpecialKeyByName("light_" + i), 3.3f, 0f));
|
||||
List<KeyboardData.Key> close = new ArrayList<KeyboardData.Key>();
|
||||
close.add(key(KeyValue.getSpecialKeyByName("lights_close"), 9.9f, 0f));
|
||||
List<KeyboardData.Row> rows = new ArrayList<KeyboardData.Row>();
|
||||
rows.add(new KeyboardData.Row(slots, 1.0f, 0f));
|
||||
rows.add(new KeyboardData.Row(close, 1.0f, 0f));
|
||||
return new KeyboardData(rows, 9.9f, null, null, null, "lights", false, false, false);
|
||||
}
|
||||
|
||||
private static KeyboardData.Key key(KeyValue kv, float width, float shift)
|
||||
{
|
||||
KeyValue[] ks = new KeyValue[9];
|
||||
ks[0] = kv;
|
||||
return new KeyboardData.Key(ks, null, 0, width, shift, null, KeyboardData.Key.Role.Action);
|
||||
}
|
||||
|
||||
/** [n] lights with short registry-style names, each row of three named in turn. */
|
||||
private static LightsProxy.Light[] lights(int n)
|
||||
{
|
||||
String[] words = { "Bedroom", "Stairs", "TV room", "Light Four", "Light Five", "Light Six",
|
||||
"Light Seven", "Light Eight", "Light Nine", "Light Ten", "Light Eleven", "Light Twelve",
|
||||
"Light Thirteen" };
|
||||
LightsProxy.Light[] out = new LightsProxy.Light[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
out[i] = new LightsProxy.Light("id" + i, words[i], "switch." + i,
|
||||
i % 2 == 0 ? LightsProxy.State.ON : LightsProxy.State.OFF, null, 0L);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -50,11 +50,15 @@ public class UsageGaugeTest
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowBalanceWarningIsStrictlyBelowOneDollar()
|
||||
public void lowBalanceWarningFiresAtOneDollarOrLess()
|
||||
{
|
||||
assertTrue(UsageGauge.isLowBalance(UsageGauge.parse(RESPONSE)));
|
||||
assertFalse(UsageGauge.isLowBalance(UsageGauge.parse(
|
||||
// exactly $1.00 counts as low, so the boundary is inclusive
|
||||
assertTrue(UsageGauge.isLowBalance(UsageGauge.parse(
|
||||
RESPONSE.replace("0.99", "1.00"))));
|
||||
// and one cent above it must not
|
||||
assertFalse(UsageGauge.isLowBalance(UsageGauge.parse(
|
||||
RESPONSE.replace("0.99", "1.01"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user