1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.inputmethod.keyboard;
18
19import static com.android.inputmethod.latin.common.Constants.ImeOption.FORCE_ASCII;
20import static com.android.inputmethod.latin.common.Constants.ImeOption.NO_SETTINGS_KEY;
21
22import android.content.Context;
23import android.content.res.Resources;
24import android.content.res.TypedArray;
25import android.content.res.XmlResourceParser;
26import android.text.InputType;
27import android.util.Log;
28import android.util.SparseArray;
29import android.util.Xml;
30import android.view.inputmethod.EditorInfo;
31import android.view.inputmethod.InputMethodSubtype;
32
33import com.android.inputmethod.compat.EditorInfoCompatUtils;
34import com.android.inputmethod.compat.InputMethodSubtypeCompatUtils;
35import com.android.inputmethod.compat.UserManagerCompatUtils;
36import com.android.inputmethod.keyboard.internal.KeyboardBuilder;
37import com.android.inputmethod.keyboard.internal.KeyboardParams;
38import com.android.inputmethod.keyboard.internal.UniqueKeysCache;
39import com.android.inputmethod.latin.InputAttributes;
40import com.android.inputmethod.latin.R;
41import com.android.inputmethod.latin.RichInputMethodSubtype;
42import com.android.inputmethod.latin.define.DebugFlags;
43import com.android.inputmethod.latin.utils.InputTypeUtils;
44import com.android.inputmethod.latin.utils.ScriptUtils;
45import com.android.inputmethod.latin.utils.SubtypeLocaleUtils;
46import com.android.inputmethod.latin.utils.XmlParseUtils;
47
48import org.xmlpull.v1.XmlPullParser;
49import org.xmlpull.v1.XmlPullParserException;
50
51import java.io.IOException;
52import java.lang.ref.SoftReference;
53import java.util.HashMap;
54
55import javax.annotation.Nonnull;
56import javax.annotation.Nullable;
57
58/**
59 * This class represents a set of keyboard layouts. Each of them represents a different keyboard
60 * specific to a keyboard state, such as alphabet, symbols, and so on.  Layouts in the same
61 * {@link KeyboardLayoutSet} are related to each other.
62 * A {@link KeyboardLayoutSet} needs to be created for each
63 * {@link android.view.inputmethod.EditorInfo}.
64 */
65public final class KeyboardLayoutSet {
66    private static final String TAG = KeyboardLayoutSet.class.getSimpleName();
67    private static final boolean DEBUG_CACHE = false;
68
69    private static final String TAG_KEYBOARD_SET = "KeyboardLayoutSet";
70    private static final String TAG_ELEMENT = "Element";
71    private static final String TAG_FEATURE = "Feature";
72
73    private static final String KEYBOARD_LAYOUT_SET_RESOURCE_PREFIX = "keyboard_layout_set_";
74
75    private final Context mContext;
76    @Nonnull
77    private final Params mParams;
78
79    // How many layouts we forcibly keep in cache. This only includes ALPHABET (default) and
80    // ALPHABET_AUTOMATIC_SHIFTED layouts - other layouts may stay in memory in the map of
81    // soft-references, but we forcibly cache this many alphabetic/auto-shifted layouts.
82    private static final int FORCIBLE_CACHE_SIZE = 4;
83    // By construction of soft references, anything that is also referenced somewhere else
84    // will stay in the cache. So we forcibly keep some references in an array to prevent
85    // them from disappearing from sKeyboardCache.
86    private static final Keyboard[] sForcibleKeyboardCache = new Keyboard[FORCIBLE_CACHE_SIZE];
87    private static final HashMap<KeyboardId, SoftReference<Keyboard>> sKeyboardCache =
88            new HashMap<>();
89    @Nonnull
90    private static final UniqueKeysCache sUniqueKeysCache = UniqueKeysCache.newInstance();
91    private final static HashMap<InputMethodSubtype, Integer> sScriptIdsForSubtypes =
92            new HashMap<>();
93
94    @SuppressWarnings("serial")
95    public static final class KeyboardLayoutSetException extends RuntimeException {
96        public final KeyboardId mKeyboardId;
97
98        public KeyboardLayoutSetException(final Throwable cause, final KeyboardId keyboardId) {
99            super(cause);
100            mKeyboardId = keyboardId;
101        }
102    }
103
104    private static final class ElementParams {
105        int mKeyboardXmlId;
106        boolean mProximityCharsCorrectionEnabled;
107        boolean mSupportsSplitLayout;
108        boolean mAllowRedundantMoreKeys;
109        public ElementParams() {}
110    }
111
112    public static final class Params {
113        String mKeyboardLayoutSetName;
114        int mMode;
115        boolean mDisableTouchPositionCorrectionDataForTest;
116        // TODO: Use {@link InputAttributes} instead of these variables.
117        EditorInfo mEditorInfo;
118        boolean mIsPasswordField;
119        boolean mVoiceInputKeyEnabled;
120        boolean mNoSettingsKey;
121        boolean mLanguageSwitchKeyEnabled;
122        RichInputMethodSubtype mSubtype;
123        boolean mIsSpellChecker;
124        int mKeyboardWidth;
125        int mKeyboardHeight;
126        int mScriptId = ScriptUtils.SCRIPT_LATIN;
127        // Indicates if the user has enabled the split-layout preference
128        // and the required ProductionFlags are enabled.
129        boolean mIsSplitLayoutEnabledByUser;
130        // Indicates if split layout is actually enabled, taking into account
131        // whether the user has enabled it, and the keyboard layout supports it.
132        boolean mIsSplitLayoutEnabled;
133        // Sparse array of KeyboardLayoutSet element parameters indexed by element's id.
134        final SparseArray<ElementParams> mKeyboardLayoutSetElementIdToParamsMap =
135                new SparseArray<>();
136    }
137
138    public static void onSystemLocaleChanged() {
139        clearKeyboardCache();
140    }
141
142    public static void onKeyboardThemeChanged() {
143        clearKeyboardCache();
144    }
145
146    private static void clearKeyboardCache() {
147        sKeyboardCache.clear();
148        sUniqueKeysCache.clear();
149    }
150
151    public static int getScriptId(final Resources resources,
152            @Nonnull final InputMethodSubtype subtype) {
153        final Integer value = sScriptIdsForSubtypes.get(subtype);
154        if (null == value) {
155            final int scriptId = Builder.readScriptId(resources, subtype);
156            sScriptIdsForSubtypes.put(subtype, scriptId);
157            return scriptId;
158        }
159        return value;
160    }
161
162    KeyboardLayoutSet(final Context context, @Nonnull final Params params) {
163        mContext = context;
164        mParams = params;
165    }
166
167    @Nonnull
168    public Keyboard getKeyboard(final int baseKeyboardLayoutSetElementId) {
169        final int keyboardLayoutSetElementId;
170        switch (mParams.mMode) {
171        case KeyboardId.MODE_PHONE:
172            if (baseKeyboardLayoutSetElementId == KeyboardId.ELEMENT_SYMBOLS) {
173                keyboardLayoutSetElementId = KeyboardId.ELEMENT_PHONE_SYMBOLS;
174            } else {
175                keyboardLayoutSetElementId = KeyboardId.ELEMENT_PHONE;
176            }
177            break;
178        case KeyboardId.MODE_NUMBER:
179        case KeyboardId.MODE_DATE:
180        case KeyboardId.MODE_TIME:
181        case KeyboardId.MODE_DATETIME:
182            keyboardLayoutSetElementId = KeyboardId.ELEMENT_NUMBER;
183            break;
184        default:
185            keyboardLayoutSetElementId = baseKeyboardLayoutSetElementId;
186            break;
187        }
188
189        ElementParams elementParams = mParams.mKeyboardLayoutSetElementIdToParamsMap.get(
190                keyboardLayoutSetElementId);
191        if (elementParams == null) {
192            elementParams = mParams.mKeyboardLayoutSetElementIdToParamsMap.get(
193                    KeyboardId.ELEMENT_ALPHABET);
194        }
195        // Note: The keyboard for each shift state, and mode are represented as an elementName
196        // attribute in a keyboard_layout_set XML file.  Also each keyboard layout XML resource is
197        // specified as an elementKeyboard attribute in the file.
198        // The KeyboardId is an internal key for a Keyboard object.
199
200        mParams.mIsSplitLayoutEnabled = mParams.mIsSplitLayoutEnabledByUser
201                && elementParams.mSupportsSplitLayout;
202        final KeyboardId id = new KeyboardId(keyboardLayoutSetElementId, mParams);
203        try {
204            return getKeyboard(elementParams, id);
205        } catch (final RuntimeException e) {
206            Log.e(TAG, "Can't create keyboard: " + id, e);
207            throw new KeyboardLayoutSetException(e, id);
208        }
209    }
210
211    @Nonnull
212    private Keyboard getKeyboard(final ElementParams elementParams, final KeyboardId id) {
213        final SoftReference<Keyboard> ref = sKeyboardCache.get(id);
214        final Keyboard cachedKeyboard = (ref == null) ? null : ref.get();
215        if (cachedKeyboard != null) {
216            if (DEBUG_CACHE) {
217                Log.d(TAG, "keyboard cache size=" + sKeyboardCache.size() + ": HIT  id=" + id);
218            }
219            return cachedKeyboard;
220        }
221
222        final KeyboardBuilder<KeyboardParams> builder =
223                new KeyboardBuilder<>(mContext, new KeyboardParams(sUniqueKeysCache));
224        sUniqueKeysCache.setEnabled(id.isAlphabetKeyboard());
225        builder.setAllowRedundantMoreKes(elementParams.mAllowRedundantMoreKeys);
226        final int keyboardXmlId = elementParams.mKeyboardXmlId;
227        builder.load(keyboardXmlId, id);
228        if (mParams.mDisableTouchPositionCorrectionDataForTest) {
229            builder.disableTouchPositionCorrectionDataForTest();
230        }
231        builder.setProximityCharsCorrectionEnabled(elementParams.mProximityCharsCorrectionEnabled);
232        final Keyboard keyboard = builder.build();
233        sKeyboardCache.put(id, new SoftReference<>(keyboard));
234        if ((id.mElementId == KeyboardId.ELEMENT_ALPHABET
235                || id.mElementId == KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED)
236                && !mParams.mIsSpellChecker) {
237            // We only forcibly cache the primary, "ALPHABET", layouts.
238            for (int i = sForcibleKeyboardCache.length - 1; i >= 1; --i) {
239                sForcibleKeyboardCache[i] = sForcibleKeyboardCache[i - 1];
240            }
241            sForcibleKeyboardCache[0] = keyboard;
242            if (DEBUG_CACHE) {
243                Log.d(TAG, "forcing caching of keyboard with id=" + id);
244            }
245        }
246        if (DEBUG_CACHE) {
247            Log.d(TAG, "keyboard cache size=" + sKeyboardCache.size() + ": "
248                    + ((ref == null) ? "LOAD" : "GCed") + " id=" + id);
249        }
250        return keyboard;
251    }
252
253    public int getScriptId() {
254        return mParams.mScriptId;
255    }
256
257    public static final class Builder {
258        private final Context mContext;
259        private final String mPackageName;
260        private final Resources mResources;
261
262        private final Params mParams = new Params();
263
264        private static final EditorInfo EMPTY_EDITOR_INFO = new EditorInfo();
265
266        public Builder(final Context context, @Nullable final EditorInfo ei) {
267            mContext = context;
268            mPackageName = context.getPackageName();
269            mResources = context.getResources();
270            final Params params = mParams;
271
272            final EditorInfo editorInfo = (ei != null) ? ei : EMPTY_EDITOR_INFO;
273            params.mMode = getKeyboardMode(editorInfo);
274            // TODO: Consolidate those with {@link InputAttributes}.
275            params.mEditorInfo = editorInfo;
276            params.mIsPasswordField = InputTypeUtils.isPasswordInputType(editorInfo.inputType);
277            params.mNoSettingsKey = InputAttributes.inPrivateImeOptions(
278                    mPackageName, NO_SETTINGS_KEY, editorInfo);
279
280            // When the device is still unlocked, features like showing the IME setting app need to
281            // be locked down.
282            // TODO: Switch to {@code UserManagerCompat.isUserUnlocked()} in the support-v4 library
283            // when it becomes publicly available.
284            @UserManagerCompatUtils.LockState
285            final int lockState = UserManagerCompatUtils.getUserLockState(context);
286            if (lockState == UserManagerCompatUtils.LOCK_STATE_LOCKED) {
287                params.mNoSettingsKey = true;
288            }
289        }
290
291        public Builder setKeyboardGeometry(final int keyboardWidth, final int keyboardHeight) {
292            mParams.mKeyboardWidth = keyboardWidth;
293            mParams.mKeyboardHeight = keyboardHeight;
294            return this;
295        }
296
297        public Builder setSubtype(@Nonnull final RichInputMethodSubtype subtype) {
298            final boolean asciiCapable = InputMethodSubtypeCompatUtils.isAsciiCapable(subtype);
299            // TODO: Consolidate with {@link InputAttributes}.
300            @SuppressWarnings("deprecation")
301            final boolean deprecatedForceAscii = InputAttributes.inPrivateImeOptions(
302                    mPackageName, FORCE_ASCII, mParams.mEditorInfo);
303            final boolean forceAscii = EditorInfoCompatUtils.hasFlagForceAscii(
304                    mParams.mEditorInfo.imeOptions)
305                    || deprecatedForceAscii;
306            final RichInputMethodSubtype keyboardSubtype = (forceAscii && !asciiCapable)
307                    ? RichInputMethodSubtype.getNoLanguageSubtype()
308                    : subtype;
309            mParams.mSubtype = keyboardSubtype;
310            mParams.mKeyboardLayoutSetName = KEYBOARD_LAYOUT_SET_RESOURCE_PREFIX
311                    + keyboardSubtype.getKeyboardLayoutSetName();
312            return this;
313        }
314
315        public Builder setIsSpellChecker(final boolean isSpellChecker) {
316            mParams.mIsSpellChecker = isSpellChecker;
317            return this;
318        }
319
320        public Builder setVoiceInputKeyEnabled(final boolean enabled) {
321            mParams.mVoiceInputKeyEnabled = enabled;
322            return this;
323        }
324
325        public Builder setLanguageSwitchKeyEnabled(final boolean enabled) {
326            mParams.mLanguageSwitchKeyEnabled = enabled;
327            return this;
328        }
329
330        public Builder disableTouchPositionCorrectionData() {
331            mParams.mDisableTouchPositionCorrectionDataForTest = true;
332            return this;
333        }
334
335        public Builder setSplitLayoutEnabledByUser(final boolean enabled) {
336            mParams.mIsSplitLayoutEnabledByUser = enabled;
337            return this;
338        }
339
340        // Super redux version of reading the script ID for some subtype from Xml.
341        static int readScriptId(final Resources resources, final InputMethodSubtype subtype) {
342            final String layoutSetName = KEYBOARD_LAYOUT_SET_RESOURCE_PREFIX
343                    + SubtypeLocaleUtils.getKeyboardLayoutSetName(subtype);
344            final int xmlId = getXmlId(resources, layoutSetName);
345            final XmlResourceParser parser = resources.getXml(xmlId);
346            try {
347                while (parser.getEventType() != XmlPullParser.END_DOCUMENT) {
348                    // Bovinate through the XML stupidly searching for TAG_FEATURE, and read
349                    // the script Id from it.
350                    parser.next();
351                    final String tag = parser.getName();
352                    if (TAG_FEATURE.equals(tag)) {
353                        return readScriptIdFromTagFeature(resources, parser);
354                    }
355                }
356            } catch (final IOException | XmlPullParserException e) {
357                throw new RuntimeException(e.getMessage() + " in " + layoutSetName, e);
358            } finally {
359                parser.close();
360            }
361            // If the tag is not found, then the default script is Latin.
362            return ScriptUtils.SCRIPT_LATIN;
363        }
364
365        private static int readScriptIdFromTagFeature(final Resources resources,
366                final XmlPullParser parser) throws IOException, XmlPullParserException {
367            final TypedArray featureAttr = resources.obtainAttributes(Xml.asAttributeSet(parser),
368                    R.styleable.KeyboardLayoutSet_Feature);
369            try {
370                final int scriptId =
371                        featureAttr.getInt(R.styleable.KeyboardLayoutSet_Feature_supportedScript,
372                                ScriptUtils.SCRIPT_UNKNOWN);
373                XmlParseUtils.checkEndTag(TAG_FEATURE, parser);
374                return scriptId;
375            } finally {
376                featureAttr.recycle();
377            }
378        }
379
380        public KeyboardLayoutSet build() {
381            if (mParams.mSubtype == null)
382                throw new RuntimeException("KeyboardLayoutSet subtype is not specified");
383            final int xmlId = getXmlId(mResources, mParams.mKeyboardLayoutSetName);
384            try {
385                parseKeyboardLayoutSet(mResources, xmlId);
386            } catch (final IOException | XmlPullParserException e) {
387                throw new RuntimeException(e.getMessage() + " in " + mParams.mKeyboardLayoutSetName,
388                        e);
389            }
390            return new KeyboardLayoutSet(mContext, mParams);
391        }
392
393        private static int getXmlId(final Resources resources, final String keyboardLayoutSetName) {
394            final String packageName = resources.getResourcePackageName(
395                    R.xml.keyboard_layout_set_qwerty);
396            return resources.getIdentifier(keyboardLayoutSetName, "xml", packageName);
397        }
398
399        private void parseKeyboardLayoutSet(final Resources res, final int resId)
400                throws XmlPullParserException, IOException {
401            final XmlResourceParser parser = res.getXml(resId);
402            try {
403                while (parser.getEventType() != XmlPullParser.END_DOCUMENT) {
404                    final int event = parser.next();
405                    if (event == XmlPullParser.START_TAG) {
406                        final String tag = parser.getName();
407                        if (TAG_KEYBOARD_SET.equals(tag)) {
408                            parseKeyboardLayoutSetContent(parser);
409                        } else {
410                            throw new XmlParseUtils.IllegalStartTag(parser, tag, TAG_KEYBOARD_SET);
411                        }
412                    }
413                }
414            } finally {
415                parser.close();
416            }
417        }
418
419        private void parseKeyboardLayoutSetContent(final XmlPullParser parser)
420                throws XmlPullParserException, IOException {
421            while (parser.getEventType() != XmlPullParser.END_DOCUMENT) {
422                final int event = parser.next();
423                if (event == XmlPullParser.START_TAG) {
424                    final String tag = parser.getName();
425                    if (TAG_ELEMENT.equals(tag)) {
426                        parseKeyboardLayoutSetElement(parser);
427                    } else if (TAG_FEATURE.equals(tag)) {
428                        mParams.mScriptId = readScriptIdFromTagFeature(mResources, parser);
429                    } else {
430                        throw new XmlParseUtils.IllegalStartTag(parser, tag, TAG_KEYBOARD_SET);
431                    }
432                } else if (event == XmlPullParser.END_TAG) {
433                    final String tag = parser.getName();
434                    if (TAG_KEYBOARD_SET.equals(tag)) {
435                        break;
436                    }
437                    throw new XmlParseUtils.IllegalEndTag(parser, tag, TAG_KEYBOARD_SET);
438                }
439            }
440        }
441
442        private void parseKeyboardLayoutSetElement(final XmlPullParser parser)
443                throws XmlPullParserException, IOException {
444            final TypedArray a = mResources.obtainAttributes(Xml.asAttributeSet(parser),
445                    R.styleable.KeyboardLayoutSet_Element);
446            try {
447                XmlParseUtils.checkAttributeExists(a,
448                        R.styleable.KeyboardLayoutSet_Element_elementName, "elementName",
449                        TAG_ELEMENT, parser);
450                XmlParseUtils.checkAttributeExists(a,
451                        R.styleable.KeyboardLayoutSet_Element_elementKeyboard, "elementKeyboard",
452                        TAG_ELEMENT, parser);
453                XmlParseUtils.checkEndTag(TAG_ELEMENT, parser);
454
455                final ElementParams elementParams = new ElementParams();
456                final int elementName = a.getInt(
457                        R.styleable.KeyboardLayoutSet_Element_elementName, 0);
458                elementParams.mKeyboardXmlId = a.getResourceId(
459                        R.styleable.KeyboardLayoutSet_Element_elementKeyboard, 0);
460                elementParams.mProximityCharsCorrectionEnabled = a.getBoolean(
461                        R.styleable.KeyboardLayoutSet_Element_enableProximityCharsCorrection,
462                        false);
463                elementParams.mSupportsSplitLayout = a.getBoolean(
464                        R.styleable.KeyboardLayoutSet_Element_supportsSplitLayout, false);
465                elementParams.mAllowRedundantMoreKeys = a.getBoolean(
466                        R.styleable.KeyboardLayoutSet_Element_allowRedundantMoreKeys, true);
467                mParams.mKeyboardLayoutSetElementIdToParamsMap.put(elementName, elementParams);
468            } finally {
469                a.recycle();
470            }
471        }
472
473        private static int getKeyboardMode(final EditorInfo editorInfo) {
474            final int inputType = editorInfo.inputType;
475            final int variation = inputType & InputType.TYPE_MASK_VARIATION;
476
477            switch (inputType & InputType.TYPE_MASK_CLASS) {
478            case InputType.TYPE_CLASS_NUMBER:
479                return KeyboardId.MODE_NUMBER;
480            case InputType.TYPE_CLASS_DATETIME:
481                switch (variation) {
482                case InputType.TYPE_DATETIME_VARIATION_DATE:
483                    return KeyboardId.MODE_DATE;
484                case InputType.TYPE_DATETIME_VARIATION_TIME:
485                    return KeyboardId.MODE_TIME;
486                default: // InputType.TYPE_DATETIME_VARIATION_NORMAL
487                    return KeyboardId.MODE_DATETIME;
488                }
489            case InputType.TYPE_CLASS_PHONE:
490                return KeyboardId.MODE_PHONE;
491            case InputType.TYPE_CLASS_TEXT:
492                if (InputTypeUtils.isEmailVariation(variation)) {
493                    return KeyboardId.MODE_EMAIL;
494                } else if (variation == InputType.TYPE_TEXT_VARIATION_URI) {
495                    return KeyboardId.MODE_URL;
496                } else if (variation == InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE) {
497                    return KeyboardId.MODE_IM;
498                } else if (variation == InputType.TYPE_TEXT_VARIATION_FILTER) {
499                    return KeyboardId.MODE_TEXT;
500                } else {
501                    return KeyboardId.MODE_TEXT;
502                }
503            default:
504                return KeyboardId.MODE_TEXT;
505            }
506        }
507    }
508}
509