Files
morns-keyboard-basic/srcs/juloo.keyboard2/Keyboard2.java
T
nyra 08c895aa12 Add live usage gauge key (GPT gradient + DeepSeek spend) beside L
- UsageGauge: WireGuard-only JSON fetch, cached, background thread, stale-aware
- Gauge key: gradient fill rises with the tightest Codex window, %.1f of today's
  DeepSeek spend, red below a $1.00 balance, flat grey when stale/offline
- Display-only: marker lives in the key indication field, no character, touch swallowed
- Settings entries; cleartext permitted only for 10.10.10.1
- ComposeKey: composing to U+00A0 keeps the named nbsp key, since adding a layout
  key perturbs the generated compose table and would otherwise emit the display glyph
- 5 new unit tests; all 21 tests pass
2026-09-27 17:38:18 +00:00

581 lines
18 KiB
Java

package juloo.keyboard2;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.graphics.drawable.Drawable;
import android.inputmethodservice.InputMethodService;
import android.os.Build.VERSION;
import android.os.Handler;
import android.os.IBinder;
import android.text.InputType;
import android.util.Log;
import android.util.LogPrinter;
import android.view.*;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
import android.view.inputmethod.InputMethodSubtype;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import java.util.AbstractMap.SimpleEntry;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import juloo.cdict.Cdict;
import juloo.keyboard2.dict.Dictionaries;
import juloo.keyboard2.dict.DictionariesActivity;
import juloo.keyboard2.prefs.LayoutsPreference;
import juloo.keyboard2.suggestions.CandidatesView;
import juloo.keyboard2.suggestions.Suggestions;
public class Keyboard2 extends InputMethodService
implements SharedPreferences.OnSharedPreferenceChangeListener
{
/** The view containing the keyboard and candidates view. */
private ViewGroup _keyboard_container_view;
private Keyboard2View _keyboard_layout_view;
private CandidatesView _candidates_view;
private Suggestions _suggestions;
private KeyEventHandler _keyeventhandler;
/** If not 'null', the layout to use instead of [_config.current_layout]. */
private KeyboardData _currentSpecialLayout;
/** Layout associated with the currently selected locale. Not 'null'. */
private KeyboardData _localeTextLayout;
/** Installed and current locales. */
private Dictionaries _dictionaries;
private ViewGroup _emojiPane = null;
private ViewGroup _clipboard_pane = null;
private Handler _handler;
private final Runnable _peakIndicatorRefresh = new Runnable()
{
@Override
public void run()
{
if (_keyboard_layout_view != null)
_keyboard_layout_view.invalidate();
_handler.postDelayed(this, 60000);
}
};
private Config _config;
private FoldStateTracker _foldStateTracker;
/** Layout currently visible before it has been modified. */
KeyboardData current_layout_unmodified()
{
if (_currentSpecialLayout != null)
return _currentSpecialLayout;
KeyboardData layout = null;
int layout_i = _config.get_current_layout();
if (layout_i >= _config.layouts.size())
layout_i = 0;
if (layout_i < _config.layouts.size())
layout = _config.layouts.get(layout_i);
if (layout == null)
layout = _localeTextLayout;
return layout;
}
/** Layout currently visible. */
KeyboardData current_layout()
{
if (_currentSpecialLayout != null)
return _currentSpecialLayout;
return LayoutModifier.modify_layout(current_layout_unmodified());
}
void setTextLayout(int l)
{
_config.set_current_layout(l);
_currentSpecialLayout = null;
_keyboard_layout_view.setKeyboard(current_layout());
}
void incrTextLayout(int delta)
{
int s = _config.layouts.size();
setTextLayout((_config.get_current_layout() + delta + s) % s);
}
void setSpecialLayout(KeyboardData l)
{
_currentSpecialLayout = l;
_keyboard_layout_view.setKeyboard(l);
}
KeyboardData loadLayout(int layout_id)
{
return KeyboardData.load(getResources(), layout_id);
}
/** Load a layout that contains a numpad. */
KeyboardData loadNumpad(int layout_id)
{
return LayoutModifier.modify_numpad(KeyboardData.load(getResources(), layout_id),
current_layout_unmodified());
}
KeyboardData loadNumericLayout()
{
return loadNumpad(_config.orientation_landscape ?
R.xml.numeric_landscape : R.xml.numeric);
}
KeyboardData loadFnLayout()
{
return LayoutModifier.modify_layout(
KeyboardData.load(getResources(), R.xml.fn));
}
KeyboardData loadPinentry(int layout_id)
{
return LayoutModifier.modify_pinentry(KeyboardData.load(getResources(), layout_id),
current_layout_unmodified());
}
@Override
public void onCreate()
{
super.onCreate();
SharedPreferences prefs = DirectBootAwarePreferences.get_shared_preferences(this);
_handler = new Handler(getMainLooper());
_foldStateTracker = new FoldStateTracker(this);
_dictionaries = Dictionaries.instance(this);
Config.initGlobalConfig(prefs, getResources(),
_foldStateTracker.isUnfolded(), _dictionaries);
_config = Config.globalConfig();
Receiver recvr = this.new Receiver();
_suggestions = new Suggestions(recvr, _config);
_keyeventhandler = new KeyEventHandler(recvr, _suggestions);
KeyValue.Stateful._handler = recvr;
_config.handler = _keyeventhandler;
prefs.registerOnSharedPreferenceChangeListener(this);
Logs.set_debug_logs(getResources().getBoolean(R.bool.debug_logs));
refreshSubtypeImm();
create_keyboard_view();
ClipboardHistoryService.on_startup(this, _keyeventhandler);
_foldStateTracker.setChangedCallback(() -> { refresh_config(); });
}
@Override
public void onDestroy() {
_handler.removeCallbacks(_peakIndicatorRefresh);
super.onDestroy();
_foldStateTracker.close();
}
private void create_keyboard_view()
{
_keyboard_container_view = (ViewGroup)inflate_view(R.layout.keyboard);
_keyboard_layout_view = (Keyboard2View)_keyboard_container_view.findViewById(R.id.keyboard_view);
_candidates_view = (CandidatesView)_keyboard_container_view.findViewById(R.id.candidates_view);
}
InputMethodManager get_imm()
{
return (InputMethodManager)getSystemService(INPUT_METHOD_SERVICE);
}
private void refreshSubtypeImm()
{
_config.shouldOfferVoiceTyping = true;
KeyboardData default_layout = null;
_config.device_locales = DeviceLocales.load(this);
if (_config.device_locales.default_ != null)
{
String layout_name = _config.device_locales.default_.default_layout;
if (layout_name != null)
default_layout = LayoutsPreference.layout_of_string(getResources(), layout_name);
}
_config.extra_keys_subtype = _config.device_locales.extra_keys();
if (default_layout == null)
default_layout = loadLayout(R.xml.latn_qwerty_us);
_localeTextLayout = default_layout;
}
private void refresh_current_dictionary()
{
_config.current_dictionary = null;
_config.emoji_dictionary = null;
if (_config.device_locales.default_ == null)
return;
String current = _config.device_locales.default_.dictionary;
if (current == null)
return;
Cdict[] dicts = _dictionaries.load(current);
if (dicts == null)
return;
_config.current_dictionary = Dictionaries.find_by_name(dicts, "main");
_config.emoji_dictionary = Dictionaries.find_by_name(dicts, "emoji");
}
private void refresh_candidates_view()
{
boolean should_show =
_config.suggestions_enabled
&& _config.editor_config.should_show_candidates_view
&& !_config.split_layout;
if (should_show)
_candidates_view.refresh_config(_config);
_candidates_view.setVisibility(should_show ? View.VISIBLE : View.GONE);
}
/** Might re-create the keyboard view. [_keyboard_layout_view.setKeyboard()] and
[setInputView()] must be called soon after. */
private void refresh_config()
{
int prev_theme = _config.theme;
_config.refresh(getResources(), _foldStateTracker.isUnfolded(), _dictionaries);
refresh_current_dictionary();
// Refreshing the theme config requires re-creating the views
if (prev_theme != _config.theme)
{
create_keyboard_view();
_emojiPane = null;
_clipboard_pane = null;
setInputView(_keyboard_container_view);
}
// Set keyboard background opacity
Drawable bg = _keyboard_container_view.getBackground().mutate();
bg.setAlpha(_config.keyboardOpacity);
_keyboard_container_view.setBackground(bg);
_keyboard_layout_view.reset();
refresh_candidates_view();
}
private KeyboardData refresh_special_layout()
{
if (_config.editor_config.numeric_layout)
{
switch (_config.selected_number_layout)
{
case PIN:
return loadPinentry(_config.orientation_landscape ?
R.xml.pin_landscape : R.xml.pin);
case NUMBER:
return loadNumericLayout();
}
}
return null;
}
@Override
public void onStartInputView(EditorInfo info, boolean restarting)
{
_config.editor_config.refresh(info, getResources());
refresh_config();
_currentSpecialLayout = refresh_special_layout();
_keyboard_layout_view.setKeyboard(current_layout());
_keyeventhandler.started(_config);
setInputView(_keyboard_container_view);
Logs.debug_startup_input_view(info, _config);
_handler.removeCallbacks(_peakIndicatorRefresh);
_handler.postDelayed(_peakIndicatorRefresh, 60000);
if (_config.usageGaugeEnabled)
UsageGauge.fetchIfNeeded(_config.usageGaugeUrl, _config.usageGaugeToken,
() -> _handler.post(() -> _keyboard_layout_view.invalidate()));
}
@Override
public void setInputView(View v)
{
ViewParent parent = v.getParent();
if (parent != null && parent instanceof ViewGroup)
((ViewGroup)parent).removeView(v);
super.setInputView(v);
updateSoftInputWindowLayoutParams();
v.requestApplyInsets();
}
@Override
public void updateFullscreenMode() {
super.updateFullscreenMode();
updateSoftInputWindowLayoutParams();
}
private void updateSoftInputWindowLayoutParams() {
final Window window = getWindow().getWindow();
// On API >= 35, Keyboard2View behaves as edge-to-edge
// APIs 30 to 34 have visual artifact when edge-to-edge is enabled
if (VERSION.SDK_INT >= 35)
{
WindowManager.LayoutParams wattrs = window.getAttributes();
wattrs.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
// Allow to draw behind system bars
wattrs.setFitInsetsTypes(0);
window.setDecorFitsSystemWindows(false);
}
updateLayoutHeightOf(window, ViewGroup.LayoutParams.MATCH_PARENT);
final View inputArea = window.findViewById(android.R.id.inputArea);
updateLayoutHeightOf(
(View) inputArea.getParent(),
isFullscreenMode()
? ViewGroup.LayoutParams.MATCH_PARENT
: ViewGroup.LayoutParams.WRAP_CONTENT);
updateLayoutGravityOf((View) inputArea.getParent(), Gravity.BOTTOM);
}
private static void updateLayoutHeightOf(final Window window, final int layoutHeight) {
final WindowManager.LayoutParams params = window.getAttributes();
if (params != null && params.height != layoutHeight) {
params.height = layoutHeight;
window.setAttributes(params);
}
}
private static void updateLayoutHeightOf(final View view, final int layoutHeight) {
final ViewGroup.LayoutParams params = view.getLayoutParams();
if (params != null && params.height != layoutHeight) {
params.height = layoutHeight;
view.setLayoutParams(params);
}
}
private static void updateLayoutGravityOf(final View view, final int layoutGravity) {
final ViewGroup.LayoutParams lp = view.getLayoutParams();
if (lp instanceof LinearLayout.LayoutParams) {
final LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) lp;
if (params.gravity != layoutGravity) {
params.gravity = layoutGravity;
view.setLayoutParams(params);
}
} else if (lp instanceof FrameLayout.LayoutParams) {
final FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) lp;
if (params.gravity != layoutGravity) {
params.gravity = layoutGravity;
view.setLayoutParams(params);
}
}
}
@Override
public void onCurrentInputMethodSubtypeChanged(InputMethodSubtype subtype)
{
refreshSubtypeImm();
refresh_current_dictionary();
refresh_candidates_view();
_keyboard_layout_view.setKeyboard(current_layout());
_keyeventhandler.ime_subtype_changed();
}
@Override
public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd, int candidatesStart, int candidatesEnd)
{
super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd);
_keyeventhandler.selection_updated(oldSelStart, newSelStart, newSelEnd);
if ((oldSelStart == oldSelEnd) != (newSelStart == newSelEnd))
_keyboard_layout_view.set_selection_state(newSelStart != newSelEnd);
}
@Override
public void onFinishInputView(boolean finishingInput)
{
_handler.removeCallbacks(_peakIndicatorRefresh);
super.onFinishInputView(finishingInput);
_keyboard_layout_view.reset();
}
@Override
public void onSharedPreferenceChanged(SharedPreferences _prefs, String _key)
{
refresh_config();
_keyboard_layout_view.setKeyboard(current_layout());
}
@Override
public boolean onEvaluateFullscreenMode()
{
/* Entirely disable fullscreen mode. */
return false;
}
@Override
public boolean onEvaluateInputViewShown()
{
// Since Android 16, this method returns [false] for unknown reasons.
if (super.onEvaluateInputViewShown())
return true;
if (getResources().getConfiguration().hardKeyboardHidden
== Configuration.HARDKEYBOARDHIDDEN_NO
&& _config.physical_keyboard_hide)
{
Logs.debug("Physical keyboard is present");
return false;
}
return true;
}
/** Called from [onClick] attributes. */
public void launch_dictionaries_activity(View v)
{
start_activity(DictionariesActivity.class);
}
void start_activity(Class cls)
{
Intent intent = new Intent(this, cls);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
/** Not static */
public class Receiver implements KeyEventHandler.IReceiver,
KeyValue.Stateful.Symbol_provider
{
public void handle_event_key(KeyValue.Event ev)
{
switch (ev)
{
case CONFIG:
start_activity(SettingsActivity.class);
break;
case SWITCH_TEXT:
_currentSpecialLayout = null;
_keyboard_layout_view.setKeyboard(current_layout());
break;
case SWITCH_NUMERIC:
setSpecialLayout(loadNumericLayout());
break;
case SWITCH_FN:
setSpecialLayout(loadFnLayout());
break;
case SWITCH_EMOJI:
if (_emojiPane == null)
_emojiPane = (ViewGroup)inflate_view(R.layout.emoji_pane);
setInputView(_emojiPane);
break;
case SWITCH_CLIPBOARD:
if (_clipboard_pane == null)
_clipboard_pane = (ViewGroup)inflate_view(R.layout.clipboard_pane);
setInputView(_clipboard_pane);
break;
case SWITCH_BACK_EMOJI:
case SWITCH_BACK_CLIPBOARD:
setInputView(_keyboard_container_view);
break;
case CHANGE_METHOD_PICKER:
get_imm().showInputMethodPicker();
break;
case CHANGE_METHOD_PREV:
if (VERSION.SDK_INT < 28)
get_imm().switchToLastInputMethod(getConnectionToken());
else
switchToPreviousInputMethod();
break;
case CHANGE_METHOD_NEXT:
if (VERSION.SDK_INT < 28)
get_imm().switchToNextInputMethod(getConnectionToken(), false);
else
switchToNextInputMethod(false);
break;
case ACTION:
InputConnection conn = getCurrentInputConnection();
if (conn != null)
conn.performEditorAction(_config.editor_config.actionId);
break;
case SWITCH_FORWARD:
incrTextLayout(1);
break;
case SWITCH_BACKWARD:
incrTextLayout(-1);
break;
case SWITCH_GREEKMATH:
setSpecialLayout(loadNumpad(R.xml.greekmath));
break;
case CAPS_LOCK:
set_shift_state(true, true);
break;
case SWITCH_VOICE_TYPING:
if (!VoiceImeSwitcher.switch_to_voice_ime(Keyboard2.this, get_imm(),
Config.globalPrefs()))
_config.shouldOfferVoiceTyping = false;
break;
case SWITCH_VOICE_TYPING_CHOOSER:
VoiceImeSwitcher.choose_voice_ime(Keyboard2.this, get_imm(),
Config.globalPrefs());
break;
case HIDE_SELF:
Keyboard2.this.requestHideSelf(0);
break;
}
}
public void set_shift_state(boolean state, boolean lock)
{
_keyboard_layout_view.set_shift_state(state, lock);
}
public void set_compose_pending(boolean pending)
{
_keyboard_layout_view.set_compose_pending(pending);
}
public void selection_state_changed(boolean selection_is_ongoing)
{
_keyboard_layout_view.set_selection_state(selection_is_ongoing);
}
public InputConnection getCurrentInputConnection()
{
return Keyboard2.this.getCurrentInputConnection();
}
public Handler getHandler()
{
return _handler;
}
public void set_suggestions(Suggestions suggestions)
{
_candidates_view.set_candidates(suggestions);
}
public String provide_stateful_key_symbol(KeyValue.Stateful q)
{
switch (q)
{
case Complete_first: return _suggestions.suggestions[0];
case Complete_second: return _suggestions.suggestions[1];
case Complete_third: return _suggestions.suggestions[2];
case Complete_emoji: return _suggestions.emoji_suggestion;
}
return "";
}
}
private IBinder getConnectionToken()
{
return getWindow().getWindow().getAttributes().token;
}
private View inflate_view(int layout)
{
return View.inflate(new ContextThemeWrapper(this, _config.theme), layout, null);
}
}