com.googlecode.eyesfree.brailleback.BrailleIME.java Source code

Java tutorial

Introduction

Here is the source code for com.googlecode.eyesfree.brailleback.BrailleIME.java

Source

/*
 * Copyright (C) 2012 Google Inc.
 *
 * 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.googlecode.eyesfree.brailleback;

import com.googlecode.eyesfree.braille.translate.BrailleTranslator;
import com.googlecode.eyesfree.utils.LogUtils;

import android.content.Context;
import android.inputmethodservice.InputMethodService;
import android.os.SystemClock;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;

import java.lang.ref.WeakReference;

/**
 * Input method service for keys from the connected braille display.
 */
public class BrailleIME extends InputMethodService {

    /** Interface between the IME and a "host" in the accessibility service. */
    public interface Host {
        /**
         * Returns a translator for input, if available.
         * The IME will ignore text input if no translator is available.
         */
        BrailleTranslator getBrailleTranslator();

        /**
         * Returns a display manager for output, if available.
         * The IME will not generate output if no display manager is available.
         */
        DisplayManager getDisplayManager();

        /**
         * Returns a feedback manager, if available.
         * No feedback will be emitted if no feedback manager is available.
         */
        FeedbackManager getFeedbackManager();

        /**
         * Called when the IME has been created by the system.
         * @see InputMethodService#onCreate()
         */
        void onCreateIME();

        /**
         * Called when the IME is being destroyed by the system.
         * @see InputMethodService#onDestroy()
         */
        void onDestroyIME();

        /**
         * Called when the IME has associated with an input connection.
         * @see InputMethodService#onBindInput()
         */
        void onBindInput();

        /**
         * Called when the IME has disassociated from an input connection.
         * @see InputMethodService#onUnbindInput()
         */
        void onUnbindInput();

        /**
         * Called when the IME has started an input session.
         * @see InputMethodService#onStartInput(EditorInfo, boolean)
         */
        void onStartInput(EditorInfo attribute, boolean restarting);

        /**
         * Called when the IME has finished an input session.
         * @see InputMethodService#onFinishInput()
         */
        void onFinishInput();

        /**
         * Called when the IME opens the input view.
         * @see InputMethodService#onStartInputView(EditorInfo, boolean)
         */
        void onStartInputView(EditorInfo info, boolean restarting);

        /**
         * Called when the IME closes the input view.
         * @see InputMethodService#onFinishInputView(boolean)
         */
        void onFinishInputView(boolean finishingInput);
    }

    public static final int DIRECTION_FORWARD = 1;
    public static final int DIRECTION_BACKWARD = -1;
    private static final int MAX_REQUEST_CHARS = 1000;
    /** Marks the extent of the editable text. */
    private static final MarkingSpan EDIT_TEXT_SPAN = new MarkingSpan();
    /** Marks the extent of the action button. */
    private static final MarkingSpan ACTION_LABEL_SPAN = new MarkingSpan();

    private static WeakReference<BrailleIME> sInstance;
    private static WeakReference<Host> sHost;
    private final Host mHost; // for testing
    private InputMethodManager mInputMethodManager;

    private ExtractedText mExtractedText;
    private int mExtractedTextToken = 0;
    /**
     * Start of current selection, relative to the start of the extracted
     * text.
     */
    private int mSelectionStart;
    /**
     * End (inclusive) of current selection, relative to the start of the
     * extracted text.
     */
    private int mSelectionEnd;
    /**
     * The text that is shown on the display.  Might be only part of the
     * full edit field if it is larger than {@code MAX_REQUEST_CHARS}.
     */
    private StringBuilder mCurrentText = new StringBuilder();

    public static BrailleIME getActiveInstance() {
        return sInstance != null ? sInstance.get() : null;
    }

    public static void setSingletonHost(Host host) {
        sHost = host != null ? new WeakReference<Host>(host) : null;
    }

    /** Constructor for general use. */
    public BrailleIME() {
        mHost = null;
    }

    /** Constructor for testing. Allows using a non-global host object. */
    /*package*/ BrailleIME(Host host, Context baseContext) {
        mHost = host;
        attachBaseContext(baseContext);
    }

    /* InputMethodService implementation */

    @Override
    public void onCreate() {
        super.onCreate();
        sInstance = new WeakReference<BrailleIME>(this);
        mInputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
        LogUtils.log(this, Log.VERBOSE, "Created Braille IME");

        Host host = getHost();
        if (host != null) {
            host.onCreateIME();
        }
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        // onFinishInput is not called when switching away from this IME
        // to another one, so clear the state here as well.
        mExtractedText = null;
        updateCurrentText();
        sInstance = null;

        Host host = getHost();
        if (host != null) {
            host.onDestroyIME();
        }
    }

