0
|
1 /*
|
|
2 * ConnectBot: simple, powerful, open-source SSH client for Android
|
|
3 * Copyright 2007 Kenny Root, Jeffrey Sharkey
|
|
4 *
|
|
5 * Licensed under the Apache License, Version 2.0 (the "License");
|
|
6 * you may not use this file except in compliance with the License.
|
|
7 * You may obtain a copy of the License at
|
|
8 *
|
|
9 * http://www.apache.org/licenses/LICENSE-2.0
|
|
10 *
|
|
11 * Unless required by applicable law or agreed to in writing, software
|
|
12 * distributed under the License is distributed on an "AS IS" BASIS,
|
|
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14 * See the License for the specific language governing permissions and
|
|
15 * limitations under the License.
|
|
16 */
|
|
17
|
|
18 package com.five_ten_sg.connectbot;
|
|
19
|
|
20 import java.util.List;
|
|
21 import java.util.regex.Matcher;
|
|
22 import java.util.regex.Pattern;
|
|
23
|
|
24 import com.five_ten_sg.connectbot.bean.SelectionArea;
|
|
25 import com.five_ten_sg.connectbot.service.FontSizeChangedListener;
|
|
26 import com.five_ten_sg.connectbot.service.TerminalBridge;
|
|
27 import com.five_ten_sg.connectbot.service.TerminalKeyListener;
|
|
28 import android.app.Activity;
|
|
29 import android.content.ContentResolver;
|
|
30 import android.content.Context;
|
|
31 import android.content.Intent;
|
|
32 import android.content.pm.ResolveInfo;
|
|
33 import android.database.Cursor;
|
|
34 import android.graphics.Canvas;
|
|
35 import android.graphics.Matrix;
|
|
36 import android.graphics.Paint;
|
|
37 import android.graphics.Path;
|
|
38 import android.graphics.PixelXorXfermode;
|
|
39 import android.graphics.RectF;
|
|
40 import android.net.Uri;
|
|
41 import android.os.AsyncTask;
|
|
42 import android.view.KeyEvent;
|
|
43 import android.view.View;
|
|
44 import android.view.ViewGroup.LayoutParams;
|
|
45 import android.view.accessibility.AccessibilityEvent;
|
|
46 import android.view.accessibility.AccessibilityManager;
|
|
47 import android.view.inputmethod.BaseInputConnection;
|
|
48 import android.view.inputmethod.EditorInfo;
|
|
49 import android.view.inputmethod.InputConnection;
|
|
50 import android.view.ScaleGestureDetector;
|
|
51 import android.widget.Toast;
|
|
52 import de.mud.terminal.VDUBuffer;
|
|
53
|
|
54 /**
|
|
55 * User interface {@link View} for showing a TerminalBridge in an
|
|
56 * {@link Activity}. Handles drawing bitmap updates and passing keystrokes down
|
|
57 * to terminal.
|
|
58 *
|
|
59 * @author jsharkey
|
|
60 */
|
|
61 public class TerminalView extends View implements FontSizeChangedListener {
|
|
62
|
|
63 private final Context context;
|
|
64 public final TerminalBridge bridge;
|
|
65 private final Paint paint;
|
|
66 private final Paint cursorPaint;
|
|
67 private final Paint cursorStrokePaint;
|
|
68
|
|
69 // Cursor paints to distinguish modes
|
|
70 private Path ctrlCursor, altCursor, shiftCursor;
|
|
71 private RectF tempSrc, tempDst;
|
|
72 private Matrix scaleMatrix;
|
|
73 private static final Matrix.ScaleToFit scaleType = Matrix.ScaleToFit.FILL;
|
|
74
|
|
75 private Toast notification = null;
|
|
76 private String lastNotification = null;
|
|
77 private volatile boolean notifications = true;
|
|
78
|
|
79 // Related to Accessibility Features
|
|
80 private boolean mAccessibilityInitialized = false;
|
|
81 private boolean mAccessibilityActive = true;
|
|
82 private Object[] mAccessibilityLock = new Object[0];
|
|
83 private StringBuffer mAccessibilityBuffer;
|
|
84 private Pattern mControlCodes = null;
|
|
85 private Matcher mCodeMatcher = null;
|
|
86 private AccessibilityEventSender mEventSender = null;
|
|
87
|
|
88 private static final String BACKSPACE_CODE = "\\x08\\x1b\\[K";
|
|
89 private static final String CONTROL_CODE_PATTERN = "\\x1b\\[K[^m]+[m|:]";
|
|
90
|
|
91 private static final int ACCESSIBILITY_EVENT_THRESHOLD = 1000;
|
|
92 private static final String SCREENREADER_INTENT_ACTION = "android.accessibilityservice.AccessibilityService";
|
|
93 private static final String SCREENREADER_INTENT_CATEGORY = "android.accessibilityservice.category.FEEDBACK_SPOKEN";
|
|
94
|
|
95 public ScaleGestureDetector mScaleDetector;
|
|
96
|
|
97 public TerminalView(Context context, TerminalBridge bridge) {
|
|
98 super(context);
|
|
99 this.context = context;
|
|
100 this.bridge = bridge;
|
|
101 paint = new Paint();
|
|
102 setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT));
|
|
103 setFocusable(true);
|
|
104 setFocusableInTouchMode(true);
|
|
105 cursorPaint = new Paint();
|
|
106 cursorPaint.setColor(bridge.color[bridge.defaultFg]);
|
|
107 cursorPaint.setXfermode(new PixelXorXfermode(bridge.color[bridge.defaultBg]));
|
|
108 cursorPaint.setAntiAlias(true);
|
|
109 cursorStrokePaint = new Paint(cursorPaint);
|
|
110 cursorStrokePaint.setStrokeWidth(0.1f);
|
|
111 cursorStrokePaint.setStyle(Paint.Style.STROKE);
|
|
112 /*
|
|
113 * Set up our cursor indicators on a 1x1 Path object which we can later
|
|
114 * transform to our character width and height
|
|
115 */
|
|
116 // TODO make this into a resource somehow
|
|
117 shiftCursor = new Path();
|
|
118 shiftCursor.lineTo(0.5f, 0.33f);
|
|
119 shiftCursor.lineTo(1.0f, 0.0f);
|
|
120 altCursor = new Path();
|
|
121 altCursor.moveTo(0.0f, 1.0f);
|
|
122 altCursor.lineTo(0.5f, 0.66f);
|
|
123 altCursor.lineTo(1.0f, 1.0f);
|
|
124 ctrlCursor = new Path();
|
|
125 ctrlCursor.moveTo(0.0f, 0.25f);
|
|
126 ctrlCursor.lineTo(1.0f, 0.5f);
|
|
127 ctrlCursor.lineTo(0.0f, 0.75f);
|
|
128 // For creating the transform when the terminal resizes
|
|
129 tempSrc = new RectF();
|
|
130 tempSrc.set(0.0f, 0.0f, 1.0f, 1.0f);
|
|
131 tempDst = new RectF();
|
|
132 scaleMatrix = new Matrix();
|
|
133 bridge.addFontSizeChangedListener(this);
|
|
134 // connect our view up to the bridge
|
|
135 setOnKeyListener(bridge.getKeyHandler());
|
|
136 mAccessibilityBuffer = new StringBuffer();
|
|
137 // Enable accessibility features if a screen reader is active.
|
|
138 new AccessibilityStateTester().execute((Void) null);
|
|
139 mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
|
|
140 }
|
|
141
|
|
142 public void destroy() {
|
|
143 // tell bridge to destroy its bitmap
|
|
144 bridge.parentDestroyed();
|
|
145 }
|
|
146
|
|
147 @Override
|
|
148 protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
|
149 super.onSizeChanged(w, h, oldw, oldh);
|
|
150 bridge.parentChanged(this);
|
|
151 scaleCursors();
|
|
152 }
|
|
153
|
|
154 public void onFontSizeChanged(float size) {
|
|
155 scaleCursors();
|
|
156 }
|
|
157
|
|
158 private void scaleCursors() {
|
|
159 // Create a scale matrix to scale our 1x1 representation of the cursor
|
|
160 tempDst.set(0.0f, 0.0f, bridge.charWidth, bridge.charHeight);
|
|
161 scaleMatrix.setRectToRect(tempSrc, tempDst, scaleType);
|
|
162 }
|
|
163
|
|
164 @Override
|
|
165 public void onDraw(Canvas canvas) {
|
|
166 if (bridge.bitmap != null) {
|
|
167 // draw the bitmap
|
|
168 bridge.onDraw();
|
|
169 // draw the bridge bitmap if it exists
|
|
170 canvas.drawBitmap(bridge.bitmap, 0, 0, paint);
|
|
171
|
|
172 // also draw cursor if visible
|
|
173 if (bridge.buffer.isCursorVisible()) {
|
|
174 int cursorColumn = bridge.buffer.getCursorColumn();
|
|
175 final int cursorRow = bridge.buffer.getCursorRow();
|
|
176 final int columns = bridge.buffer.getColumns();
|
|
177
|
|
178 if (cursorColumn == columns)
|
|
179 cursorColumn = columns - 1;
|
|
180
|
|
181 if (cursorColumn < 0 || cursorRow < 0)
|
|
182 return;
|
|
183
|
|
184 int currentAttribute = bridge.buffer.getAttributes(
|
|
185 cursorColumn, cursorRow);
|
|
186 boolean onWideCharacter = (currentAttribute & VDUBuffer.FULLWIDTH) != 0;
|
|
187 int x = cursorColumn * bridge.charWidth;
|
|
188 int y = (bridge.buffer.getCursorRow()
|
|
189 + bridge.buffer.screenBase - bridge.buffer.windowBase)
|
|
190 * bridge.charHeight;
|
|
191 // Save the current clip and translation
|
|
192 canvas.save();
|
|
193 canvas.translate(x, y);
|
|
194 canvas.clipRect(0, 0,
|
|
195 bridge.charWidth * (onWideCharacter ? 2 : 1),
|
|
196 bridge.charHeight);
|
|
197 canvas.drawPaint(cursorPaint);
|
|
198 final int deadKey = bridge.getKeyHandler().getDeadKey();
|
|
199
|
|
200 if (deadKey != 0) {
|
|
201 canvas.drawText(new char[] { (char)deadKey }, 0, 1, 0, 0, cursorStrokePaint);
|
|
202 }
|
|
203
|
|
204 // Make sure we scale our decorations to the correct size.
|
|
205 canvas.concat(scaleMatrix);
|
|
206 int metaState = bridge.getKeyHandler().getMetaState();
|
|
207
|
|
208 if ((metaState & TerminalKeyListener.META_SHIFT_ON) != 0)
|
|
209 canvas.drawPath(shiftCursor, cursorStrokePaint);
|
|
210 else if ((metaState & TerminalKeyListener.META_SHIFT_LOCK) != 0)
|
|
211 canvas.drawPath(shiftCursor, cursorPaint);
|
|
212
|
|
213 if ((metaState & TerminalKeyListener.META_ALT_ON) != 0)
|
|
214 canvas.drawPath(altCursor, cursorStrokePaint);
|
|
215 else if ((metaState & TerminalKeyListener.META_ALT_LOCK) != 0)
|
|
216 canvas.drawPath(altCursor, cursorPaint);
|
|
217
|
|
218 if ((metaState & TerminalKeyListener.META_CTRL_ON) != 0)
|
|
219 canvas.drawPath(ctrlCursor, cursorStrokePaint);
|
|
220 else if ((metaState & TerminalKeyListener.META_CTRL_LOCK) != 0)
|
|
221 canvas.drawPath(ctrlCursor, cursorPaint);
|
|
222
|
|
223 // Restore previous clip region
|
|
224 canvas.restore();
|
|
225 }
|
|
226
|
|
227 // draw any highlighted area
|
|
228 if (bridge.isSelectingForCopy()) {
|
|
229 SelectionArea area = bridge.getSelectionArea();
|
|
230 canvas.save(Canvas.CLIP_SAVE_FLAG);
|
|
231 canvas.clipRect(
|
|
232 area.getLeft() * bridge.charWidth,
|
|
233 area.getTop() * bridge.charHeight,
|
|
234 (area.getRight() + 1) * bridge.charWidth,
|
|
235 (area.getBottom() + 1) * bridge.charHeight
|
|
236 );
|
|
237 canvas.drawPaint(cursorPaint);
|
|
238 canvas.restore();
|
|
239 }
|
|
240 }
|
|
241 }
|
|
242
|
|
243 public void notifyUser(String message) {
|
|
244 if (!notifications)
|
|
245 return;
|
|
246
|
|
247 if (notification != null) {
|
|
248 // Don't keep telling the user the same thing.
|
|
249 if (lastNotification != null && lastNotification.equals(message))
|
|
250 return;
|
|
251
|
|
252 notification.setText(message);
|
|
253 notification.show();
|
|
254 }
|
|
255 else {
|
|
256 notification = Toast.makeText(context, message, Toast.LENGTH_SHORT);
|
|
257 notification.show();
|
|
258 }
|
|
259
|
|
260 lastNotification = message;
|
|
261 }
|
|
262
|
|
263 /**
|
|
264 * Ask the {@link TerminalBridge} we're connected to to resize to a specific size.
|
|
265 * @param width
|
|
266 * @param height
|
|
267 */
|
|
268 public void forceSize(int width, int height) {
|
|
269 bridge.resizeComputed(width, height, getWidth(), getHeight());
|
|
270 }
|
|
271
|
|
272 /**
|
|
273 * Sets the ability for the TerminalView to display Toast notifications to the user.
|
|
274 * @param value whether to enable notifications or not
|
|
275 */
|
|
276 public void setNotifications(boolean value) {
|
|
277 notifications = value;
|
|
278 }
|
|
279
|
|
280 @Override
|
|
281 public boolean onCheckIsTextEditor() {
|
|
282 return true;
|
|
283 }
|
|
284
|
|
285 @Override
|
|
286 public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
|
287 outAttrs.imeOptions |=
|
|
288 EditorInfo.IME_FLAG_NO_EXTRACT_UI |
|
|
289 EditorInfo.IME_FLAG_NO_ENTER_ACTION |
|
|
290 EditorInfo.IME_ACTION_NONE;
|
|
291 outAttrs.inputType = EditorInfo.TYPE_NULL;
|
|
292 return new BaseInputConnection(this, false) {
|
|
293 @Override
|
|
294 public boolean deleteSurroundingText(int leftLength, int rightLength) {
|
|
295 if (rightLength == 0 && leftLength == 0) {
|
|
296 return this.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
|
|
297 }
|
|
298
|
|
299 for (int i = 0; i < leftLength; i++) {
|
|
300 this.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
|
|
301 }
|
|
302
|
|
303 // TODO: forward delete
|
|
304 return true;
|
|
305 }
|
|
306 };
|
|
307 }
|
|
308
|
|
309 public void propagateConsoleText(char[] rawText, int length) {
|
|
310 if (mAccessibilityActive) {
|
|
311 synchronized (mAccessibilityLock) {
|
|
312 mAccessibilityBuffer.append(rawText, 0, length);
|
|
313 }
|
|
314
|
|
315 if (mAccessibilityInitialized) {
|
|
316 if (mEventSender != null) {
|
|
317 removeCallbacks(mEventSender);
|
|
318 }
|
|
319 else {
|
|
320 mEventSender = new AccessibilityEventSender();
|
|
321 }
|
|
322
|
|
323 postDelayed(mEventSender, ACCESSIBILITY_EVENT_THRESHOLD);
|
|
324 }
|
|
325 }
|
|
326 }
|
|
327
|
|
328 private class AccessibilityEventSender implements Runnable {
|
|
329 public void run() {
|
|
330 synchronized (mAccessibilityLock) {
|
|
331 if (mCodeMatcher == null) {
|
|
332 mCodeMatcher = mControlCodes.matcher(mAccessibilityBuffer);
|
|
333 }
|
|
334 else {
|
|
335 mCodeMatcher.reset(mAccessibilityBuffer);
|
|
336 }
|
|
337
|
|
338 // Strip all control codes out.
|
|
339 mAccessibilityBuffer = new StringBuffer(mCodeMatcher.replaceAll(" "));
|
|
340 // Apply Backspaces using backspace character sequence
|
|
341 int i = mAccessibilityBuffer.indexOf(BACKSPACE_CODE);
|
|
342
|
|
343 while (i != -1) {
|
|
344 mAccessibilityBuffer = mAccessibilityBuffer.replace(i == 0 ? 0 : i - 1, i
|
|
345 + BACKSPACE_CODE.length(), "");
|
|
346 i = mAccessibilityBuffer.indexOf(BACKSPACE_CODE);
|
|
347 }
|
|
348
|
|
349 if (mAccessibilityBuffer.length() > 0) {
|
|
350 AccessibilityEvent event = AccessibilityEvent.obtain(
|
|
351 AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED);
|
|
352 event.setFromIndex(0);
|
|
353 event.setAddedCount(mAccessibilityBuffer.length());
|
|
354 event.getText().add(mAccessibilityBuffer);
|
|
355 sendAccessibilityEventUnchecked(event);
|
|
356 mAccessibilityBuffer.setLength(0);
|
|
357 }
|
|
358 }
|
|
359 }
|
|
360 }
|
|
361
|
|
362 private class AccessibilityStateTester extends AsyncTask<Void, Void, Boolean> {
|
|
363 @Override
|
|
364 protected Boolean doInBackground(Void... params) {
|
|
365 /*
|
|
366 * Presumably if the accessibility manager is not enabled, we don't
|
|
367 * need to send accessibility events.
|
|
368 */
|
|
369 final AccessibilityManager accessibility = (AccessibilityManager) context
|
|
370 .getSystemService(Context.ACCESSIBILITY_SERVICE);
|
|
371
|
|
372 if (!accessibility.isEnabled()) {
|
|
373 return false;
|
|
374 }
|
|
375
|
|
376 /*
|
|
377 * Restrict the set of intents to only accessibility services that
|
|
378 * have the category FEEDBACK_SPOKEN (aka, screen readers).
|
|
379 */
|
|
380 final Intent screenReaderIntent = new Intent(SCREENREADER_INTENT_ACTION);
|
|
381 screenReaderIntent.addCategory(SCREENREADER_INTENT_CATEGORY);
|
|
382 final ContentResolver cr = context.getContentResolver();
|
|
383 final List<ResolveInfo> screenReaders = context.getPackageManager().queryIntentServices(
|
|
384 screenReaderIntent, 0);
|
|
385 boolean foundScreenReader = false;
|
|
386 final int N = screenReaders.size();
|
|
387
|
|
388 for (int i = 0; i < N; i++) {
|
|
389 final ResolveInfo screenReader = screenReaders.get(i);
|
|
390 /*
|
|
391 * All screen readers are expected to implement a content
|
|
392 * provider that responds to:
|
|
393 * content://<nameofpackage>.providers.StatusProvider
|
|
394 */
|
|
395 final Cursor cursor = cr.query(
|
|
396 Uri.parse("content://" + screenReader.serviceInfo.packageName
|
|
397 + ".providers.StatusProvider"), null, null, null, null);
|
|
398
|
|
399 if (cursor != null && cursor.moveToFirst()) {
|
|
400 /*
|
|
401 * These content providers use a special cursor that only has
|
|
402 * one element, an integer that is 1 if the screen reader is
|
|
403 * running.
|
|
404 */
|
|
405 final int status = cursor.getInt(0);
|
|
406 cursor.close();
|
|
407
|
|
408 if (status == 1) {
|
|
409 foundScreenReader = true;
|
|
410 break;
|
|
411 }
|
|
412 }
|
|
413 }
|
|
414
|
|
415 if (foundScreenReader) {
|
|
416 mControlCodes = Pattern.compile(CONTROL_CODE_PATTERN);
|
|
417 }
|
|
418
|
|
419 return foundScreenReader;
|
|
420 }
|
|
421
|
|
422 @Override
|
|
423 protected void onPostExecute(Boolean result) {
|
|
424 mAccessibilityActive = result;
|
|
425 mAccessibilityInitialized = true;
|
|
426
|
|
427 if (result) {
|
|
428 mEventSender = new AccessibilityEventSender();
|
|
429 postDelayed(mEventSender, ACCESSIBILITY_EVENT_THRESHOLD);
|
|
430 }
|
|
431 else {
|
|
432 mAccessibilityBuffer = null;
|
|
433 }
|
|
434 }
|
|
435 }
|
|
436
|
|
437 private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
|
|
438 @Override
|
|
439 public boolean onScale(ScaleGestureDetector detector) {
|
|
440 float mScaleFactor = detector.getScaleFactor();
|
|
441
|
|
442 if (mScaleFactor > 1.1) {
|
|
443 bridge.increaseFontSize();
|
|
444 return true;
|
|
445 }
|
|
446 else if (mScaleFactor < 0.9) {
|
|
447 bridge.decreaseFontSize();
|
|
448 return true;
|
|
449 }
|
|
450
|
|
451 return (false);
|
|
452 }
|
|
453 }
|
|
454
|
|
455 }
|