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