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;
31import static android.provider.Settings.Secure.getString;
32
33import android.provider.Settings;
34import android.speech.tts.TextToSpeech.Engine;
35import android.speech.tts.TextToSpeech.EngineInfo;
36import android.text.TextUtils;
37import android.util.AttributeSet;
38import android.util.Log;
39import android.util.Xml;
40
41import java.io.IOException;
42import java.util.ArrayList;
43import java.util.Collections;
44import java.util.Comparator;
45import java.util.List;
46import java.util.Locale;
47import 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 */
58public 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