- 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
581 lines
18 KiB
Java
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);
|
|
}
|
|
}
|