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