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