    @Override
    public void onBindInput() {
        super.onBindInput();

        Host host = getHost();
        if (host != null) {
            host.onBindInput();
        }
    }

    @Override
    public void onUnbindInput() {
        super.onUnbindInput();

        Host host = getHost();
        if (host != null) {
            host.onUnbindInput();
        }
    }

    @Override
    public void onStartInput(EditorInfo attribute, boolean restarting) {
        super.onStartInput(attribute, restarting);
        LogUtils.log(this, Log.VERBOSE,
                "onStartInput: inputType: %x, imeOption: %x, " + ", label: %s, hint: %s, package: %s, ",
                attribute.inputType, attribute.imeOptions, attribute.label, attribute.hintText,
                attribute.packageName);
        InputConnection ic = getCurrentInputConnection();
        if (ic != null) {
            ExtractedTextRequest req = new ExtractedTextRequest();
            req.token = ++mExtractedTextToken;
            req.hintMaxChars = MAX_REQUEST_CHARS;
            mExtractedText = getCurrentInputConnection().getExtractedText(req,
                    InputConnection.GET_EXTRACTED_TEXT_MONITOR);
        } else {
            mExtractedText = null;
        }
        updateCurrentText();
        updateDisplay();

        Host host = getHost();
        if (host != null) {
            host.onStartInput(attribute, restarting);
        }
    }

    @Override
    public void onFinishInput() {
        super.onFinishInput();
        LogUtils.log(this, Log.VERBOSE, "onFinishInput");
        mExtractedText = null;
        updateCurrentText();

        Host host = getHost();
        if (host != null) {
            host.onFinishInput();
        }
    }

    @Override
    public void onStartInputView(EditorInfo info, boolean restarting) {
        super.onStartInputView(info, restarting);
        LogUtils.log(this, Log.VERBOSE, "onStartInputView");

        Host host = getHost();
        if (host != null) {
            host.onStartInputView(info, restarting);
        }
    }

    @Override
    public void onFinishInputView(boolean finishingInput) {
        super.onFinishInputView(finishingInput);
        LogUtils.log(this, Log.VERBOSE, "onFinishInputView");

        Host host = getHost();
        if (host != null) {
            host.onFinishInputView(finishingInput);
        }
    }

    @Override
    public boolean onEvaluateFullscreenMode() {
        return false;
    }

