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.accessibility;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.text.TextUtils;
22import android.util.Log;
23import android.util.SparseIntArray;
24import android.view.inputmethod.EditorInfo;
25
26import com.android.inputmethod.keyboard.Key;
27import com.android.inputmethod.keyboard.Keyboard;
28import com.android.inputmethod.keyboard.KeyboardId;
29import com.android.inputmethod.latin.Constants;
30import com.android.inputmethod.latin.R;
31import com.android.inputmethod.latin.utils.StringUtils;
32
33import java.util.Locale;
34
35final class KeyCodeDescriptionMapper {
36    private static final String TAG = KeyCodeDescriptionMapper.class.getSimpleName();
37    private static final String SPOKEN_LETTER_RESOURCE_NAME_FORMAT = "spoken_accented_letter_%04X";
38    private static final String SPOKEN_SYMBOL_RESOURCE_NAME_FORMAT = "spoken_symbol_%04X";
39    private static final String SPOKEN_EMOJI_RESOURCE_NAME_FORMAT = "spoken_emoji_%04X";
40
41    // The resource ID of the string spoken for obscured keys
42    private static final int OBSCURED_KEY_RES_ID = R.string.spoken_description_dot;
43
44    private static final KeyCodeDescriptionMapper sInstance = new KeyCodeDescriptionMapper();
45
46    public static KeyCodeDescriptionMapper getInstance() {
47        return sInstance;
48    }
49
50    // Sparse array of spoken description resource IDs indexed by key codes
51    private final SparseIntArray mKeyCodeMap = new SparseIntArray();
52
53    private KeyCodeDescriptionMapper() {
54        // Special non-character codes defined in Keyboard
55        mKeyCodeMap.put(Constants.CODE_SPACE, R.string.spoken_description_space);
56        mKeyCodeMap.put(Constants.CODE_DELETE, R.string.spoken_description_delete);
57        mKeyCodeMap.put(Constants.CODE_ENTER, R.string.spoken_description_return);
58        mKeyCodeMap.put(Constants.CODE_SETTINGS, R.string.spoken_description_settings);
59        mKeyCodeMap.put(Constants.CODE_SHIFT, R.string.spoken_description_shift);
60        mKeyCodeMap.put(Constants.CODE_SHORTCUT, R.string.spoken_description_mic);
61        mKeyCodeMap.put(Constants.CODE_SWITCH_ALPHA_SYMBOL, R.string.spoken_description_to_symbol);
62        mKeyCodeMap.put(Constants.CODE_TAB, R.string.spoken_description_tab);
63        mKeyCodeMap.put(Constants.CODE_LANGUAGE_SWITCH,
64                R.string.spoken_description_language_switch);
65        mKeyCodeMap.put(Constants.CODE_ACTION_NEXT, R.string.spoken_description_action_next);
66        mKeyCodeMap.put(Constants.CODE_ACTION_PREVIOUS,
67                R.string.spoken_description_action_previous);
68        mKeyCodeMap.put(Constants.CODE_EMOJI, R.string.spoken_description_emoji);
69        // Because the upper-case and lower-case mappings of the following letters is depending on
70        // the locale, the upper case descriptions should be defined here. The lower case
71        // descriptions are handled in {@link #getSpokenLetterDescriptionId(Context,int)}.
72        // U+0049: "I" LATIN CAPITAL LETTER I
73        // U+0069: "i" LATIN SMALL LETTER I
74        // U+0130: "İ" LATIN CAPITAL LETTER I WITH DOT ABOVE
75        // U+0131: "ı" LATIN SMALL LETTER DOTLESS I
76        mKeyCodeMap.put(0x0049, R.string.spoken_letter_0049);
77        mKeyCodeMap.put(0x0130, R.string.spoken_letter_0130);
78    }
79
80    /**
81     * Returns the localized description of the action performed by a specified
82     * key based on the current keyboard state.
83     *
84     * @param context The package's context.
85     * @param keyboard The keyboard on which the key resides.
86     * @param key The key from which to obtain a description.
87     * @param shouldObscure {@true} if text (e.g. non-control) characters should be obscured.
88     * @return a character sequence describing the action performed by pressing the key
89     */
90    public String getDescriptionForKey(final Context context, final Keyboard keyboard,
91            final Key key, final boolean shouldObscure) {
92        final int code = key.getCode();
93
94        if (code == Constants.CODE_SWITCH_ALPHA_SYMBOL) {
95            final String description = getDescriptionForSwitchAlphaSymbol(context, keyboard);
96            if (description != null) {
97                return description;
98            }
99        }
100
101        if (code == Constants.CODE_SHIFT) {
102            return getDescriptionForShiftKey(context, keyboard);
103        }
104
105        if (code == Constants.CODE_ENTER) {
106            // The following function returns the correct description in all action and
107            // regular enter cases, taking care of all modes.
108            return getDescriptionForActionKey(context, keyboard, key);
109        }
110
111        if (code == Constants.CODE_OUTPUT_TEXT) {
112            return key.getOutputText();
113        }
114
115        // Just attempt to speak the description.
116        if (code != Constants.CODE_UNSPECIFIED) {
117            // If the key description should be obscured, now is the time to do it.
118            final boolean isDefinedNonCtrl = Character.isDefined(code)
119                    && !Character.isISOControl(code);
120            if (shouldObscure && isDefinedNonCtrl) {
121                return context.getString(OBSCURED_KEY_RES_ID);
122            }
123            final String description = getDescriptionForCodePoint(context, code);
124            if (description != null) {
125                return description;
126            }
127            if (!TextUtils.isEmpty(key.getLabel())) {
128                return key.getLabel();
129            }
130            return context.getString(R.string.spoken_description_unknown);
131        }
132        return null;
133    }
134
135    /**
136     * Returns a context-specific description for the CODE_SWITCH_ALPHA_SYMBOL
137     * key or {@code null} if there is not a description provided for the
138     * current keyboard context.
139     *
140     * @param context The package's context.
141     * @param keyboard The keyboard on which the key resides.
142     * @return a character sequence describing the action performed by pressing the key
143     */
144    private static String getDescriptionForSwitchAlphaSymbol(final Context context,
145            final Keyboard keyboard) {
146        final KeyboardId keyboardId = keyboard.mId;
147        final int elementId = keyboardId.mElementId;
148        final int resId;
149
150        switch (elementId) {
151        case KeyboardId.ELEMENT_ALPHABET:
152        case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED:
153        case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED:
154        case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED:
155        case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED:
156            resId = R.string.spoken_description_to_symbol;
157            break;
158        case KeyboardId.ELEMENT_SYMBOLS:
159        case KeyboardId.ELEMENT_SYMBOLS_SHIFTED:
160            resId = R.string.spoken_description_to_alpha;
161            break;
162        case KeyboardId.ELEMENT_PHONE:
163            resId = R.string.spoken_description_to_symbol;
164            break;
165        case KeyboardId.ELEMENT_PHONE_SYMBOLS:
166            resId = R.string.spoken_description_to_numeric;
167            break;
168        default:
169            Log.e(TAG, "Missing description for keyboard element ID:" + elementId);
170            return null;
171        }
172        return context.getString(resId);
173    }
174
175    /**
176     * Returns a context-sensitive description of the "Shift" key.
177     *
178     * @param context The package's context.
179     * @param keyboard The keyboard on which the key resides.
180     * @return A context-sensitive description of the "Shift" key.
181     */
182    private static String getDescriptionForShiftKey(final Context context,
183            final Keyboard keyboard) {
184        final KeyboardId keyboardId = keyboard.mId;
185        final int elementId = keyboardId.mElementId;
186        final int resId;
187
188        switch (elementId) {
189        case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED:
190        case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED:
191            resId = R.string.spoken_description_caps_lock;
192            break;
193        case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED:
194        case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED:
195            resId = R.string.spoken_description_shift_shifted;
196            break;
197        case KeyboardId.ELEMENT_SYMBOLS:
198            resId = R.string.spoken_description_symbols_shift;
199            break;
200        case KeyboardId.ELEMENT_SYMBOLS_SHIFTED:
201            resId = R.string.spoken_description_symbols_shift_shifted;
202            break;
203        default:
204            resId = R.string.spoken_description_shift;
205        }
206        return context.getString(resId);
207    }
208
209    /**
210     * Returns a context-sensitive description of the "Enter" action key.
211     *
212     * @param context The package's context.
213     * @param keyboard The keyboard on which the key resides.
214     * @param key The key to describe.
215     * @return Returns a context-sensitive description of the "Enter" action key.
216     */
217    private static String getDescriptionForActionKey(final Context context, final Keyboard keyboard,
218            final Key key) {
219        final KeyboardId keyboardId = keyboard.mId;
220        final int actionId = keyboardId.imeAction();
221        final int resId;
222
223        // Always use the label, if available.
224        if (!TextUtils.isEmpty(key.getLabel())) {
225            return key.getLabel().trim();
226        }
227
228        // Otherwise, use the action ID.
229        switch (actionId) {
230        case EditorInfo.IME_ACTION_SEARCH:
231            resId = R.string.spoken_description_search;
232            break;
233        case EditorInfo.IME_ACTION_GO:
234            resId = R.string.label_go_key;
235            break;
236        case EditorInfo.IME_ACTION_SEND:
237            resId = R.string.label_send_key;
238            break;
239        case EditorInfo.IME_ACTION_NEXT:
240            resId = R.string.label_next_key;
241            break;
242        case EditorInfo.IME_ACTION_DONE:
243            resId = R.string.label_done_key;
244            break;
245        case EditorInfo.IME_ACTION_PREVIOUS:
246            resId = R.string.label_previous_key;
247            break;
248        default:
249            resId = R.string.spoken_description_return;
250        }
251        return context.getString(resId);
252    }
253
254    /**
255     * Returns a localized character sequence describing what will happen when
256     * the specified key is pressed based on its key code point.
257     *
258     * @param context The package's context.
259     * @param codePoint The code point from which to obtain a description.
260     * @return a character sequence describing the code point.
261     */
262    public String getDescriptionForCodePoint(final Context context, final int codePoint) {
263        // If the key description should be obscured, now is the time to do it.
264        final int index = mKeyCodeMap.indexOfKey(codePoint);
265        if (index >= 0) {
266            return context.getString(mKeyCodeMap.valueAt(index));
267        }
268        final String accentedLetter = getSpokenAccentedLetterDescription(context, codePoint);
269        if (accentedLetter != null) {
270            return accentedLetter;
271        }
272        // Here, <code>code</code> may be a base (non-accented) letter.
273        final String unsupportedSymbol = getSpokenSymbolDescription(context, codePoint);
274        if (unsupportedSymbol != null) {
275            return unsupportedSymbol;
276        }
277        final String emojiDescription = getSpokenEmojiDescription(context, codePoint);
278        if (emojiDescription != null) {
279            return emojiDescription;
280        }
281        if (Character.isDefined(codePoint) && !Character.isISOControl(codePoint)) {
282            return StringUtils.newSingleCodePointString(codePoint);
283        }
284        return null;
285    }
286
287    // TODO: Remove this method once TTS supports those accented letters' verbalization.
288    private String getSpokenAccentedLetterDescription(final Context context, final int code) {
289        final boolean isUpperCase = Character.isUpperCase(code);
290        final int baseCode = isUpperCase ? Character.toLowerCase(code) : code;
291        final int baseIndex = mKeyCodeMap.indexOfKey(baseCode);
292        final int resId = (baseIndex >= 0) ? mKeyCodeMap.valueAt(baseIndex)
293                : getSpokenDescriptionId(context, baseCode, SPOKEN_LETTER_RESOURCE_NAME_FORMAT);
294        if (resId == 0) {
295            return null;
296        }
297        final String spokenText = context.getString(resId);
298        return isUpperCase ? context.getString(R.string.spoken_description_upper_case, spokenText)
299                : spokenText;
300    }
301
302    // TODO: Remove this method once TTS supports those symbols' verbalization.
303    private String getSpokenSymbolDescription(final Context context, final int code) {
304        final int resId = getSpokenDescriptionId(context, code, SPOKEN_SYMBOL_RESOURCE_NAME_FORMAT);
305        if (resId == 0) {
306            return null;
307        }
308        final String spokenText = context.getString(resId);
309        if (!TextUtils.isEmpty(spokenText)) {
310            return spokenText;
311        }
312        // If a translated description is empty, fall back to unknown symbol description.
313        return context.getString(R.string.spoken_symbol_unknown);
314    }
315
316    // TODO: Remove this method once TTS supports emoji verbalization.
317    private String getSpokenEmojiDescription(final Context context, final int code) {
318        final int resId = getSpokenDescriptionId(context, code, SPOKEN_EMOJI_RESOURCE_NAME_FORMAT);
319        if (resId == 0) {
320            return null;
321        }
322        final String spokenText = context.getString(resId);
323        if (!TextUtils.isEmpty(spokenText)) {
324            return spokenText;
325        }
326        // If a translated description is empty, fall back to unknown emoji description.
327        return context.getString(R.string.spoken_emoji_unknown);
328    }
329
330    private int getSpokenDescriptionId(final Context context, final int code,
331            final String resourceNameFormat) {
332        final String resourceName = String.format(Locale.ROOT, resourceNameFormat, code);
333        final Resources resources = context.getResources();
334        // Note that the resource package name may differ from the context package name.
335        final String resourcePackageName = resources.getResourcePackageName(
336                R.string.spoken_description_unknown);
337        final int resId = resources.getIdentifier(resourceName, "string", resourcePackageName);
338        if (resId != 0) {
339            mKeyCodeMap.append(code, resId);
340        }
341        return resId;
342    }
343}
344