1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of 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, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17 package com.android.tv.settings.system; 18 19 import android.app.AlertDialog; 20 import android.content.ActivityNotFoundException; 21 import android.content.ContentResolver; 22 import android.content.Intent; 23 import android.os.Bundle; 24 import android.provider.Settings; 25 import android.speech.tts.TextToSpeech; 26 import android.speech.tts.TtsEngines; 27 import android.speech.tts.UtteranceProgressListener; 28 import android.support.v17.preference.LeanbackPreferenceFragment; 29 import android.support.v7.preference.ListPreference; 30 import android.support.v7.preference.Preference; 31 import android.support.v7.preference.PreferenceCategory; 32 import android.text.TextUtils; 33 import android.util.Log; 34 import android.widget.Checkable; 35 36 import com.android.tv.settings.R; 37 38 import java.util.ArrayList; 39 import java.util.HashMap; 40 import java.util.List; 41 import java.util.Locale; 42 import java.util.MissingResourceException; 43 import java.util.Objects; 44 import java.util.Set; 45 46 public class TextToSpeechFragment extends LeanbackPreferenceFragment implements 47 Preference.OnPreferenceChangeListener, Preference.OnPreferenceClickListener, 48 TtsEnginePreference.RadioButtonGroupState { 49 private static final String TAG = "TextToSpeechSettings"; 50 private static final boolean DBG = false; 51 52 /** Preference key for the engine settings preference */ 53 private static final String KEY_ENGINE_SETTINGS = "tts_engine_settings"; 54 55 /** Preference key for the "play TTS example" preference. */ 56 private static final String KEY_PLAY_EXAMPLE = "tts_play_example"; 57 58 /** Preference key for the TTS rate selection dialog. */ 59 private static final String KEY_DEFAULT_RATE = "tts_default_rate"; 60 61 /** Preference key for the TTS status field. */ 62 private static final String KEY_STATUS = "tts_status"; 63 64 /** 65 * Preference key for the engine selection preference. 66 */ 67 private static final String KEY_ENGINE_PREFERENCE_SECTION = 68 "tts_engine_preference_section"; 69 70 /** 71 * These look like birth years, but they aren't mine. I'm much younger than this. 72 */ 73 private static final int GET_SAMPLE_TEXT = 1983; 74 private static final int VOICE_DATA_INTEGRITY_CHECK = 1977; 75 76 private PreferenceCategory mEnginePreferenceCategory; 77 private Preference mEngineSettingsPref; 78 private ListPreference mDefaultRatePref; 79 private Preference mPlayExample; 80 private Preference mEngineStatus; 81 82 private int mDefaultRate = TextToSpeech.Engine.DEFAULT_RATE; 83 84 /** 85 * The currently selected engine. 86 */ 87 private String mCurrentEngine; 88 89 /** 90 * The engine checkbox that is currently checked. Saves us a bit of effort 91 * in deducing the right one from the currently selected engine. 92 */ 93 private Checkable mCurrentChecked; 94 95 /** 96 * The previously selected TTS engine. Useful for rollbacks if the users 97 * choice is not loaded or fails a voice integrity check. 98 */ 99 private String mPreviousEngine; 100 101 private TextToSpeech mTts = null; 102 private TtsEngines mEnginesHelper = null; 103 104 private String mSampleText = null; 105 106 /** 107 * Default locale used by selected TTS engine, null if not connected to any engine. 108 */ 109 private Locale mCurrentDefaultLocale; 110 111 /** 112 * List of available locals of selected TTS engine, as returned by 113 * {@link TextToSpeech.Engine#ACTION_CHECK_TTS_DATA} activity. If empty, then activity 114 * was not yet called. 115 */ 116 private List<String> mAvailableStrLocals; 117 118 /** 119 * The initialization listener used when we are initalizing the settings 120 * screen for the first time (as opposed to when a user changes his choice 121 * of engine). 122 */ 123 private final TextToSpeech.OnInitListener mInitListener = new TextToSpeech.OnInitListener() { 124 @Override 125 public void onInit(int status) { 126 onInitEngine(status); 127 } 128 }; 129 130 /** 131 * The initialization listener used when the user changes his choice of 132 * engine (as opposed to when then screen is being initialized for the first 133 * time). 134 */ 135 private final TextToSpeech.OnInitListener mUpdateListener = new TextToSpeech.OnInitListener() { 136 @Override 137 public void onInit(int status) { 138 onUpdateEngine(status); 139 } 140 }; 141 142 @Override 143 public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { 144 addPreferencesFromResource(R.xml.tts_settings); 145 146 mEngineSettingsPref = findPreference(KEY_ENGINE_SETTINGS); 147 148 mPlayExample = findPreference(KEY_PLAY_EXAMPLE); 149 mPlayExample.setOnPreferenceClickListener(this); 150 mPlayExample.setEnabled(false); 151 152 mEnginePreferenceCategory = (PreferenceCategory) findPreference( 153 KEY_ENGINE_PREFERENCE_SECTION); 154 mDefaultRatePref = (ListPreference) findPreference(KEY_DEFAULT_RATE); 155 156 mEngineStatus = findPreference(KEY_STATUS); 157 updateEngineStatus(R.string.tts_status_checking); 158 } 159 160 @Override 161 public void onCreate(Bundle savedInstanceState) { 162 super.onCreate(savedInstanceState); 163 164 getActivity().setVolumeControlStream(TextToSpeech.Engine.DEFAULT_STREAM); 165 166 mTts = new TextToSpeech(getActivity().getApplicationContext(), mInitListener); 167 mEnginesHelper = new TtsEngines(getActivity().getApplicationContext()); 168 169 setTtsUtteranceProgressListener(); 170 initSettings(); 171 } 172 173 @Override 174 public void onResume() { 175 super.onResume(); 176 177 if (mTts == null || mCurrentDefaultLocale == null) { 178 return; 179 } 180 Locale ttsDefaultLocale = mTts.getDefaultLanguage(); 181 if (!mCurrentDefaultLocale.equals(ttsDefaultLocale)) { 182 updateWidgetState(false); 183 checkDefaultLocale(); 184 } 185 } 186 187 private void setTtsUtteranceProgressListener() { 188 if (mTts == null) { 189 return; 190 } 191 mTts.setOnUtteranceProgressListener(new UtteranceProgressListener() { 192 @Override 193 public void onStart(String utteranceId) {} 194 195 @Override 196 public void onDone(String utteranceId) {} 197 198 @Override 199 public void onError(String utteranceId) { 200 Log.e(TAG, "Error while trying to synthesize sample text"); 201 } 202 }); 203 } 204 205 @Override 206 public void onDestroy() { 207 super.onDestroy(); 208 if (mTts != null) { 209 mTts.shutdown(); 210 mTts = null; 211 } 212 } 213 214 private void initSettings() { 215 final ContentResolver resolver = getActivity().getContentResolver(); 216 217 // Set up the default rate. 218 try { 219 mDefaultRate = android.provider.Settings.Secure.getInt(resolver, 220 Settings.Secure.TTS_DEFAULT_RATE); 221 } catch (Settings.SettingNotFoundException e) { 222 // Default rate setting not found, initialize it 223 mDefaultRate = TextToSpeech.Engine.DEFAULT_RATE; 224 } 225 mDefaultRatePref.setValue(String.valueOf(mDefaultRate)); 226 mDefaultRatePref.setOnPreferenceChangeListener(this); 227 228 mCurrentEngine = mTts.getCurrentEngine(); 229 230 mEnginePreferenceCategory.removeAll(); 231 232 List<TextToSpeech.EngineInfo> engines = mEnginesHelper.getEngines(); 233 for (TextToSpeech.EngineInfo engine : engines) { 234 TtsEnginePreference enginePref = 235 new TtsEnginePreference(getPreferenceManager().getContext(), engine, 236 this); 237 mEnginePreferenceCategory.addPreference(enginePref); 238 } 239 240 checkVoiceData(mCurrentEngine); 241 } 242 243 /** 244 * Called when the TTS engine is initialized. 245 */ 246 public void onInitEngine(int status) { 247 if (status == TextToSpeech.SUCCESS) { 248 if (DBG) Log.d(TAG, "TTS engine for settings screen initialized."); 249 checkDefaultLocale(); 250 } else { 251 if (DBG) Log.d(TAG, "TTS engine for settings screen failed to initialize successfully."); 252 updateWidgetState(false); 253 } 254 } 255 256 private void checkDefaultLocale() { 257 Locale defaultLocale = mTts.getDefaultLanguage(); 258 if (defaultLocale == null) { 259 Log.e(TAG, "Failed to get default language from engine " + mCurrentEngine); 260 updateWidgetState(false); 261 updateEngineStatus(R.string.tts_status_not_supported); 262 return; 263 } 264 265 // ISO-3166 alpha 3 country codes are out of spec. If we won't normalize, 266 // we may end up with English (USA)and German (DEU). 267 final Locale oldDefaultLocale = mCurrentDefaultLocale; 268 mCurrentDefaultLocale = mEnginesHelper.parseLocaleString(defaultLocale.toString()); 269 if (!Objects.equals(oldDefaultLocale, mCurrentDefaultLocale)) { 270 mSampleText = null; 271 } 272 273 mTts.setLanguage(defaultLocale); 274 if (evaluateDefaultLocale() && mSampleText == null) { 275 getSampleText(); 276 } 277 } 278 279 private boolean evaluateDefaultLocale() { 280 // Check if we are connected to the engine, and CHECK_VOICE_DATA returned list 281 // of available languages. 282 if (mCurrentDefaultLocale == null || mAvailableStrLocals == null) { 283 return false; 284 } 285 286 boolean notInAvailableLangauges = true; 287 try { 288 // Check if language is listed in CheckVoices Action result as available voice. 289 String defaultLocaleStr = mCurrentDefaultLocale.getISO3Language(); 290 if (!TextUtils.isEmpty(mCurrentDefaultLocale.getISO3Country())) { 291 defaultLocaleStr += "-" + mCurrentDefaultLocale.getISO3Country(); 292 } 293 if (!TextUtils.isEmpty(mCurrentDefaultLocale.getVariant())) { 294 defaultLocaleStr += "-" + mCurrentDefaultLocale.getVariant(); 295 } 296 297 for (String loc : mAvailableStrLocals) { 298 if (loc.equalsIgnoreCase(defaultLocaleStr)) { 299 notInAvailableLangauges = false; 300 break; 301 } 302 } 303 } catch (MissingResourceException e) { 304 if (DBG) Log.wtf(TAG, "MissingResourceException", e); 305 updateEngineStatus(R.string.tts_status_not_supported); 306 updateWidgetState(false); 307 return false; 308 } 309 310 int defaultAvailable = mTts.setLanguage(mCurrentDefaultLocale); 311 if (defaultAvailable == TextToSpeech.LANG_NOT_SUPPORTED || 312 defaultAvailable == TextToSpeech.LANG_MISSING_DATA || 313 notInAvailableLangauges) { 314 if (DBG) Log.d(TAG, "Default locale for this TTS engine is not supported."); 315 updateEngineStatus(R.string.tts_status_not_supported); 316 updateWidgetState(false); 317 return false; 318 } else { 319 if (isNetworkRequiredForSynthesis()) { 320 updateEngineStatus(R.string.tts_status_requires_network); 321 } else { 322 updateEngineStatus(R.string.tts_status_ok); 323 } 324 updateWidgetState(true); 325 return true; 326 } 327 } 328 329 /** 330 * Ask the current default engine to return a string of sample text to be 331 * spoken to the user. 332 */ 333 private void getSampleText() { 334 String currentEngine = mTts.getCurrentEngine(); 335 336 if (TextUtils.isEmpty(currentEngine)) currentEngine = mTts.getDefaultEngine(); 337 338 // TODO: This is currently a hidden private API. The intent extras 339 // and the intent action should be made public if we intend to make this 340 // a public API. We fall back to using a canned set of strings if this 341 // doesn't work. 342 Intent intent = new Intent(TextToSpeech.Engine.ACTION_GET_SAMPLE_TEXT); 343 344 intent.putExtra("language", mCurrentDefaultLocale.getLanguage()); 345 intent.putExtra("country", mCurrentDefaultLocale.getCountry()); 346 intent.putExtra("variant", mCurrentDefaultLocale.getVariant()); 347 intent.setPackage(currentEngine); 348 349 try { 350 if (DBG) Log.d(TAG, "Getting sample text: " + intent.toUri(0)); 351 startActivityForResult(intent, GET_SAMPLE_TEXT); 352 } catch (ActivityNotFoundException ex) { 353 Log.e(TAG, "Failed to get sample text, no activity found for " + intent + ")"); 354 } 355 } 356 357 /** 358 * Called when voice data integrity check returns 359 */ 360 @Override 361 public void onActivityResult(int requestCode, int resultCode, Intent data) { 362 if (requestCode == GET_SAMPLE_TEXT) { 363 onSampleTextReceived(resultCode, data); 364 } else if (requestCode == VOICE_DATA_INTEGRITY_CHECK) { 365 onVoiceDataIntegrityCheckDone(data); 366 } 367 } 368 369 private String getDefaultSampleString() { 370 if (mTts != null && mTts.getLanguage() != null) { 371 try { 372 final String currentLang = mTts.getLanguage().getISO3Language(); 373 String[] strings = getActivity().getResources().getStringArray( 374 R.array.tts_demo_strings); 375 String[] langs = getActivity().getResources().getStringArray( 376 R.array.tts_demo_string_langs); 377 378 for (int i = 0; i < strings.length; ++i) { 379 if (langs[i].equals(currentLang)) { 380 return strings[i]; 381 } 382 } 383 } catch (MissingResourceException e) { 384 if (DBG) Log.wtf(TAG, "MissingResourceException", e); 385 // Ignore and fall back to default sample string 386 } 387 } 388 return getString(R.string.tts_default_sample_string); 389 } 390 391 private boolean isNetworkRequiredForSynthesis() { 392 Set<String> features = mTts.getFeatures(mCurrentDefaultLocale); 393 return features != null && 394 features.contains(TextToSpeech.Engine.KEY_FEATURE_NETWORK_SYNTHESIS) && 395 !features.contains(TextToSpeech.Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS); 396 } 397 398 private void onSampleTextReceived(int resultCode, Intent data) { 399 String sample = getDefaultSampleString(); 400 401 if (resultCode == TextToSpeech.LANG_AVAILABLE && data != null) { 402 if (data.getStringExtra("sampleText") != null) { 403 sample = data.getStringExtra("sampleText"); 404 } 405 if (DBG) Log.d(TAG, "Got sample text: " + sample); 406 } else { 407 if (DBG) Log.d(TAG, "Using default sample text :" + sample); 408 } 409 410 mSampleText = sample; 411 if (mSampleText != null) { 412 updateWidgetState(true); 413 } else { 414 Log.e(TAG, "Did not have a sample string for the requested language. Using default"); 415 } 416 } 417 418 private void speakSampleText() { 419 final boolean networkRequired = isNetworkRequiredForSynthesis(); 420 if (!networkRequired || 421 mTts.isLanguageAvailable(mCurrentDefaultLocale) >= TextToSpeech.LANG_AVAILABLE) { 422 HashMap<String, String> params = new HashMap<>(); 423 params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "Sample"); 424 425 mTts.speak(mSampleText, TextToSpeech.QUEUE_FLUSH, params); 426 } else { 427 Log.w(TAG, "Network required for sample synthesis for requested language"); 428 displayNetworkAlert(); 429 } 430 } 431 432 @Override 433 public boolean onPreferenceChange(Preference preference, Object objValue) { 434 if (KEY_DEFAULT_RATE.equals(preference.getKey())) { 435 // Default rate 436 mDefaultRate = Integer.parseInt((String) objValue); 437 try { 438 android.provider.Settings.Secure.putInt(getActivity().getContentResolver(), 439 Settings.Secure.TTS_DEFAULT_RATE, mDefaultRate); 440 if (mTts != null) { 441 mTts.setSpeechRate(mDefaultRate / 100.0f); 442 } 443 if (DBG) Log.d(TAG, "TTS default rate changed, now " + mDefaultRate); 444 } catch (NumberFormatException e) { 445 Log.e(TAG, "could not persist default TTS rate setting", e); 446 } 447 } 448 449 return true; 450 } 451 452 /** 453 * Called when mPlayExample is clicked 454 */ 455 @Override 456 public boolean onPreferenceClick(Preference preference) { 457 if (preference == mPlayExample) { 458 // Get the sample text from the TTS engine; onActivityResult will do 459 // the actual speaking 460 speakSampleText(); 461 return true; 462 } 463 464 return false; 465 } 466 467 private void updateWidgetState(boolean enable) { 468 mEngineSettingsPref.setEnabled(enable); 469 mPlayExample.setEnabled(enable); 470 mDefaultRatePref.setEnabled(enable); 471 mEngineStatus.setEnabled(enable); 472 } 473 474 private void updateEngineStatus(int resourceId) { 475 Locale locale = mCurrentDefaultLocale; 476 if (locale == null) { 477 locale = Locale.getDefault(); 478 } 479 mEngineStatus.setSummary(getString(resourceId, locale.getDisplayName())); 480 } 481 482 private void displayNetworkAlert() { 483 AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); 484 builder.setTitle(android.R.string.dialog_alert_title) 485 .setMessage(getActivity().getString(R.string.tts_engine_network_required)) 486 .setCancelable(false) 487 .setPositiveButton(android.R.string.ok, null); 488 489 AlertDialog dialog = builder.create(); 490 dialog.show(); 491 } 492 493 private void updateDefaultEngine(String engine) { 494 if (DBG) Log.d(TAG, "Updating default synth to : " + engine); 495 496 // Disable the "play sample text" preference and the speech 497 // rate preference while the engine is being swapped. 498 updateWidgetState(false); 499 updateEngineStatus(R.string.tts_status_checking); 500 501 // Keep track of the previous engine that was being used. So that 502 // we can reuse the previous engine. 503 // 504 // Note that if TextToSpeech#getCurrentEngine is not null, it means at 505 // the very least that we successfully bound to the engine service. 506 mPreviousEngine = mTts.getCurrentEngine(); 507 508 // Step 1: Shut down the existing TTS engine. 509 try { 510 mTts.shutdown(); 511 mTts = null; 512 } catch (Exception e) { 513 Log.e(TAG, "Error shutting down TTS engine" + e); 514 } 515 516 // Step 2: Connect to the new TTS engine. 517 // Step 3 is continued on #onUpdateEngine (below) which is called when 518 // the app binds successfully to the engine. 519 if (DBG) Log.d(TAG, "Updating engine : Attempting to connect to engine: " + engine); 520 mTts = new TextToSpeech(getActivity().getApplicationContext(), mUpdateListener, engine); 521 setTtsUtteranceProgressListener(); 522 } 523 524 /* 525 * Step 3: We have now bound to the TTS engine the user requested. We will 526 * attempt to check voice data for the engine if we successfully bound to it, 527 * or revert to the previous engine if we didn't. 528 */ 529 public void onUpdateEngine(int status) { 530 if (status == TextToSpeech.SUCCESS) { 531 if (DBG) { 532 Log.d(TAG, "Updating engine: Successfully bound to the engine: " + 533 mTts.getCurrentEngine()); 534 } 535 checkVoiceData(mTts.getCurrentEngine()); 536 } else { 537 if (DBG) Log.d(TAG, "Updating engine: Failed to bind to engine, reverting."); 538 if (mPreviousEngine != null) { 539 // This is guaranteed to at least bind, since mPreviousEngine would be 540 // null if the previous bind to this engine failed. 541 mTts = new TextToSpeech(getActivity().getApplicationContext(), mInitListener, 542 mPreviousEngine); 543 setTtsUtteranceProgressListener(); 544 } 545 mPreviousEngine = null; 546 } 547 } 548 549 /* 550 * Step 4: Check whether the voice data for the engine is ok. 551 */ 552 private void checkVoiceData(String engine) { 553 Intent intent = new Intent(TextToSpeech.Engine.ACTION_CHECK_TTS_DATA); 554 intent.setPackage(engine); 555 try { 556 if (DBG) Log.d(TAG, "Updating engine: Checking voice data: " + intent.toUri(0)); 557 startActivityForResult(intent, VOICE_DATA_INTEGRITY_CHECK); 558 } catch (ActivityNotFoundException ex) { 559 Log.e(TAG, "Failed to check TTS data, no activity found for " + intent + ")"); 560 } 561 } 562 563 /* 564 * Step 5: The voice data check is complete. 565 */ 566 private void onVoiceDataIntegrityCheckDone(Intent data) { 567 final String engine = mTts.getCurrentEngine(); 568 569 if (engine == null) { 570 Log.e(TAG, "Voice data check complete, but no engine bound"); 571 return; 572 } 573 574 if (data == null){ 575 Log.e(TAG, "Engine failed voice data integrity check (null return)" + 576 mTts.getCurrentEngine()); 577 return; 578 } 579 580 android.provider.Settings.Secure.putString(getActivity().getContentResolver(), 581 Settings.Secure.TTS_DEFAULT_SYNTH, engine); 582 583 mAvailableStrLocals = data.getStringArrayListExtra( 584 TextToSpeech.Engine.EXTRA_AVAILABLE_VOICES); 585 if (mAvailableStrLocals == null) { 586 Log.e(TAG, "Voice data check complete, but no available voices found"); 587 // Set mAvailableStrLocals to empty list 588 mAvailableStrLocals = new ArrayList<String>(); 589 } 590 if (evaluateDefaultLocale()) { 591 getSampleText(); 592 } 593 594 final TextToSpeech.EngineInfo engineInfo = mEnginesHelper.getEngineInfo(engine); 595 TtsEngineSettingsFragment.prepareArgs(mEngineSettingsPref.getExtras(), 596 engineInfo.name, engineInfo.label, data); 597 } 598 599 @Override 600 public Checkable getCurrentChecked() { 601 return mCurrentChecked; 602 } 603 604 @Override 605 public String getCurrentKey() { 606 return mCurrentEngine; 607 } 608 609 @Override 610 public void setCurrentChecked(Checkable current) { 611 mCurrentChecked = current; 612 } 613 614 @Override 615 public void setCurrentKey(String key) { 616 mCurrentEngine = key; 617 updateDefaultEngine(mCurrentEngine); 618 } 619 620 621 } 622