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