FreeRDP
Loading...
Searching...
No Matches
ExtendedKeyboardView.java
1/*
2 Android Extended On-screen Keyboard
3
4 Copyright 2026 Ibrahim Sevinc <ibrahim.sevinc.mail@gmail.com>
5
6 This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
7 If a copy of the MPL was not distributed with this file, You can obtain one at
8 http://mozilla.org/MPL/2.0/.
9*/
10
11package com.freerdp.freerdpcore.presentation;
12
13import android.content.Context;
14import android.content.res.ColorStateList;
15import android.os.Handler;
16import android.os.Looper;
17import android.util.AttributeSet;
18import android.util.SparseArray;
19import android.util.TypedValue;
20import android.view.Gravity;
21import android.view.HapticFeedbackConstants;
22import android.view.View;
23import android.view.ViewConfiguration;
24import android.widget.FrameLayout;
25import android.widget.ImageView;
26import android.widget.LinearLayout;
27import android.widget.TextView;
28
29import androidx.core.content.ContextCompat;
30
31import com.freerdp.freerdpcore.R;
32import com.freerdp.freerdpcore.utils.KeyboardMapper;
33
34// One keyboard: a persistent modifier/action bar plus a collapsible, paged key panel.
35// Keys carry an @integer/keycode_* value and are fed straight to KeyboardMapper via the listener.
36public class ExtendedKeyboardView extends LinearLayout
37{
38 public static final int PAGE_SPECIAL = 0;
39 public static final int PAGE_NUM = 1;
40
41 public interface Listener
42 {
43 void onKey(int keycode);
44
45 void onKeyLock(int keycode);
46
47 int getModifierState(int keycode);
48
49 void onExpandedChanged(boolean expanded);
50 }
51
52 // The key fill only says whether the modifier is engaged; armed vs locked is the lamp's job.
53 private static final class ModifierKey
54 {
55 private final View key;
56 private final View led;
57
58 ModifierKey(View key, View led)
59 {
60 this.key = key;
61 this.led = led;
62 }
63
64 void setState(boolean on, boolean locked)
65 {
66 key.setActivated(on || locked);
67 led.setActivated(on || locked);
68 led.setSelected(locked);
69 }
70 }
71
72 private Listener listener;
73 private View panel;
74 private View bar;
75 private TextView expandKey;
76 private final View[] pages = new View[2];
77 private final SparseArray<ModifierKey> modifierKeys = new SparseArray<>();
78 private final Handler repeatHandler = new Handler(Looper.getMainLooper());
79 private int currentPage = PAGE_SPECIAL;
80 private boolean expanded = false;
81 private int insetLeft = 0;
82 private int insetRight = 0;
83 private int insetBottom = 0;
84
85 public ExtendedKeyboardView(Context context)
86 {
87 this(context, null);
88 }
89
90 public ExtendedKeyboardView(Context context, AttributeSet attrs)
91 {
92 super(context, attrs);
93 setOrientation(VERTICAL);
94 build();
95 }
96
97 public void setListener(Listener l)
98 {
99 this.listener = l;
100 }
101
102 private int code(int resId)
103 {
104 return getResources().getInteger(resId);
105 }
106
107 private int px(int dimenId)
108 {
109 return getResources().getDimensionPixelSize(dimenId);
110 }
111
112 private void build()
113 {
114 bar = buildBar();
115 addView(bar, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT,
116 LayoutParams.WRAP_CONTENT));
117
118 panel = buildPanel();
119 panel.setVisibility(GONE);
120 addView(panel, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT,
121 LayoutParams.WRAP_CONTENT));
122 }
123
124 private View buildBar()
125 {
126 LinearLayout row = new LinearLayout(getContext());
127 row.setOrientation(HORIZONTAL);
128 row.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.kbd_bar_bg));
129 int barPad = px(R.dimen.kbd_bar_pad);
130 row.setPadding(barPad, barPad, barPad, barPad);
131
132 int[] codes = { R.integer.keycode_esc, R.integer.keycode_tab,
133 R.integer.keycode_toggle_shift, R.integer.keycode_toggle_ctrl,
134 R.integer.keycode_toggle_win, R.integer.keycode_toggle_alt,
135 R.integer.keycode_delete };
136 String[] labels = { "Esc", "Tab", "Shift", "Ctrl", "Win", "Alt", "Del" };
137 boolean[] mods = { false, false, true, true, true, true, false };
138
139 for (int i = 0; i < codes.length; i++)
140 {
141 int keycode = code(codes[i]);
142 // modifiers latch, so they get a lamp instead of auto-repeat
143 View key =
144 codes[i] == R.integer.keycode_toggle_win
145 ? makeIconKey(keycode, R.drawable.ic_win_logo,
146 getContext().getString(R.string.kbd_win_key), !mods[i], 1.0f)
147 : makeKey(keycode, labels[i], !mods[i], 1.0f);
148 row.addView(mods[i] ? withIndicator(key, keycode) : key);
149 }
150
151 expandKey = makeKey(0, "▼", false, 1.0f);
152 expandKey.setContentDescription(getContext().getString(R.string.menu_ext_keyboard));
153 expandKey.setOnClickListener(v -> setExpanded(!expanded));
154 tintAsChrome(expandKey);
155 row.addView(expandKey);
156
157 return row;
158 }
159
160 private View buildPanel()
161 {
162 LinearLayout p = new LinearLayout(getContext());
163 p.setOrientation(VERTICAL);
164 p.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.kbd_panel_bg));
165
166 FrameLayout pageHost = new FrameLayout(getContext());
167 pages[PAGE_SPECIAL] = buildSpecialPage();
168 pages[PAGE_NUM] = buildNumPage();
169 for (View pg : pages)
170 pageHost.addView(pg, new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT,
171 LayoutParams.WRAP_CONTENT));
172 p.addView(pageHost, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT,
173 LayoutParams.WRAP_CONTENT));
174
175 selectPage(PAGE_SPECIAL);
176 return p;
177 }
178
179 private View buildSpecialPage()
180 {
181 LinearLayout page = new LinearLayout(getContext());
182 page.setOrientation(VERTICAL);
183 page.addView(
184 gridRow(new int[] { R.integer.keycode_F1, R.integer.keycode_F2, R.integer.keycode_F3,
185 R.integer.keycode_F4, R.integer.keycode_F5, R.integer.keycode_F6 },
186 new String[] { "F1", "F2", "F3", "F4", "F5", "F6" }));
187 page.addView(gridRow(new int[] { R.integer.keycode_F7, R.integer.keycode_F8,
188 R.integer.keycode_F9, R.integer.keycode_F10,
189 R.integer.keycode_F11, R.integer.keycode_F12 },
190 new String[] { "F7", "F8", "F9", "F10", "F11", "F12" }));
191 page.addView(gridRow(new int[] { R.integer.keycode_insert, R.integer.keycode_home,
192 R.integer.keycode_print, R.integer.keycode_pgup,
193 R.integer.keycode_up, R.integer.keycode_pgdn },
194 new String[] { "Ins", "Home", "PrtSc", "PgUp", "↑", "PgDn" }));
195 // the page-switch key rides the normal keycode path: KeyboardMapper turns it into a
196 // switchKeyboard() callback that lands back here as selectPage()
197 LinearLayout row4 =
198 gridRow(new int[] { R.integer.keycode_numpad_keyboard, R.integer.keycode_end,
199 R.integer.keycode_menu, R.integer.keycode_left,
200 R.integer.keycode_down, R.integer.keycode_right },
201 new String[] { "⇆ 123", "End", "Menu", "←", "↓", "→" });
202 tintAsChrome((TextView)row4.getChildAt(0));
203 page.addView(row4);
204 return page;
205 }
206
207 private View buildNumPage()
208 {
209 LinearLayout page = new LinearLayout(getContext());
210 page.setOrientation(VERTICAL);
211 page.addView(gridRow(
212 new int[] { R.integer.keycode_numpad_left_paren, R.integer.keycode_numpad_right_paren,
213 R.integer.keycode_numpad_7, R.integer.keycode_numpad_8,
214 R.integer.keycode_numpad_9, R.integer.keycode_numpad_subtract },
215 new String[] { "(", ")", "7", "8", "9", "−" }));
216 page.addView(
217 gridRow(new int[] { R.integer.keycode_numpad_divide, R.integer.keycode_numpad_multiply,
218 R.integer.keycode_numpad_4, R.integer.keycode_numpad_5,
219 R.integer.keycode_numpad_6, R.integer.keycode_numpad_add },
220 new String[] { "/", "*", "4", "5", "6", "+" }));
221 page.addView(gridRow(new int[] { R.integer.keycode_numpad_equals, R.integer.keycode_comma,
222 R.integer.keycode_numpad_1, R.integer.keycode_numpad_2,
223 R.integer.keycode_numpad_3, R.integer.keycode_backspace },
224 new String[] { "=", ",", "1", "2", "3", "⌫" }));
225 int gap = px(R.dimen.kbd_key_gap);
226 LinearLayout row4 =
227 gridRow(new int[] { R.integer.keycode_specialkeys_keyboard, R.integer.keycode_numpad_0,
228 R.integer.keycode_numpad_comma, R.integer.keycode_numpad_enter },
229 new String[] { "⇆ Fn", "0", ".", "↵" }, new float[] { 1.0f, 3.0f, 1.0f, 1.0f },
230 new int[] { 0, 4 * gap, 0, 0 });
231 tintAsChrome((TextView)row4.getChildAt(0));
232 page.addView(row4);
233 return page;
234 }
235
236 // Keys that drive the keyboard rather than send input. Marking them by hue leaves the grey
237 // scale to mean one thing only: how far a key is from its resting state.
238 private void tintAsChrome(TextView k)
239 {
240 k.setBackgroundTintList(
241 ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.kbd_key_chrome)));
242 k.setTextColor(ContextCompat.getColor(getContext(), R.color.kbd_key_chrome_text));
243 }
244
245 private LinearLayout gridRow(int[] codeRes, String[] labels)
246 {
247 float[] weights = new float[codeRes.length];
248 for (int i = 0; i < weights.length; i++)
249 weights[i] = 1.0f;
250 return gridRow(codeRes, labels, weights, new int[codeRes.length]);
251 }
252
253 // baseWidths[i] is the fixed part of a key's width; the weight distributes the remainder.
254 private LinearLayout gridRow(int[] codeRes, String[] labels, float[] weights, int[] baseWidths)
255 {
256 LinearLayout row = new LinearLayout(getContext());
257 row.setOrientation(HORIZONTAL);
258 for (int i = 0; i < codeRes.length; i++)
259 {
260 TextView k = makeKey(code(codeRes[i]), labels[i], true, weights[i]);
261 k.getLayoutParams().width = baseWidths[i];
262 row.addView(k);
263 }
264 return row;
265 }
266
267 private TextView makeKey(int keycode, String label, boolean repeatable, float weight)
268 {
269 TextView k = new TextView(getContext());
270 k.setText(label);
271 k.setGravity(Gravity.CENTER);
272 k.setTextColor(ContextCompat.getColor(getContext(), R.color.kbd_text));
273 k.setTextSize(TypedValue.COMPLEX_UNIT_PX,
274 getResources().getDimension(R.dimen.kbd_key_text));
275 styleKey(k, keycode, repeatable, weight);
276 return k;
277 }
278
279 private ImageView makeIconKey(int keycode, int iconRes, String description, boolean repeatable,
280 float weight)
281 {
282 ImageView k = new ImageView(getContext());
283 k.setImageResource(iconRes);
284 k.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
285 k.setContentDescription(description);
286 // tint like a label so an icon key matches the text keys beside it in either theme
287 k.setImageTintList(
288 ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.kbd_text)));
289 styleKey(k, keycode, repeatable, weight);
290 return k;
291 }
292
293 private void styleKey(View k, int keycode, boolean repeatable, float weight)
294 {
295 k.setBackgroundResource(R.drawable.bg_kbd_key);
296 k.setBackgroundTintList(
297 ContextCompat.getColorStateList(getContext(), R.color.kbd_key_tint));
298 k.setClickable(true);
299 k.setFocusable(true);
300 LinearLayout.LayoutParams lp =
301 new LinearLayout.LayoutParams(0, px(R.dimen.kbd_key_height), weight);
302 int gap = px(R.dimen.kbd_key_gap);
303 lp.setMargins(gap, gap, gap, gap);
304 k.setLayoutParams(lp);
305 k.setOnClickListener(v -> {
306 if (listener != null)
307 listener.onKey(keycode);
308 });
309 if (repeatable)
310 k.setOnLongClickListener(v -> {
311 startRepeat(v, keycode);
312 return true;
313 });
314 }
315
316 // Wraps a latching modifier so it carries an indicator lamp in its leading top corner.
317 private View withIndicator(View key, int keycode)
318 {
319 FrameLayout holder = new FrameLayout(getContext());
320 holder.setLayoutParams(key.getLayoutParams());
321 key.setLayoutParams(
322 new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
323 key.setOnLongClickListener(v -> {
324 v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
325 if (listener != null)
326 listener.onKeyLock(keycode);
327 return true;
328 });
329 holder.addView(key);
330
331 View led = new View(getContext());
332 led.setBackgroundResource(R.drawable.bg_kbd_led);
333 int size = px(R.dimen.kbd_led_size);
334 int inset = px(R.dimen.kbd_led_inset);
335 FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(size, size);
336 lp.gravity = Gravity.TOP | Gravity.START;
337 lp.setMargins(inset, inset, 0, 0);
338 holder.addView(led, lp);
339
340 modifierKeys.put(keycode, new ModifierKey(key, led));
341 return holder;
342 }
343
344 // The pressed state terminates the loop, so a cancelled gesture or a detached view stops it.
345 private void startRepeat(View k, int keycode)
346 {
347 repeatHandler.removeCallbacksAndMessages(null);
348 repeatHandler.post(new Runnable() {
349 @Override public void run()
350 {
351 if (!k.isPressed() || !isAttachedToWindow())
352 return;
353 if (listener != null)
354 listener.onKey(keycode);
355 repeatHandler.postDelayed(this, ViewConfiguration.getKeyRepeatDelay());
356 }
357 });
358 }
359
360 public void selectPage(int index)
361 {
362 currentPage = index;
363 for (int i = 0; i < pages.length; i++)
364 pages[i].setVisibility(i == index ? VISIBLE : GONE);
365 }
366
367 public void setInsets(int left, int right, int bottom)
368 {
369 this.insetLeft = left;
370 this.insetRight = right;
371 this.insetBottom = bottom;
372 applyInsets();
373 }
374
375 private void applyInsets()
376 {
377 int barPad = px(R.dimen.kbd_bar_pad);
378 if (bar != null)
379 {
380 // collapsed, the bar is the bottom-most child and has to clear the nav bar itself
381 int barBottom = expanded ? barPad : barPad + insetBottom;
382 bar.setPadding(insetLeft + barPad, barPad, insetRight + barPad, barBottom);
383 }
384 if (panel != null)
385 panel.setPadding(insetLeft, 0, insetRight, insetBottom);
386 }
387
388 public void setExpanded(boolean expand)
389 {
390 setExpanded(expand, true);
391 }
392
393 public void setExpanded(boolean expand, boolean notify)
394 {
395 if (expanded == expand)
396 return;
397 expanded = expand;
398 panel.setVisibility(expand ? VISIBLE : GONE);
399 expandKey.setText(expand ? "▲" : "▼");
400 applyInsets();
401 if (notify && listener != null)
402 listener.onExpandedChanged(expand);
403 }
404
405 public boolean isExpanded()
406 {
407 return expanded;
408 }
409
410 // Re-reads every modifier's state from the listener and repaints key and lamp.
411 public void refreshModifiers()
412 {
413 if (listener == null)
414 return;
415 for (int i = 0; i < modifierKeys.size(); i++)
416 {
417 int state = listener.getModifierState(modifierKeys.keyAt(i));
418 modifierKeys.valueAt(i).setState(state == KeyboardMapper.KEYSTATE_ON,
419 state == KeyboardMapper.KEYSTATE_LOCKED);
420 }
421 }
422
423 @Override protected void onConfigurationChanged(android.content.res.Configuration newConfig)
424 {
425 super.onConfigurationChanged(newConfig);
426
427 // the key dimensions are orientation dependent, so rebuild and restore
428 int page = currentPage;
429 boolean wasExpanded = expanded;
430
431 removeAllViews();
432 modifierKeys.clear();
433 build();
434
435 // build() starts out collapsed: without clearing the flag setExpanded() sees no change
436 // and leaves the freshly built panel hidden
437 expanded = false;
438 selectPage(page);
439 setExpanded(wasExpanded, false);
440 applyInsets();
441 refreshModifiers();
442 }
443
444 @Override protected void onDetachedFromWindow()
445 {
446 repeatHandler.removeCallbacksAndMessages(null);
447 super.onDetachedFromWindow();
448 }
449}