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 32 import static android.provider.Settings.Secure.getString; 33 34 import android.provider.Settings; 35 import android.speech.tts.TextToSpeech.Engine; 36 import android.speech.tts.TextToSpeech.EngineInfo; 37 import android.text.TextUtils; 38 import android.util.AttributeSet; 39 import android.util.Log; 40 import android.util.Xml; 41 42 import java.io.IOException; 43 import java.util.ArrayList; 44 import java.util.Collections; 45 import java.util.Comparator; 46 import java.util.HashMap; 47 import java.util.List; 48 import java.util.Locale; 49 import java.util.Map; 50 import 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 */ 63 public 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 * This method tries its best to return a valid {@link Locale} object from the TTS-specific 431 * Locale input (returned by {@link TextToSpeech#getLanguage} 432 * and {@link TextToSpeech#getDefaultLanguage}). A TTS Locale language field contains 433 * a three-letter ISO 639-2/T code (where a proper Locale would use a two-letter ISO 639-1 434 * code), and the country field contains a three-letter ISO 3166 country code (where a proper 435 * Locale would use a two-letter ISO 3166-1 code). 436 * 437 * This method tries to convert three-letter language and country codes into their two-letter 438 * equivalents. If it fails to do so, it keeps the value from the TTS locale. 439 */ 440 public static Locale normalizeTTSLocale(Locale ttsLocale) { 441 String language = ttsLocale.getLanguage(); 442 if (!TextUtils.isEmpty(language)) { 443 String normalizedLanguage = sNormalizeLanguage.get(language); 444 if (normalizedLanguage != null) { 445 language = normalizedLanguage; 446 } 447 } 448 449 String country = ttsLocale.getCountry(); 450 if (!TextUtils.isEmpty(country)) { 451 String normalizedCountry= sNormalizeCountry.get(country); 452 if (normalizedCountry != null) { 453 country = normalizedCountry; 454 } 455 } 456 return new Locale(language, country, ttsLocale.getVariant()); 457 } 458 459 /** 460 * Return the old-style string form of the locale. It consists of 3 letter codes: 461 * <ul> 462 * <li>"ISO 639-2/T language code" if the locale has no country entry</li> 463 * <li> "ISO 639-2/T language code{@link #LOCALE_DELIMITER}ISO 3166 country code" 464 * if the locale has no variant entry</li> 465 * <li> "ISO 639-2/T language code{@link #LOCALE_DELIMITER}ISO 3166 country 466 * code{@link #LOCALE_DELIMITER}variant" if the locale has a variant entry</li> 467 * </ul> 468 * If we fail to generate those codes using {@link Locale#getISO3Country()} and 469 * {@link Locale#getISO3Language()}, then we return new String[]{"eng","USA",""}; 470 */ 471 static public String[] toOldLocaleStringFormat(Locale locale) { 472 String[] ret = new String[]{"","",""}; 473 try { 474 // Note that the default locale might have an empty variant 475 // or language, and we take care that the construction is 476 // the same as {@link #getV1Locale} i.e no trailing delimiters 477 // or spaces. 478 ret[0] = locale.getISO3Language(); 479 ret[1] = locale.getISO3Country(); 480 ret[2] = locale.getVariant(); 481 482 return ret; 483 } catch (MissingResourceException e) { 484 // Default locale does not have a ISO 3166 and/or ISO 639-2/T codes. Return the 485 // default "eng-usa" (that would be the result of Locale.getDefault() == Locale.US). 486 return new String[]{"eng","USA",""}; 487 } 488 } 489 490 /** 491 * Parses a comma separated list of engine locale preferences. The list is of the 492 * form {@code "engine_name_1:locale_1,engine_name_2:locale2"} and so on and 493 * so forth. Returns null if the list is empty, malformed or if there is no engine 494 * specific preference in the list. 495 */ 496 private static String parseEnginePrefFromList(String prefValue, String engineName) { 497 if (TextUtils.isEmpty(prefValue)) { 498 return null; 499 } 500 501 String[] prefValues = prefValue.split(","); 502 503 for (String value : prefValues) { 504 final int delimiter = value.indexOf(':'); 505 if (delimiter > 0) { 506 if (engineName.equals(value.substring(0, delimiter))) { 507 return value.substring(delimiter + 1); 508 } 509 } 510 } 511 512 return null; 513 } 514 515 /** 516 * Serialize the locale to a string and store it as a default locale for the given engine. If 517 * the passed locale is null, an empty string will be serialized; that empty string, when 518 * read back, will evaluate to {@link Locale#getDefault()}. 519 */ 520 public synchronized void updateLocalePrefForEngine(String engineName, Locale newLocale) { 521 final String prefList = Settings.Secure.getString(mContext.getContentResolver(), 522 Settings.Secure.TTS_DEFAULT_LOCALE); 523 if (DBG) { 524 Log.d(TAG, "updateLocalePrefForEngine(" + engineName + ", " + newLocale + 525 "), originally: " + prefList); 526 } 527 528 final String newPrefList = updateValueInCommaSeparatedList(prefList, 529 engineName, (newLocale != null) ? newLocale.toString() : ""); 530 531 if (DBG) Log.d(TAG, "updateLocalePrefForEngine(), writing: " + newPrefList.toString()); 532 533 Settings.Secure.putString(mContext.getContentResolver(), 534 Settings.Secure.TTS_DEFAULT_LOCALE, newPrefList.toString()); 535 } 536 537 /** 538 * Updates the value for a given key in a comma separated list of key value pairs, 539 * each of which are delimited by a colon. If no value exists for the given key, 540 * the kay value pair are appended to the end of the list. 541 */ 542 private String updateValueInCommaSeparatedList(String list, String key, 543 String newValue) { 544 StringBuilder newPrefList = new StringBuilder(); 545 if (TextUtils.isEmpty(list)) { 546 // If empty, create a new list with a single entry. 547 newPrefList.append(key).append(':').append(newValue); 548 } else { 549 String[] prefValues = list.split(","); 550 // Whether this is the first iteration in the loop. 551 boolean first = true; 552 // Whether we found the given key. 553 boolean found = false; 554 for (String value : prefValues) { 555 final int delimiter = value.indexOf(':'); 556 if (delimiter > 0) { 557 if (key.equals(value.substring(0, delimiter))) { 558 if (first) { 559 first = false; 560 } else { 561 newPrefList.append(','); 562 } 563 found = true; 564 newPrefList.append(key).append(':').append(newValue); 565 } else { 566 if (first) { 567 first = false; 568 } else { 569 newPrefList.append(','); 570 } 571 // Copy across the entire key + value as is. 572 newPrefList.append(value); 573 } 574 } 575 } 576 577 if (!found) { 578 // Not found, but the rest of the keys would have been copied 579 // over already, so just append it to the end. 580 newPrefList.append(','); 581 newPrefList.append(key).append(':').append(newValue); 582 } 583 } 584 585 return newPrefList.toString(); 586 } 587 } 588