diff src/com/five_ten_sg/connectbot/TerminalView.java @ 0:0ce5cc452d02

initial version
author Carl Byington <carl@five-ten-sg.com>
date Thu, 22 May 2014 10:41:19 -0700
parents
children cfcb8d9859a8
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/com/five_ten_sg/connectbot/TerminalView.java	Thu May 22 10:41:19 2014 -0700
@@ -0,0 +1,455 @@
+/*
+ * ConnectBot: simple, powerful, open-source SSH client for Android
+ * Copyright 2007 Kenny Root, Jeffrey Sharkey
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.five_ten_sg.connectbot;
+
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.five_ten_sg.connectbot.bean.SelectionArea;
+import com.five_ten_sg.connectbot.service.FontSizeChangedListener;
+import com.five_ten_sg.connectbot.service.TerminalBridge;
+import com.five_ten_sg.connectbot.service.TerminalKeyListener;
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.database.Cursor;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PixelXorXfermode;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.view.ScaleGestureDetector;
+import android.widget.Toast;
+import de.mud.terminal.VDUBuffer;
+
+/**
+ * User interface {@link View} for showing a TerminalBridge in an
+ * {@link Activity}. Handles drawing bitmap updates and passing keystrokes down
+ * to terminal.
+ *
+ * @author jsharkey
+ */
+public class TerminalView extends View implements FontSizeChangedListener {
+
+    private final Context context;
+    public final TerminalBridge bridge;
+    private final Paint paint;
+    private final Paint cursorPaint;
+    private final Paint cursorStrokePaint;
+
+    // Cursor paints to distinguish modes
+    private Path ctrlCursor, altCursor, shiftCursor;
+    private RectF tempSrc, tempDst;
+    private Matrix scaleMatrix;
+    private static final Matrix.ScaleToFit scaleType = Matrix.ScaleToFit.FILL;
+
+    private Toast notification = null;
+    private String lastNotification = null;
+    private volatile boolean notifications = true;
+
+    // Related to Accessibility Features
+    private boolean mAccessibilityInitialized = false;
+    private boolean mAccessibilityActive = true;
+    private Object[] mAccessibilityLock = new Object[0];
+    private StringBuffer mAccessibilityBuffer;
+    private Pattern mControlCodes = null;
+    private Matcher mCodeMatcher = null;
+    private AccessibilityEventSender mEventSender = null;
+
+    private static final String BACKSPACE_CODE = "\\x08\\x1b\\[K";
+    private static final String CONTROL_CODE_PATTERN = "\\x1b\\[K[^m]+[m|:]";
+
+    private static final int ACCESSIBILITY_EVENT_THRESHOLD = 1000;
+    private static final String SCREENREADER_INTENT_ACTION = "android.accessibilityservice.AccessibilityService";
+    private static final String SCREENREADER_INTENT_CATEGORY = "android.accessibilityservice.category.FEEDBACK_SPOKEN";
+
+    public ScaleGestureDetector mScaleDetector;
+
+    public TerminalView(Context context, TerminalBridge bridge) {
+        super(context);
+        this.context = context;
+        this.bridge = bridge;
+        paint = new Paint();
+        setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT));
+        setFocusable(true);
+        setFocusableInTouchMode(true);
+        cursorPaint = new Paint();
+        cursorPaint.setColor(bridge.color[bridge.defaultFg]);
+        cursorPaint.setXfermode(new PixelXorXfermode(bridge.color[bridge.defaultBg]));
+        cursorPaint.setAntiAlias(true);
+        cursorStrokePaint = new Paint(cursorPaint);
+        cursorStrokePaint.setStrokeWidth(0.1f);
+        cursorStrokePaint.setStyle(Paint.Style.STROKE);
+        /*
+         * Set up our cursor indicators on a 1x1 Path object which we can later
+         * transform to our character width and height
+         */
+        // TODO make this into a resource somehow
+        shiftCursor = new Path();
+        shiftCursor.lineTo(0.5f, 0.33f);
+        shiftCursor.lineTo(1.0f, 0.0f);
+        altCursor = new Path();
+        altCursor.moveTo(0.0f, 1.0f);
+        altCursor.lineTo(0.5f, 0.66f);
+        altCursor.lineTo(1.0f, 1.0f);
+        ctrlCursor = new Path();
+        ctrlCursor.moveTo(0.0f, 0.25f);
+        ctrlCursor.lineTo(1.0f, 0.5f);
+        ctrlCursor.lineTo(0.0f, 0.75f);
+        // For creating the transform when the terminal resizes
+        tempSrc = new RectF();
+        tempSrc.set(0.0f, 0.0f, 1.0f, 1.0f);
+        tempDst = new RectF();
+        scaleMatrix = new Matrix();
+        bridge.addFontSizeChangedListener(this);
+        // connect our view up to the bridge
+        setOnKeyListener(bridge.getKeyHandler());
+        mAccessibilityBuffer = new StringBuffer();
+        // Enable accessibility features if a screen reader is active.
+        new AccessibilityStateTester().execute((Void) null);
+        mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
+    }
+
+    public void destroy() {
+        // tell bridge to destroy its bitmap
+        bridge.parentDestroyed();
+    }
+
+    @Override
+    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+        super.onSizeChanged(w, h, oldw, oldh);
+        bridge.parentChanged(this);
+        scaleCursors();
+    }
+
+    public void onFontSizeChanged(float size) {
+        scaleCursors();
+    }
+
+    private void scaleCursors() {
+        // Create a scale matrix to scale our 1x1 representation of the cursor
+        tempDst.set(0.0f, 0.0f, bridge.charWidth, bridge.charHeight);
+        scaleMatrix.setRectToRect(tempSrc, tempDst, scaleType);
+    }
+
+    @Override
+    public void onDraw(Canvas canvas) {
+        if (bridge.bitmap != null) {
+            // draw the bitmap
+            bridge.onDraw();
+            // draw the bridge bitmap if it exists
+            canvas.drawBitmap(bridge.bitmap, 0, 0, paint);
+
+            // also draw cursor if visible
+            if (bridge.buffer.isCursorVisible()) {
+                int cursorColumn = bridge.buffer.getCursorColumn();
+                final int cursorRow = bridge.buffer.getCursorRow();
+                final int columns = bridge.buffer.getColumns();
+
+                if (cursorColumn == columns)
+                    cursorColumn = columns - 1;
+
+                if (cursorColumn < 0 || cursorRow < 0)
+                    return;
+
+                int currentAttribute = bridge.buffer.getAttributes(
+                                           cursorColumn, cursorRow);
+                boolean onWideCharacter = (currentAttribute & VDUBuffer.FULLWIDTH) != 0;
+                int x = cursorColumn * bridge.charWidth;
+                int y = (bridge.buffer.getCursorRow()
+                         + bridge.buffer.screenBase - bridge.buffer.windowBase)
+                        * bridge.charHeight;
+                // Save the current clip and translation
+                canvas.save();
+                canvas.translate(x, y);
+                canvas.clipRect(0, 0,
+                                bridge.charWidth * (onWideCharacter ? 2 : 1),
+                                bridge.charHeight);
+                canvas.drawPaint(cursorPaint);
+                final int deadKey = bridge.getKeyHandler().getDeadKey();
+
+                if (deadKey != 0) {
+                    canvas.drawText(new char[] { (char)deadKey }, 0, 1, 0, 0, cursorStrokePaint);
+                }
+
+                // Make sure we scale our decorations to the correct size.
+                canvas.concat(scaleMatrix);
+                int metaState = bridge.getKeyHandler().getMetaState();
+
+                if ((metaState & TerminalKeyListener.META_SHIFT_ON) != 0)
+                    canvas.drawPath(shiftCursor, cursorStrokePaint);
+                else if ((metaState & TerminalKeyListener.META_SHIFT_LOCK) != 0)
+                    canvas.drawPath(shiftCursor, cursorPaint);
+
+                if ((metaState & TerminalKeyListener.META_ALT_ON) != 0)
+                    canvas.drawPath(altCursor, cursorStrokePaint);
+                else if ((metaState & TerminalKeyListener.META_ALT_LOCK) != 0)
+                    canvas.drawPath(altCursor, cursorPaint);
+
+                if ((metaState & TerminalKeyListener.META_CTRL_ON) != 0)
+                    canvas.drawPath(ctrlCursor, cursorStrokePaint);
+                else if ((metaState & TerminalKeyListener.META_CTRL_LOCK) != 0)
+                    canvas.drawPath(ctrlCursor, cursorPaint);
+
+                // Restore previous clip region
+                canvas.restore();
+            }
+
+            // draw any highlighted area
+            if (bridge.isSelectingForCopy()) {
+                SelectionArea area = bridge.getSelectionArea();
+                canvas.save(Canvas.CLIP_SAVE_FLAG);
+                canvas.clipRect(
+                    area.getLeft() * bridge.charWidth,
+                    area.getTop() * bridge.charHeight,
+                    (area.getRight() + 1) * bridge.charWidth,
+                    (area.getBottom() + 1) * bridge.charHeight
+                );
+                canvas.drawPaint(cursorPaint);
+                canvas.restore();
+            }
+        }
+    }
+
+    public void notifyUser(String message) {
+        if (!notifications)
+            return;
+
+        if (notification != null) {
+            // Don't keep telling the user the same thing.
+            if (lastNotification != null && lastNotification.equals(message))
+                return;
+
+            notification.setText(message);
+            notification.show();
+        }
+        else {
+            notification = Toast.makeText(context, message, Toast.LENGTH_SHORT);
+            notification.show();
+        }
+
+        lastNotification = message;
+    }
+
+    /**
+     * Ask the {@link TerminalBridge} we're connected to to resize to a specific size.
+     * @param width
+     * @param height
+     */
+    public void forceSize(int width, int height) {
+        bridge.resizeComputed(width, height, getWidth(), getHeight());
+    }
+
+    /**
+     * Sets the ability for the TerminalView to display Toast notifications to the user.
+     * @param value whether to enable notifications or not
+     */
+    public void setNotifications(boolean value) {
+        notifications = value;
+    }
+
+    @Override
+    public boolean onCheckIsTextEditor() {
+        return true;
+    }
+
+    @Override
+    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+        outAttrs.imeOptions |=
+            EditorInfo.IME_FLAG_NO_EXTRACT_UI |
+            EditorInfo.IME_FLAG_NO_ENTER_ACTION |
+            EditorInfo.IME_ACTION_NONE;
+        outAttrs.inputType = EditorInfo.TYPE_NULL;
+        return new BaseInputConnection(this, false) {
+            @Override
+            public boolean deleteSurroundingText(int leftLength, int rightLength) {
+                if (rightLength == 0 && leftLength == 0) {
+                    return this.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
+                }
+
+                for (int i = 0; i < leftLength; i++) {
+                    this.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
+                }
+
+                // TODO: forward delete
+                return true;
+            }
+        };
+    }
+
+    public void propagateConsoleText(char[] rawText, int length) {
+        if (mAccessibilityActive) {
+            synchronized (mAccessibilityLock) {
+                mAccessibilityBuffer.append(rawText, 0, length);
+            }
+
+            if (mAccessibilityInitialized) {
+                if (mEventSender != null) {
+                    removeCallbacks(mEventSender);
+                }
+                else {
+                    mEventSender = new AccessibilityEventSender();
+                }
+
+                postDelayed(mEventSender, ACCESSIBILITY_EVENT_THRESHOLD);
+            }
+        }
+    }
+
+    private class AccessibilityEventSender implements Runnable {
+        public void run() {
+            synchronized (mAccessibilityLock) {
+                if (mCodeMatcher == null) {
+                    mCodeMatcher = mControlCodes.matcher(mAccessibilityBuffer);
+                }
+                else {
+                    mCodeMatcher.reset(mAccessibilityBuffer);
+                }
+
+                // Strip all control codes out.
+                mAccessibilityBuffer = new StringBuffer(mCodeMatcher.replaceAll(" "));
+                // Apply Backspaces using backspace character sequence
+                int i = mAccessibilityBuffer.indexOf(BACKSPACE_CODE);
+
+                while (i != -1) {
+                    mAccessibilityBuffer = mAccessibilityBuffer.replace(i == 0 ? 0 : i - 1, i
+                                           + BACKSPACE_CODE.length(), "");
+                    i = mAccessibilityBuffer.indexOf(BACKSPACE_CODE);
+                }
+
+                if (mAccessibilityBuffer.length() > 0) {
+                    AccessibilityEvent event = AccessibilityEvent.obtain(
+                                                   AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED);
+                    event.setFromIndex(0);
+                    event.setAddedCount(mAccessibilityBuffer.length());
+                    event.getText().add(mAccessibilityBuffer);
+                    sendAccessibilityEventUnchecked(event);
+                    mAccessibilityBuffer.setLength(0);
+                }
+            }
+        }
+    }
+
+    private class AccessibilityStateTester extends AsyncTask<Void, Void, Boolean> {
+        @Override
+        protected Boolean doInBackground(Void... params) {
+            /*
+             * Presumably if the accessibility manager is not enabled, we don't
+             * need to send accessibility events.
+             */
+            final AccessibilityManager accessibility = (AccessibilityManager) context
+                    .getSystemService(Context.ACCESSIBILITY_SERVICE);
+
+            if (!accessibility.isEnabled()) {
+                return false;
+            }
+
+            /*
+             * Restrict the set of intents to only accessibility services that
+             * have the category FEEDBACK_SPOKEN (aka, screen readers).
+             */
+            final Intent screenReaderIntent = new Intent(SCREENREADER_INTENT_ACTION);
+            screenReaderIntent.addCategory(SCREENREADER_INTENT_CATEGORY);
+            final ContentResolver cr = context.getContentResolver();
+            final List<ResolveInfo> screenReaders = context.getPackageManager().queryIntentServices(
+                    screenReaderIntent, 0);
+            boolean foundScreenReader = false;
+            final int N = screenReaders.size();
+
+            for (int i = 0; i < N; i++) {
+                final ResolveInfo screenReader = screenReaders.get(i);
+                /*
+                 * All screen readers are expected to implement a content
+                 * provider that responds to:
+                 * content://<nameofpackage>.providers.StatusProvider
+                 */
+                final Cursor cursor = cr.query(
+                                          Uri.parse("content://" + screenReader.serviceInfo.packageName
+                                                    + ".providers.StatusProvider"), null, null, null, null);
+
+                if (cursor != null && cursor.moveToFirst()) {
+                    /*
+                     * These content providers use a special cursor that only has
+                     * one element, an integer that is 1 if the screen reader is
+                     * running.
+                     */
+                    final int status = cursor.getInt(0);
+                    cursor.close();
+
+                    if (status == 1) {
+                        foundScreenReader = true;
+                        break;
+                    }
+                }
+            }
+
+            if (foundScreenReader) {
+                mControlCodes = Pattern.compile(CONTROL_CODE_PATTERN);
+            }
+
+            return foundScreenReader;
+        }
+
+        @Override
+        protected void onPostExecute(Boolean result) {
+            mAccessibilityActive = result;
+            mAccessibilityInitialized = true;
+
+            if (result) {
+                mEventSender = new AccessibilityEventSender();
+                postDelayed(mEventSender, ACCESSIBILITY_EVENT_THRESHOLD);
+            }
+            else {
+                mAccessibilityBuffer = null;
+            }
+        }
+    }
+
+    private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
+        @Override
+        public boolean onScale(ScaleGestureDetector detector) {
+            float mScaleFactor = detector.getScaleFactor();
+
+            if (mScaleFactor > 1.1) {
+                bridge.increaseFontSize();
+                return true;
+            }
+            else if (mScaleFactor < 0.9) {
+                bridge.decreaseFontSize();
+                return true;
+            }
+
+            return (false);
+        }
+    }
+
+}