TtsEngines.java revision 6dfa6e2a9be08a3a0f152a7b772efc8ce2469bce
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * 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, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License.
15 */
16package android.speech.tts;
17
18import org.xmlpull.v1.XmlPullParserException;
19
20import android.content.ContentResolver;
21import android.content.Context;
22import android.content.Intent;
23import android.content.pm.ApplicationInfo;
24import android.content.pm.PackageManager;
25import android.content.pm.PackageManager.NameNotFoundException;
26import android.content.pm.ResolveInfo;
27import android.content.pm.ServiceInfo;
28import android.content.res.Resources;
29import android.content.res.TypedArray;
30import android.content.res.XmlResourceParser;
31
32import static android.provider.Settings.Secure.getString;
33
34import android.provider.Settings;
35import android.speech.tts.TextToSpeech.Engine;
36import android.speech.tts.TextToSpeech.EngineInfo;
37import android.text.TextUtils;
38import android.util.AttributeSet;
39import android.util.Log;
40import android.util.Xml;
41
42import java.io.IOException;
43import java.util.ArrayList;
44import java.util.Collections;
45import java.util.Comparator;
46import java.util.HashMap;
47import java.util.List;
48import java.util.Locale;
49import java.util.Map;
50import java.util.MissingResourceException;
51
52/**
53 * Support class for querying the list of available engines
54 * on the device and deciding which one to use etc.
55 *
56 * Comments in this class the use the shorthand "system engines" for engines that
57 * are a part of the system image.
58 *
59 * This class is thread-safe/
60 *
61 * @hide
62 */
63public class TtsEngines {
64    private static final String TAG = "TtsEngines";
65    private static final boolean DBG = false;
66
67    /** Locale delimiter used by the old-style 3 char locale string format (like "eng-usa") */
68    private static final String LOCALE_DELIMITER_OLD = "-";
69
70    /** Locale delimiter used by the new-style locale string format (Locale.toString() results,
71     * like "en_US") */
72    private static final String LOCALE_DELIMITER_NEW = "_";
73
74    private final Context mContext;
75
76    /** Mapping of various language strings to the normalized Locale form */
77    private static final Map<String, String> sNormalizeLanguage;
78
79    /** Mapping of various country strings to the normalized Locale form */
80    private static final Map<String, String> sNormalizeCountry;
81
82    // Populate the sNormalize* maps
83    static {
84        HashMap<String, String> normalizeLanguage = new HashMap<String, String>();
85        for (String language : Locale.getISOLanguages()) {
86            try {
87                normalizeLanguage.put(new Locale(language).getISO3Language(), language);
88            } catch (MissingResourceException e) {
89                continue;
90            }
91        }
92        sNormalizeLanguage = Collections.unmodifiableMap(normalizeLanguage);
93
94        HashMap<String, String> normalizeCountry = new HashMap<String, String>();
95        for (String country : Locale.getISOCountries()) {
96            try {
97                normalizeCountry.put(new Locale("", country).getISO3Country(), country);
98            } catch (MissingResourceException e) {
99                continue;
100            }
101        }
102        sNormalizeCountry = Collections.unmodifiableMap(normalizeCountry);
103    }
104
105    public TtsEngines(Context ctx) {
106        mContext = ctx;
107    }
108
109    /**
110     * @return the default TTS engine. If the user has set a default, and the engine
111     *         is available on the device, the default is returned. Otherwise,
112     *         the highest ranked engine is returned as per {@link EngineInfoComparator}.
113     */
114    public String getDefaultEngine() {
115        String engine = getString(mContext.getContentResolver(),
116                Settings.Secure.TTS_DEFAULT_SYNTH);
117        return isEngineInstalled(engine) ? engine : getHighestRankedEngineName();
118    }
119
120    /**
121     * @return the package name of the highest ranked system engine, {@code null}
122     *         if no TTS engines were present in the system image.
123     */
124    public String getHighestRankedEngineName() {
125        final List<EngineInfo> engines = getEngines();
126
127        if (engines.size() > 0 && engines.get(0).system) {
128            return engines.get(0).name;
129        }
130
131        return null;
132    }
133
134    /**
135     * Returns the engine info for a given engine name. Note that engines are
136     * identified by their package name.
137     */
138    public EngineInfo getEngineInfo(String packageName) {
139        PackageManager pm = mContext.getPackageManager();
140        Intent intent = new Intent(Engine.INTENT_ACTION_TTS_SERVICE);
141        intent.setPackage(packageName);
142        List<ResolveInfo> resolveInfos = pm.queryIntentServices(intent,
143                PackageManager.MATCH_DEFAULT_ONLY);
144        // Note that the current API allows only one engine per
145        // package name. Since the "engine name" is the same as
146        // the package name.
147        if (resolveInfos != null && resolveInfos.size() == 1) {
148            return getEngineInfo(resolveInfos.get(0), pm);
149        }
150
151        return null;
152    }
153
154    /**
155     * Gets a list of all installed TTS engines.
156     *
157     * @return A list of engine info objects. The list can be empty, but never {@code null}.
158     */
159    public List<EngineInfo> getEngines() {
160        PackageManager pm = mContext.getPackageManager();
161        Intent intent = new Intent(Engine.INTENT_ACTION_TTS_SERVICE);
162        List<ResolveInfo> resolveInfos =
163                pm.queryIntentServices(intent, PackageManager.MATCH_DEFAULT_ONLY);
164        if (resolveInfos == null) return Collections.emptyList();
165
166        List<EngineInfo> engines = new ArrayList<EngineInfo>(resolveInfos.size());
167
168        for (ResolveInfo resolveInfo : resolveInfos) {
169            EngineInfo engine = getEngineInfo(resolveInfo, pm);
170            if (engine != null) {
171                engines.add(engine);
172            }
173        }
174        Collections.sort(engines, EngineInfoComparator.INSTANCE);
175
176        return engines;
177    }
178
179    private boolean isSystemEngine(ServiceInfo info) {
180        final ApplicationInfo appInfo = info.applicationInfo;
181        return appInfo != null && (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
182    }
183
184    /**
185     * @return true if a given engine is installed on the system.
186     */
187    public boolean isEngineInstalled(String engine) {
188        if (engine == null) {
189            return false;
190        }
191
192        return getEngineInfo(engine) != null;
193    }
194
195    /**
196     * @return an intent that can launch the settings activity for a given tts engine.
197     */
198    public Intent getSettingsIntent(String engine) {
199        PackageManager pm = mContext.getPackageManager();
200        Intent intent = new Intent(Engine.INTENT_ACTION_TTS_SERVICE);
201        intent.setPackage(engine);
202        List<ResolveInfo> resolveInfos = pm.queryIntentServices(intent,
203                PackageManager.MATCH_DEFAULT_ONLY | PackageManager.GET_META_DATA);
204        // Note that the current API allows only one engine per
205        // package name. Since the "engine name" is the same as
206        // the package name.
207        if (resolveInfos != null && resolveInfos.size() == 1) {
208            ServiceInfo service = resolveInfos.get(0).serviceInfo;
209            if (service != null) {
210                final String settings = settingsActivityFromServiceInfo(service, pm);
211                if (settings != null) {
212                    Intent i = new Intent();
213                    i.setClassName(engine, settings);
214                    return i;
215                }
216            }
217        }
218
219        return null;
220    }
221
222    /**
223     * The name of the XML tag that text to speech engines must use to
224     * declare their meta data.
225     *
226     * {@link com.android.internal.R.styleable#TextToSpeechEngine}
227     */
228    private static final String XML_TAG_NAME = "tts-engine";
229
230    private String settingsActivityFromServiceInfo(ServiceInfo si, PackageManager pm) {
231        XmlResourceParser parser = null;
232        try {
233            parser = si.loadXmlMetaData(pm, TextToSpeech.Engine.SERVICE_META_DATA);
234            if (parser == null) {
235                Log.w(TAG, "No meta-data found for :" + si);
236                return null;
237            }
238
239            final Resources res = pm.getResourcesForApplication(si.applicationInfo);
240
241            int type;
242            while ((type = parser.next()) != XmlResourceParser.END_DOCUMENT) {
243                if (type == XmlResourceParser.START_TAG) {
244                    if (!XML_TAG_NAME.equals(parser.getName())) {
245                        Log.w(TAG, "Package " + si + " uses unknown tag :"
246                                + parser.getName());
247                        return null;
248                    }
249
250                    final AttributeSet attrs = Xml.asAttributeSet(parser);
251                    final TypedArray array = res.obtainAttributes(attrs,
252                            com.android.internal.R.styleable.TextToSpeechEngine);
253                    final String settings = array.getString(
254                            com.android.internal.R.styleable.TextToSpeechEngine_settingsActivity);
255                    array.recycle();
256
257                    return settings;
258                }
259            }
260
261            return null;
262        } catch (NameNotFoundException e) {
263            Log.w(TAG, "Could not load resources for : " + si);
264            return null;
265        } catch (XmlPullParserException e) {
266            Log.w(TAG, "Error parsing metadata for " + si + ":" + e);
267            return null;
268        } catch (IOException e) {
269            Log.w(TAG, "Error parsing metadata for " + si + ":" + e);
270            return null;
271        } finally {
272            if (parser != null) {
273                parser.close();
274            }
275        }
276    }
277
278    private EngineInfo getEngineInfo(ResolveInfo resolve, PackageManager pm) {
279        ServiceInfo service = resolve.serviceInfo;
280        if (service != null) {
281            EngineInfo engine = new EngineInfo();
282            // Using just the package name isn't great, since it disallows having
283            // multiple engines in the same package, but that's what the existing API does.
284            engine.name = service.packageName;
285            CharSequence label = service.loadLabel(pm);
286            engine.label = TextUtils.isEmpty(label) ? engine.name : label.toString();
287            engine.icon = service.getIconResource();
288            engine.priority = resolve.priority;
289            engine.system = isSystemEngine(service);
290            return engine;
291        }
292
293        return null;
294    }
295
296    private static class EngineInfoComparator implements Comparator<EngineInfo> {
297        private EngineInfoComparator() { }
298
299        static EngineInfoComparator INSTANCE = new EngineInfoComparator();
300
301        /**
302         * Engines that are a part of the system image are always lesser
303         * than those that are not. Within system engines / non system engines
304         * the engines are sorted in order of their declared priority.
305         */
306        @Override
307        public int compare(EngineInfo lhs, EngineInfo rhs) {
308            if (lhs.system && !rhs.system) {
309                return -1;
310            } else if (rhs.system && !lhs.system) {
311                return 1;
312            } else {
313                // Either both system engines, or both non system
314                // engines.
315                //
316                // Note, this isn't a typo. Higher priority numbers imply
317                // higher priority, but are "lower" in the sort order.
318                return rhs.priority - lhs.priority;
319            }
320        }
321    }
322
323    /**
324     * Returns the default locale for a given TTS engine. Attempts to read the
325     * value from {@link Settings.Secure#TTS_DEFAULT_LOCALE}, failing which the
326     * default phone locale is returned.
327     *
328     * @param engineName the engine to return the locale for.
329     * @return the locale preference for this engine. Will be non null.
330     */
331    public Locale getLocalePrefForEngine(String engineName) {
332        return getLocalePrefForEngine(engineName,
333                getString(mContext.getContentResolver(), Settings.Secure.TTS_DEFAULT_LOCALE));
334    }
335
336    /**
337     * Returns the default locale for a given TTS engine from given settings string. */
338    public Locale getLocalePrefForEngine(String engineName, String prefValue) {
339        String localeString = parseEnginePrefFromList(
340                prefValue,
341                engineName);
342
343        if (TextUtils.isEmpty(localeString)) {
344            // The new style setting is unset, attempt to return the old style setting.
345            return Locale.getDefault();
346        }
347
348        Locale result = parseLocaleString(localeString);
349        if (result == null) {
350            Log.w(TAG, "Failed to parse locale " + localeString + ", returning en_US instead");
351            result = Locale.US;
352        }
353
354        if (DBG) Log.d(TAG, "getLocalePrefForEngine(" + engineName + ")= " + result);
355
356        return result;
357    }
358
359
360    /**
361     * True if a given TTS engine uses the default phone locale as a default locale. Attempts to
362     * read the value from {@link Settings.Secure#TTS_DEFAULT_LOCALE}. If
363     * its  value is empty, this methods returns true.
364     *
365     * @param engineName the engine to return the locale for.
366     */
367    public boolean isLocaleSetToDefaultForEngine(String engineName) {
368        return TextUtils.isEmpty(parseEnginePrefFromList(
369                    getString(mContext.getContentResolver(), Settings.Secure.TTS_DEFAULT_LOCALE),
370                    engineName));
371    }
372
373    /**
374     * Parses a locale encoded as a string, and tries its best to return a valid {@link Locale}
375     * object, even if the input string is encoded using the old-style 3 character format e.g.
376     * "deu-deu". At the end, we test if the resulting locale can return ISO3 language and
377     * country codes ({@link Locale#getISO3Language()} and {@link Locale#getISO3Country()}),
378     * if it fails to do so, we return null.
379     */
380    public Locale parseLocaleString(String localeString) {
381        String language = "", country = "", variant = "";
382        if (!TextUtils.isEmpty(localeString)) {
383            String[] split = localeString.split(
384                    "[" + LOCALE_DELIMITER_OLD + LOCALE_DELIMITER_NEW + "]");
385            language = split[0].toLowerCase();
386            if (split.length == 0) {
387                Log.w(TAG, "Failed to convert " + localeString + " to a valid Locale object. Only" +
388                            " separators");
389                return null;
390            }
391            if (split.length > 3) {
392                Log.w(TAG, "Failed to convert " + localeString + " to a valid Locale object. Too" +
393                        " many separators");
394                return null;
395            }
396            if (split.length >= 2) {
397                country = split[1].toUpperCase();
398            }
399            if (split.length >= 3) {
400                variant = split[2];
401            }
402
403        }
404
405        String normalizedLanguage = sNormalizeLanguage.get(language);
406        if (normalizedLanguage != null) {
407            language = normalizedLanguage;
408        }
409
410        String normalizedCountry= sNormalizeCountry.get(country);
411        if (normalizedCountry != null) {
412            country = normalizedCountry;
413        }
414
415        if (DBG) Log.d(TAG, "parseLocalePref(" + language + "," + country +
416                "," + variant +")");
417
418        Locale result = new Locale(language, country, variant);
419        try {
420            result.getISO3Language();
421            result.getISO3Country();
422            return result;
423        } catch(MissingResourceException e) {
424            Log.w(TAG, "Failed to convert " + localeString + " to a valid Locale object.");
425            return null;
426        }
427    }
428
429    /**
430     * Return the old-style string form of the locale. It consists of 3 letter codes:
431     * <ul>
432     *   <li>"ISO 639-2/T language code" if the locale has no country entry</li>
433     *   <li> "ISO 639-2/T language code{@link #LOCALE_DELIMITER}ISO 3166 country code"
434     *     if the locale has no variant entry</li>
435     *   <li> "ISO 639-2/T language code{@link #LOCALE_DELIMITER}ISO 3166 country
436     *     code{@link #LOCALE_DELIMITER}variant" if the locale has a variant entry</li>
437     * </ul>
438     * If we fail to generate those codes using {@link Locale#getISO3Country()} and
439     * {@link Locale#getISO3Language()}, then we return new String[]{"eng","USA",""};
440     */
441    static public String[] toOldLocaleStringFormat(Locale locale) {
442        String[] ret = new String[]{"","",""};
443        try {
444            // Note that the default locale might have an empty variant
445            // or language, and we take care that the construction is
446            // the same as {@link #getV1Locale} i.e no trailing delimiters
447            // or spaces.
448            ret[0] = locale.getISO3Language();
449            ret[1] = locale.getISO3Country();
450            ret[2] = locale.getVariant();
451
452            return ret;
453        } catch (MissingResourceException e) {
454            // Default locale does not have a ISO 3166 and/or ISO 639-2/T codes. Return the
455            // default "eng-usa" (that would be the result of Locale.getDefault() == Locale.US).
456            return new String[]{"eng","USA",""};
457        }
458    }
459
460    /**
461     * Parses a comma separated list of engine locale preferences. The list is of the
462     * form {@code "engine_name_1:locale_1,engine_name_2:locale2"} and so on and
463     * so forth. Returns null if the list is empty, malformed or if there is no engine
464     * specific preference in the list.
465     */
466    private static String parseEnginePrefFromList(String prefValue, String engineName) {
467        if (TextUtils.isEmpty(prefValue)) {
468            return null;
469        }
470
471        String[] prefValues = prefValue.split(",");
472
473        for (String value : prefValues) {
474            final int delimiter = value.indexOf(':');
475            if (delimiter > 0) {
476                if (engineName.equals(value.substring(0, delimiter))) {
477                    return value.substring(delimiter + 1);
478                }
479            }
480        }
481
482        return null;
483    }
484
485    /**
486     * Serialize the locale to a string and store it as a default locale for the given engine. If
487     * the passed locale is null, an empty string will be serialized; that empty string, when
488     * read back, will evaluate to {@link Locale#getDefault()}.
489     */
490    public synchronized void updateLocalePrefForEngine(String engineName, Locale newLocale) {
491        final String prefList = Settings.Secure.getString(mContext.getContentResolver(),
492                Settings.Secure.TTS_DEFAULT_LOCALE);
493        if (DBG) {
494            Log.d(TAG, "updateLocalePrefForEngine(" + engineName + ", " + newLocale +
495                    "), originally: " + prefList);
496        }
497
498        final String newPrefList = updateValueInCommaSeparatedList(prefList,
499                engineName, (newLocale != null) ? newLocale.toString() : "");
500
501        if (DBG) Log.d(TAG, "updateLocalePrefForEngine(), writing: " + newPrefList.toString());
502
503        Settings.Secure.putString(mContext.getContentResolver(),
504                Settings.Secure.TTS_DEFAULT_LOCALE, newPrefList.toString());
505    }
506
507    /**
508     * Updates the value for a given key in a comma separated list of key value pairs,
509     * each of which are delimited by a colon. If no value exists for the given key,
510     * the kay value pair are appended to the end of the list.
511     */
512    private String updateValueInCommaSeparatedList(String list, String key,
513            String newValue) {
514        StringBuilder newPrefList = new StringBuilder();
515        if (TextUtils.isEmpty(list)) {
516            // If empty, create a new list with a single entry.
517            newPrefList.append(key).append(':').append(newValue);
518        } else {
519            String[] prefValues = list.split(",");
520            // Whether this is the first iteration in the loop.
521            boolean first = true;
522            // Whether we found the given key.
523            boolean found = false;
524            for (String value : prefValues) {
525                final int delimiter = value.indexOf(':');
526                if (delimiter > 0) {
527                    if (key.equals(value.substring(0, delimiter))) {
528                        if (first) {
529                            first = false;
530                        } else {
531                            newPrefList.append(',');
532                        }
533                        found = true;
534                        newPrefList.append(key).append(':').append(newValue);
535                    } else {
536                        if (first) {
537                            first = false;
538                        } else {
539                            newPrefList.append(',');
540                        }
541                        // Copy across the entire key + value as is.
542                        newPrefList.append(value);
543                    }
544                }
545            }
546
547            if (!found) {
548                // Not found, but the rest of the keys would have been copied
549                // over already, so just append it to the end.
550                newPrefList.append(',');
551                newPrefList.append(key).append(':').append(newValue);
552            }
553        }
554
555        return newPrefList.toString();
556    }
557}
558