    @Override
    public View onCreateInputView() {
        final LayoutInflater inflater = getLayoutInflater();
        final View inputView = inflater.inflate(R.layout.braille_ime, null);
        inputView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                switchAwayFromThisIme();
            }
        });
        return inputView;
    }

    @Override
    public void onUpdateExtractedText(int token, ExtractedText text) {
        // The superclass only deals with fullscreen support, which we've
        // disabled, so don't call it here.
        if (mExtractedText == null || token != mExtractedTextToken) {
            return;
        }
        mExtractedText = text;
        updateCurrentText();
        updateDisplay();
    }

    @Override
    public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd,
            int candidatesStart, int candidatesEnd) {
        if (mExtractedText != null) {
            int off = mExtractedText.startOffset;
            int len = mCurrentText.length();
            newSelStart -= off;
            newSelEnd -= off;
            newSelStart = newSelStart < 0 ? 0 : (newSelStart > len ? len : newSelStart);
            newSelEnd = newSelEnd < 0 ? 0 : (newSelEnd > len ? len : newSelEnd);
            mSelectionStart = newSelStart;
            mSelectionEnd = newSelEnd;
            updateDisplay();
        }
    }

    /* Exposed for use by the host. */

    public boolean route(int position, DisplayManager.Content content) {
        InputConnection ic = getCurrentInputConnection();
        Spanned text = content.getSpanned();
        if (ic != null && text != null) {
            MarkingSpan[] spans = text.getSpans(position, position, MarkingSpan.class);
            if (spans.length == 1) {
                if (spans[0] == ACTION_LABEL_SPAN) {
                    return emitFeedbackOnFailure(sendDefaultAction(), FeedbackManager.TYPE_COMMAND_FAILED);
                } else if (spans[0] == EDIT_TEXT_SPAN) {
                    return emitFeedbackOnFailure(setCursor(ic, position - text.getSpanStart(EDIT_TEXT_SPAN)),
                            FeedbackManager.TYPE_COMMAND_FAILED);
                }
            } else if (spans.length == 0) {
                // Most likely, the user clicked on the label/hint part of the
                // content.
                emitFeedback(FeedbackManager.TYPE_NAVIGATE_OUT_OF_BOUNDS);
                return true;
            } else if (spans.length > 1) {
                LogUtils.log(this, Log.ERROR, "Conflicting spans in Braille IME");
            }
        }
        emitFeedback(FeedbackManager.TYPE_COMMAND_FAILED);
        return true;
    }

    public boolean sendDefaultAction() {
        if (!allowsDefaultAction()) {
            return false;
        }
        EditorInfo ei = getCurrentInputEditorInfo();
        InputConnection ic = getCurrentInputConnection();
        if (ei == null || ic == null) {
            return false;
        }

        int actionId = ei.actionId;
        if (actionId != 0) {
            return ic.performEditorAction(actionId);
        } else {
            return sendDefaultEditorAction(false);
        }
    }

    public boolean moveCursor(int direction, int granularity) {
        if (mCurrentText == null) {
            return false;
        }
        switch (granularity) {
        case AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER:
            int keyCode = (direction == DIRECTION_BACKWARD) ? KeyEvent.KEYCODE_DPAD_LEFT
                    : KeyEvent.KEYCODE_DPAD_RIGHT;
            return sendAndroidKey(keyCode);
        case AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH:
            if (!isMultiLineField()) {
                return false;
            }
            int newPos = (direction == DIRECTION_BACKWARD) ? findParagraphBreakBackward()
                    : findParagraphBreakForward();
            // newPos == the length means having the insertion point after
            // the last character, so the below comparison is correct.
            if (newPos < 0 || newPos > mCurrentText.length()) {
                return false;
            }
            return setCursor(getCurrentInputConnection(), newPos);
        }
        return false;
    }

    /**
     * Attempts to send down and up key events for a raw {@code keyCode}
     * through an input connection.
     */
    public boolean sendAndroidKey(int keyCode) {
        return emitFeedbackOnFailure(sendAndroidKeyInternal(keyCode), FeedbackManager.TYPE_COMMAND_FAILED);
    }

    public boolean handleBrailleKey(int dots) {
        return emitFeedbackOnFailure(handleBrailleKeyInternal(dots), FeedbackManager.TYPE_COMMAND_FAILED);
    }

    public void updateDisplay() {
        if (mExtractedText == null) {
            return;
        }
        DisplayManager displayManager = getCurrentDisplayManager();
        if (displayManager == null) {
            return;
        }
        EditorInfo ei = getCurrentInputEditorInfo();
        if (ei == null) {
            LogUtils.log(this, Log.WARN, "No input editor info");
            return;
        }
        CharSequence label = ei.label;
        CharSequence hint = ei.hintText;
        if (TextUtils.isEmpty(label)) {
            label = hint;
            hint = null;
        }
        SpannableStringBuilder text = new SpannableStringBuilder();
        if (!TextUtils.isEmpty(label)) {
            text.append(label);
            text.append(": "); // TODO: Put in a resource.
        }
        int editStart = text.length();
        text.append(mCurrentText);
        addMarkingSpan(text, EDIT_TEXT_SPAN, editStart);
        CharSequence actionLabel = getActionLabel();
        if (actionLabel != null) {
            text.append(" [");
            text.append(actionLabel);
            text.append("]");
            addMarkingSpan(text, ACTION_LABEL_SPAN, text.length() - (actionLabel.length() + 2));
        }
        DisplaySpans.addSelection(text, editStart + mSelectionStart, editStart + mSelectionEnd);
        displayManager.setContent(new DisplayManager.Content(text).setPanStrategy(DisplayManager.Content.PAN_CURSOR)
                .setSplitParagraphs(isMultiLineField()));
    }

    /* Private */

    private void updateCurrentText() {
        if (mExtractedText == null) {
            mCurrentText.setLength(0);
            mSelectionStart = mSelectionEnd = 0;
            return;
        }
        if (mExtractedText.text != null) {
            int len = mCurrentText.length();
            if (mExtractedText.partialStartOffset < 0) {
                // Complete update.
                mCurrentText.replace(0, len, mExtractedText.text.toString());
            } else {
                int start = Math.min(mExtractedText.partialStartOffset, len);
                int end = Math.min(mExtractedText.partialEndOffset, len);
                mCurrentText.replace(start, end, mExtractedText.text.toString());
            }
        }

        // Update selection, keeping it within the text range even if the
        // client messed up.
        int len = mCurrentText.length();
        int start = mExtractedText.selectionStart;
        start = start < 0 ? 0 : (start > len ? len : start);
        int end = mExtractedText.selectionEnd;
        end = end < 0 ? 0 : (end > len ? len : end);
        mSelectionStart = start;
        mSelectionEnd = end;
    }

    private int findParagraphBreakBackward() {
        if (mSelectionStart <= 0) {
            return -1;
        }
        return mCurrentText.lastIndexOf("\n", mSelectionStart - 2) + 1;
    }

    private int findParagraphBreakForward() {
        if (mSelectionEnd >= mCurrentText.length()) {
            return -1;
        }
        int index = mCurrentText.indexOf("\n", mSelectionEnd);
        if (index >= 0 && index < mCurrentText.length()) {
            return index + 1;
        } else {
            return mCurrentText.length();
        }
    }

    private boolean setCursor(InputConnection ic, int pos) {
        if (mCurrentText == null) {
            return false;
        }
        int textLen = mCurrentText.length();
        pos = (pos < 0) ? 0 : ((pos <= textLen) ? pos : textLen);
        return ic.setSelection(pos, pos);
    }

    private boolean sendAndroidKeyInternal(int keyCode) {
        LogUtils.log(this, Log.VERBOSE, "sendAndroidKey: %d", keyCode);
        InputConnection ic = getCurrentInputConnection();
        if (ic == null) {
            return false;
        }
        long eventTime = SystemClock.uptimeMillis();
        if (!ic.sendKeyEvent(new KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0 /*repeat*/))) {
            return false;
        }
        return ic.sendKeyEvent(new KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, keyCode, 0 /*repeat*/));
    }

    private boolean handleBrailleKeyInternal(int dots) {
        // TODO: Support more than computer braille.  This means that
        // there's not a 1:1 correspondence between a cell and a character,
        // so requires more book-keeping.
        BrailleTranslator translator = getCurrentBrailleTranslator();
        InputConnection ic = getCurrentInputConnection();
        if (translator == null || ic == null) {
            LogUtils.log(this, Log.WARN, "missing translator %s or IC %s", translator, ic);
            return false;
        }
        CharSequence text = translator.backTranslate(new byte[] { (byte) dots });
        if (!TextUtils.isEmpty(text)) {
            return ic.commitText(text, 1);
        }
        return true;
    }

    private Host getHost() {
        if (mHost != null) {
            return mHost;
        } else if (sHost != null) {
            return sHost.get();
        } else {
            return null;
        }
    }

    private BrailleTranslator getCurrentBrailleTranslator() {
        Host host = getHost();
        return host != null ? host.getBrailleTranslator() : null;
    }

    private DisplayManager getCurrentDisplayManager() {
        Host host = getHost();
        return host != null ? host.getDisplayManager() : null;
    }

    private FeedbackManager getCurrentFeedbackManager() {
        Host host = getHost();
        return host != null ? host.getFeedbackManager() : null;
    }

    private CharSequence getActionLabel() {
        EditorInfo ei = getCurrentInputEditorInfo();
        if (ei == null) {
            return null;
        }
        if (!allowsDefaultAction()) {
            // The edit field asks us to not show this inline in the
            // editor.
            return null;
        }
        if (ei.actionLabel != null) {
            return ei.actionLabel;
        }
        return getTextForImeAction(ei.imeOptions);
    }

    private boolean allowsDefaultAction() {
        EditorInfo ei = getCurrentInputEditorInfo();
        if (ei != null && (ei.imeOptions & EditorInfo.IME_FLAG_NO_ENTER_ACTION) == 0) {
            return true;
        }
        return false;
    }

    private boolean isMultiLineField() {
        EditorInfo ei = getCurrentInputEditorInfo();
        if (ei == null) {
            return false;
        }
        int type = ei.inputType;
        final int mask = EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE | EditorInfo.TYPE_TEXT_FLAG_IME_MULTI_LINE;
        // Consider this a multiline field if it is multiline in the main
        // text field, and not multiline only in the ime fullscreen mode.
        return ((type & mask) == EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE);
    }

    private void addMarkingSpan(Spannable spannable, MarkingSpan span, int position) {
        if (position < spannable.length()) {
            spannable.setSpan(span, position, spannable.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
    }

    // Visible for testing.
    /*package*/ void switchAwayFromThisIme() {
        LogUtils.log(this, Log.DEBUG, "Switching to last IME");
        mInputMethodManager.switchToNextInputMethod(getWindow().getWindow().getAttributes().token, false);
    }

    private void emitFeedback(int type) {
        FeedbackManager feedbackManager = getCurrentFeedbackManager();
        if (feedbackManager != null) {
            feedbackManager.emitFeedback(type);
        }
    }

    private boolean emitFeedbackOnFailure(boolean result, int type) {
        if (!result) {
            emitFeedback(type);
        }
        return true;
    }

    private static class MarkingSpan {
    }

}