/* * Copyright (C) 2011 The Android Open Source Project * * 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 android.view.textservice; import android.annotation.BinderThread; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.SuppressLint; import android.compat.annotation.UnsupportedAppUsage; import android.os.Binder; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; import android.os.Process; import android.os.RemoteException; import android.util.Log; import android.view.inputmethod.InputMethodManager; import com.android.internal.annotations.GuardedBy; import com.android.internal.textservice.ISpellCheckerSession; import com.android.internal.textservice.ISpellCheckerSessionListener; import com.android.internal.textservice.ITextServicesSessionListener; import dalvik.system.CloseGuard; import java.util.ArrayDeque; import java.util.Locale; import java.util.Queue; import java.util.concurrent.Executor; /** * The SpellCheckerSession interface provides the per client functionality of SpellCheckerService. * * * *

Applications

* *

In most cases, applications that are using the standard * {@link android.widget.TextView} or its subclasses will have little they need * to do to work well with spell checker services. The main things you need to * be aware of are:

* * * *

For the rare people amongst us writing client applications that use the spell checker service * directly, you will need to use {@link #getSuggestions(TextInfo, int)} or * {@link #getSuggestions(TextInfo[], int, boolean)} for obtaining results from the spell checker * service by yourself.

* *

Security

* *

There are a lot of security issues associated with spell checkers, * since they could monitor all the text being sent to them * through, for instance, {@link android.widget.TextView}. * The Android spell checker framework also allows * arbitrary third party spell checkers, so care must be taken to restrict their * selection and interactions.

* *

Here are some key points about the security architecture behind the * spell checker framework:

* * * */ public class SpellCheckerSession { private static final String TAG = SpellCheckerSession.class.getSimpleName(); private static final boolean DBG = false; /** * Name under which a SpellChecker service component publishes information about itself. * This meta-data must reference an XML resource. **/ public static final String SERVICE_META_DATA = "android.view.textservice.scs"; private static final int MSG_ON_GET_SUGGESTION_MULTIPLE = 1; private static final int MSG_ON_GET_SUGGESTION_MULTIPLE_FOR_SENTENCE = 2; private final InternalListener mInternalListener; private final TextServicesManager mTextServicesManager; private final SpellCheckerInfo mSpellCheckerInfo; @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553) private final SpellCheckerSessionListener mSpellCheckerSessionListener; private final SpellCheckerSessionListenerImpl mSpellCheckerSessionListenerImpl; private final Executor mExecutor; private final CloseGuard mGuard = CloseGuard.get(); /** * Constructor * @hide */ public SpellCheckerSession( SpellCheckerInfo info, TextServicesManager tsm, SpellCheckerSessionListener listener, Executor executor) { if (info == null || listener == null || tsm == null) { throw new NullPointerException(); } mSpellCheckerInfo = info; mSpellCheckerSessionListenerImpl = new SpellCheckerSessionListenerImpl(this); mInternalListener = new InternalListener(mSpellCheckerSessionListenerImpl); mTextServicesManager = tsm; mSpellCheckerSessionListener = listener; mExecutor = executor; mGuard.open("finishSession"); } /** * @return true if the connection to a text service of this session is disconnected and not * alive. */ public boolean isSessionDisconnected() { return mSpellCheckerSessionListenerImpl.isDisconnected(); } /** * Get the spell checker service info this spell checker session has. * @return SpellCheckerInfo for the specified locale. */ public SpellCheckerInfo getSpellChecker() { return mSpellCheckerInfo; } /** * Cancel pending and running spell check tasks */ public void cancel() { mSpellCheckerSessionListenerImpl.cancel(); } /** * Finish this session and allow TextServicesManagerService to disconnect the bound spell * checker. */ public void close() { mGuard.close(); mSpellCheckerSessionListenerImpl.close(); mTextServicesManager.finishSpellCheckerService(mSpellCheckerSessionListenerImpl); } /** * Get suggestions from the specified sentences * @param textInfos an array of text metadata for a spell checker * @param suggestionsLimit the maximum number of suggestions that will be returned */ public void getSentenceSuggestions(TextInfo[] textInfos, int suggestionsLimit) { final InputMethodManager imm = mTextServicesManager.getInputMethodManager(); if (imm != null && imm.isInputMethodSuppressingSpellChecker()) { handleOnGetSentenceSuggestionsMultiple(new SentenceSuggestionsInfo[0]); return; } mSpellCheckerSessionListenerImpl.getSentenceSuggestionsMultiple( textInfos, suggestionsLimit); } /** * Get candidate strings for a substring of the specified text. * @param textInfo text metadata for a spell checker * @param suggestionsLimit the maximum number of suggestions that will be returned * @deprecated use {@link SpellCheckerSession#getSentenceSuggestions(TextInfo[], int)} instead */ @Deprecated public void getSuggestions(TextInfo textInfo, int suggestionsLimit) { getSuggestions(new TextInfo[] {textInfo}, suggestionsLimit, false); } /** * A batch process of getSuggestions * @param textInfos an array of text metadata for a spell checker * @param suggestionsLimit the maximum number of suggestions that will be returned * @param sequentialWords true if textInfos can be treated as sequential words. * @deprecated use {@link SpellCheckerSession#getSentenceSuggestions(TextInfo[], int)} instead */ @Deprecated public void getSuggestions( TextInfo[] textInfos, int suggestionsLimit, boolean sequentialWords) { if (DBG) { Log.w(TAG, "getSuggestions from " + mSpellCheckerInfo.getId()); } final InputMethodManager imm = mTextServicesManager.getInputMethodManager(); if (imm != null && imm.isInputMethodSuppressingSpellChecker()) { handleOnGetSuggestionsMultiple(new SuggestionsInfo[0]); return; } mSpellCheckerSessionListenerImpl.getSuggestionsMultiple( textInfos, suggestionsLimit, sequentialWords); } void handleOnGetSuggestionsMultiple(SuggestionsInfo[] suggestionsInfos) { mExecutor.execute(() -> mSpellCheckerSessionListener.onGetSuggestions(suggestionsInfos)); } void handleOnGetSentenceSuggestionsMultiple(SentenceSuggestionsInfo[] suggestionsInfos) { mExecutor.execute(() -> mSpellCheckerSessionListener.onGetSentenceSuggestions(suggestionsInfos)); } private static final class SpellCheckerSessionListenerImpl extends ISpellCheckerSessionListener.Stub { private static final int TASK_CANCEL = 1; private static final int TASK_GET_SUGGESTIONS_MULTIPLE = 2; private static final int TASK_CLOSE = 3; private static final int TASK_GET_SUGGESTIONS_MULTIPLE_FOR_SENTENCE = 4; private static String taskToString(int task) { switch (task) { case TASK_CANCEL: return "TASK_CANCEL"; case TASK_GET_SUGGESTIONS_MULTIPLE: return "TASK_GET_SUGGESTIONS_MULTIPLE"; case TASK_CLOSE: return "TASK_CLOSE"; case TASK_GET_SUGGESTIONS_MULTIPLE_FOR_SENTENCE: return "TASK_GET_SUGGESTIONS_MULTIPLE_FOR_SENTENCE"; default: return "Unexpected task=" + task; } } private final Queue mPendingTasks = new ArrayDeque<>(); @GuardedBy("SpellCheckerSessionListenerImpl.this") private SpellCheckerSession mSpellCheckerSession; private static final int STATE_WAIT_CONNECTION = 0; private static final int STATE_CONNECTED = 1; private static final int STATE_CLOSED_AFTER_CONNECTION = 2; private static final int STATE_CLOSED_BEFORE_CONNECTION = 3; private static String stateToString(int state) { switch (state) { case STATE_WAIT_CONNECTION: return "STATE_WAIT_CONNECTION"; case STATE_CONNECTED: return "STATE_CONNECTED"; case STATE_CLOSED_AFTER_CONNECTION: return "STATE_CLOSED_AFTER_CONNECTION"; case STATE_CLOSED_BEFORE_CONNECTION: return "STATE_CLOSED_BEFORE_CONNECTION"; default: return "Unexpected state=" + state; } } private int mState = STATE_WAIT_CONNECTION; private ISpellCheckerSession mISpellCheckerSession; private HandlerThread mThread; private Handler mAsyncHandler; SpellCheckerSessionListenerImpl(SpellCheckerSession spellCheckerSession) { mSpellCheckerSession = spellCheckerSession; } private static class SpellCheckerParams { public final int mWhat; public final TextInfo[] mTextInfos; public final int mSuggestionsLimit; public final boolean mSequentialWords; public ISpellCheckerSession mSession; public SpellCheckerParams(int what, TextInfo[] textInfos, int suggestionsLimit, boolean sequentialWords) { mWhat = what; mTextInfos = textInfos; mSuggestionsLimit = suggestionsLimit; mSequentialWords = sequentialWords; } } private void processTask(ISpellCheckerSession session, SpellCheckerParams scp, boolean async) { if (DBG) { synchronized (this) { Log.d(TAG, "entering processTask:" + " session.hashCode()=#" + Integer.toHexString(session.hashCode()) + " scp.mWhat=" + taskToString(scp.mWhat) + " async=" + async + " mAsyncHandler=" + mAsyncHandler + " mState=" + stateToString(mState)); } } if (async || mAsyncHandler == null) { switch (scp.mWhat) { case TASK_CANCEL: try { session.onCancel(); } catch (RemoteException e) { Log.e(TAG, "Failed to cancel " + e); } break; case TASK_GET_SUGGESTIONS_MULTIPLE: try { session.onGetSuggestionsMultiple(scp.mTextInfos, scp.mSuggestionsLimit, scp.mSequentialWords); } catch (RemoteException e) { Log.e(TAG, "Failed to get suggestions " + e); } break; case TASK_GET_SUGGESTIONS_MULTIPLE_FOR_SENTENCE: try { session.onGetSentenceSuggestionsMultiple( scp.mTextInfos, scp.mSuggestionsLimit); } catch (RemoteException e) { Log.e(TAG, "Failed to get suggestions " + e); } break; case TASK_CLOSE: try { session.onClose(); } catch (RemoteException e) { Log.e(TAG, "Failed to close " + e); } break; } } else { // The interface is to a local object, so need to execute it // asynchronously. scp.mSession = session; mAsyncHandler.sendMessage(Message.obtain(mAsyncHandler, 1, scp)); } if (scp.mWhat == TASK_CLOSE) { // If we are closing, we want to clean up our state now even // if it is pending as an async operation. synchronized (this) { processCloseLocked(); } } } @GuardedBy("SpellCheckerSessionListenerImpl.this") private void processCloseLocked() { if (DBG) Log.d(TAG, "entering processCloseLocked:" + " session" + (mISpellCheckerSession != null ? ".hashCode()=#" + Integer.toHexString(mISpellCheckerSession.hashCode()) : "=null") + " mState=" + stateToString(mState)); mISpellCheckerSession = null; if (mThread != null) { mThread.quit(); } mSpellCheckerSession = null; mPendingTasks.clear(); mThread = null; mAsyncHandler = null; switch (mState) { case STATE_WAIT_CONNECTION: mState = STATE_CLOSED_BEFORE_CONNECTION; break; case STATE_CONNECTED: mState = STATE_CLOSED_AFTER_CONNECTION; break; default: Log.e(TAG, "processCloseLocked is called unexpectedly. mState=" + stateToString(mState)); break; } } public void onServiceConnected(ISpellCheckerSession session) { synchronized (this) { switch (mState) { case STATE_WAIT_CONNECTION: // OK, go ahead. break; case STATE_CLOSED_BEFORE_CONNECTION: // This is possible, and not an error. The client no longer is interested // in this connection. OK to ignore. if (DBG) Log.i(TAG, "ignoring onServiceConnected since the session is" + " already closed."); return; default: Log.e(TAG, "ignoring onServiceConnected due to unexpected mState=" + stateToString(mState)); return; } if (session == null) { Log.e(TAG, "ignoring onServiceConnected due to session=null"); return; } mISpellCheckerSession = session; if (session.asBinder() instanceof Binder && mThread == null) { if (DBG) Log.d(TAG, "starting HandlerThread in onServiceConnected."); // If this is a local object, we need to do our own threading // to make sure we handle it asynchronously. mThread = new HandlerThread("SpellCheckerSession", Process.THREAD_PRIORITY_BACKGROUND); mThread.start(); mAsyncHandler = new Handler(mThread.getLooper()) { @Override public void handleMessage(Message msg) { SpellCheckerParams scp = (SpellCheckerParams)msg.obj; processTask(scp.mSession, scp, true); } }; } mState = STATE_CONNECTED; if (DBG) { Log.d(TAG, "processed onServiceConnected: mISpellCheckerSession.hashCode()=#" + Integer.toHexString(mISpellCheckerSession.hashCode()) + " mPendingTasks.size()=" + mPendingTasks.size()); } while (!mPendingTasks.isEmpty()) { processTask(session, mPendingTasks.poll(), false); } } } public void cancel() { processOrEnqueueTask(new SpellCheckerParams(TASK_CANCEL, null, 0, false)); } public void getSuggestionsMultiple( TextInfo[] textInfos, int suggestionsLimit, boolean sequentialWords) { processOrEnqueueTask( new SpellCheckerParams(TASK_GET_SUGGESTIONS_MULTIPLE, textInfos, suggestionsLimit, sequentialWords)); } public void getSentenceSuggestionsMultiple(TextInfo[] textInfos, int suggestionsLimit) { processOrEnqueueTask( new SpellCheckerParams(TASK_GET_SUGGESTIONS_MULTIPLE_FOR_SENTENCE, textInfos, suggestionsLimit, false)); } public void close() { processOrEnqueueTask(new SpellCheckerParams(TASK_CLOSE, null, 0, false)); } public boolean isDisconnected() { synchronized (this) { return mState != STATE_CONNECTED; } } private void processOrEnqueueTask(SpellCheckerParams scp) { ISpellCheckerSession session; synchronized (this) { if (scp.mWhat == TASK_CLOSE && (mState == STATE_CLOSED_AFTER_CONNECTION || mState == STATE_CLOSED_BEFORE_CONNECTION)) { // It is OK to call SpellCheckerSession#close() multiple times. // Don't output confusing/misleading warning messages. return; } if (mState != STATE_WAIT_CONNECTION && mState != STATE_CONNECTED) { Log.e(TAG, "ignoring processOrEnqueueTask due to unexpected mState=" + stateToString(mState) + " scp.mWhat=" + taskToString(scp.mWhat)); return; } if (mState == STATE_WAIT_CONNECTION) { // If we are still waiting for the connection. Need to pay special attention. if (scp.mWhat == TASK_CLOSE) { processCloseLocked(); return; } // Enqueue the task to task queue. SpellCheckerParams closeTask = null; if (scp.mWhat == TASK_CANCEL) { if (DBG) Log.d(TAG, "canceling pending tasks in processOrEnqueueTask."); while (!mPendingTasks.isEmpty()) { final SpellCheckerParams tmp = mPendingTasks.poll(); if (tmp.mWhat == TASK_CLOSE) { // Only one close task should be processed, while we need to remove // all close tasks from the queue closeTask = tmp; } } } mPendingTasks.offer(scp); if (closeTask != null) { mPendingTasks.offer(closeTask); } if (DBG) Log.d(TAG, "queueing tasks in processOrEnqueueTask since the" + " connection is not established." + " mPendingTasks.size()=" + mPendingTasks.size()); return; } session = mISpellCheckerSession; } // session must never be null here. processTask(session, scp, false); } @BinderThread @Override public void onGetSuggestions(SuggestionsInfo[] results) { SpellCheckerSession session = getSpellCheckerSession(); if (session != null) { // Lock should not be held when calling callback, in order to avoid deadlock. session.handleOnGetSuggestionsMultiple(results); } } @BinderThread @Override public void onGetSentenceSuggestions(SentenceSuggestionsInfo[] results) { SpellCheckerSession session = getSpellCheckerSession(); if (session != null) { // Lock should not be held when calling callback, in order to avoid deadlock. session.handleOnGetSentenceSuggestionsMultiple(results); } } @Nullable private SpellCheckerSession getSpellCheckerSession() { synchronized (SpellCheckerSessionListenerImpl.this) { return mSpellCheckerSession; } } } /** Parameters used to create a {@link SpellCheckerSession}. */ public static class SpellCheckerSessionParams { @Nullable private final Locale mLocale; private final boolean mShouldReferToSpellCheckerLanguageSettings; private final @SuggestionsInfo.ResultAttrs int mSupportedAttributes; private final Bundle mExtras; private SpellCheckerSessionParams(Locale locale, boolean referToSpellCheckerLanguageSettings, int supportedAttributes, Bundle extras) { mLocale = locale; mShouldReferToSpellCheckerLanguageSettings = referToSpellCheckerLanguageSettings; mSupportedAttributes = supportedAttributes; mExtras = extras; } /** * Returns the locale in which the spell checker should operate. * * @see android.service.textservice.SpellCheckerService.Session#getLocale() */ @SuppressLint("UseIcu") @Nullable public Locale getLocale() { return mLocale; } /** * Returns true if the user's spell checker language settings should be used to determine * the spell checker locale. */ public boolean shouldReferToSpellCheckerLanguageSettings() { return mShouldReferToSpellCheckerLanguageSettings; } /** * Returns a bitmask of {@link SuggestionsInfo} attributes that the spell checker can set * in {@link SuggestionsInfo} it returns. * * @see android.service.textservice.SpellCheckerService.Session#getSupportedAttributes() */ public @SuggestionsInfo.ResultAttrs int getSupportedAttributes() { return mSupportedAttributes; } /** * Returns a bundle containing extra parameters for the spell checker. * *

This bundle can be used to pass implementation-specific parameters to the * {@link android.service.textservice.SpellCheckerService} implementation. * * @see android.service.textservice.SpellCheckerService.Session#getBundle() */ @NonNull public Bundle getExtras() { return mExtras; } /** Builder of {@link SpellCheckerSessionParams}. */ public static final class Builder { @Nullable private Locale mLocale; private boolean mShouldReferToSpellCheckerLanguageSettings = false; private @SuggestionsInfo.ResultAttrs int mSupportedAttributes = 0; private Bundle mExtras = Bundle.EMPTY; /** Constructs a {@code Builder}. */ public Builder() { } /** * Returns constructed {@link SpellCheckerSession} instance. * *

Before calling this method, either {@link #setLocale(Locale)} should be called * with a non-null locale or * {@link #setShouldReferToSpellCheckerLanguageSettings(boolean)} should be called with * {@code true}. */ @NonNull public SpellCheckerSessionParams build() { if (mLocale == null && !mShouldReferToSpellCheckerLanguageSettings) { throw new IllegalArgumentException("mLocale should not be null if " + " mShouldReferToSpellCheckerLanguageSettings is false."); } return new SpellCheckerSessionParams(mLocale, mShouldReferToSpellCheckerLanguageSettings, mSupportedAttributes, mExtras); } /** * Sets the locale in which the spell checker should operate. * * @see android.service.textservice.SpellCheckerService.Session#getLocale() */ @NonNull public Builder setLocale(@SuppressLint("UseIcu") @Nullable Locale locale) { mLocale = locale; return this; } /** * Sets whether or not the user's spell checker language settings should be used to * determine spell checker locale. * *

If {@code shouldReferToSpellCheckerLanguageSettings} is true, the exact way of * determining spell checker locale differs based on {@code locale} specified in * {@link #setLocale(Locale)}. * If {@code shouldReferToSpellCheckerLanguageSettings} is true and {@code locale} is * null, the locale specified in Settings will be used. If * {@code shouldReferToSpellCheckerLanguageSettings} is true and {@code locale} is not * null, {@link SpellCheckerSession} can be created only when the locale specified in * Settings is the same as {@code locale}. Exceptionally, if * {@code shouldReferToSpellCheckerLanguageSettings} is true and {@code locale} is * language only (e.g. "en"), the specified locale in Settings (e.g. "en_US") will be * used. * * @see #setLocale(Locale) */ @NonNull public Builder setShouldReferToSpellCheckerLanguageSettings( boolean shouldReferToSpellCheckerLanguageSettings) { mShouldReferToSpellCheckerLanguageSettings = shouldReferToSpellCheckerLanguageSettings; return this; } /** * Sets a bitmask of {@link SuggestionsInfo} attributes that the spell checker can set * in {@link SuggestionsInfo} it returns. * * @see android.service.textservice.SpellCheckerService.Session#getSupportedAttributes() */ @NonNull public Builder setSupportedAttributes( @SuggestionsInfo.ResultAttrs int supportedAttributes) { mSupportedAttributes = supportedAttributes; return this; } /** * Sets a bundle containing extra parameters for the spell checker. * *

This bundle can be used to pass implementation-specific parameters to the * {@link android.service.textservice.SpellCheckerService} implementation. * * @see android.service.textservice.SpellCheckerService.Session#getBundle() */ @NonNull public Builder setExtras(@NonNull Bundle extras) { mExtras = extras; return this; } } } /** * Callback for getting results from text services */ public interface SpellCheckerSessionListener { /** * Callback for {@link SpellCheckerSession#getSuggestions(TextInfo, int)} * and {@link SpellCheckerSession#getSuggestions(TextInfo[], int, boolean)} * @param results an array of {@link SuggestionsInfo}s. * These results are suggestions for {@link TextInfo}s queried by * {@link SpellCheckerSession#getSuggestions(TextInfo, int)} or * {@link SpellCheckerSession#getSuggestions(TextInfo[], int, boolean)} */ public void onGetSuggestions(SuggestionsInfo[] results); /** * Callback for {@link SpellCheckerSession#getSentenceSuggestions(TextInfo[], int)} * @param results an array of {@link SentenceSuggestionsInfo}s. * These results are suggestions for {@link TextInfo}s * queried by {@link SpellCheckerSession#getSentenceSuggestions(TextInfo[], int)}. */ public void onGetSentenceSuggestions(SentenceSuggestionsInfo[] results); } private static final class InternalListener extends ITextServicesSessionListener.Stub { private final SpellCheckerSessionListenerImpl mParentSpellCheckerSessionListenerImpl; public InternalListener(SpellCheckerSessionListenerImpl spellCheckerSessionListenerImpl) { mParentSpellCheckerSessionListenerImpl = spellCheckerSessionListenerImpl; } @Override public void onServiceConnected(ISpellCheckerSession session) { mParentSpellCheckerSessionListenerImpl.onServiceConnected(session); } } @Override protected void finalize() throws Throwable { try { // Note that mGuard will be null if the constructor threw. if (mGuard != null) { mGuard.warnIfOpen(); close(); } } finally { super.finalize(); } } /** * @hide */ public ITextServicesSessionListener getTextServicesSessionListener() { return mInternalListener; } /** * @hide */ public ISpellCheckerSessionListener getSpellCheckerSessionListener() { return mSpellCheckerSessionListenerImpl; } }