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