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 (status == TextToSpeech.SUCCESS) {
257             if (DBG) Log.d(TAG, "TTS engine for settings screen initialized.");
258             checkDefaultLocale();
259         } else {
260             if (DBG) Log.d(TAG, "TTS engine for settings screen failed to initialize successfully.");
261             updateWidgetState(false);
262         }
263     }
264 
checkDefaultLocale()265     private void checkDefaultLocale() {
266         Locale defaultLocale = mTts.getDefaultLanguage();
267         if (defaultLocale == null) {
268             Log.e(TAG, "Failed to get default language from engine " + mCurrentEngine);
269             updateWidgetState(false);
270             updateEngineStatus(R.string.tts_status_not_supported);
271             return;
272         }
273 
274         // ISO-3166 alpha 3 country codes are out of spec. If we won't normalize,
275         // we may end up with English (USA)and German (DEU).
276         final Locale oldDefaultLocale = mCurrentDefaultLocale;
277         mCurrentDefaultLocale = mEnginesHelper.parseLocaleString(defaultLocale.toString());
278         if (!Objects.equals(oldDefaultLocale, mCurrentDefaultLocale)) {
279             mSampleText = null;
280         }
281 
282         mTts.setLanguage(defaultLocale);
283         if (evaluateDefaultLocale() && mSampleText == null) {
284             getSampleText();
285         }
286     }
287 
evaluateDefaultLocale()288     private boolean evaluateDefaultLocale() {
289         // Check if we are connected to the engine, and CHECK_VOICE_DATA returned list
290         // of available languages.
291         if (mCurrentDefaultLocale == null || mAvailableStrLocals == null) {
292             return false;
293         }
294 
295         boolean notInAvailableLangauges = true;
296         try {
297             // Check if language is listed in CheckVoices Action result as available voice.
298             String defaultLocaleStr = mCurrentDefaultLocale.getISO3Language();
299             if (!TextUtils.isEmpty(mCurrentDefaultLocale.getISO3Country())) {
300                 defaultLocaleStr += "-" + mCurrentDefaultLocale.getISO3Country();
301             }
302             if (!TextUtils.isEmpty(mCurrentDefaultLocale.getVariant())) {
303                 defaultLocaleStr += "-" + mCurrentDefaultLocale.getVariant();
304             }
305 
306             for (String loc : mAvailableStrLocals) {
307                 if (loc.equalsIgnoreCase(defaultLocaleStr)) {
308                     notInAvailableLangauges = false;
309                     break;
310                 }
311             }
312         } catch (MissingResourceException e) {
313             if (DBG) Log.wtf(TAG, "MissingResourceException", e);
314             updateEngineStatus(R.string.tts_status_not_supported);
315             updateWidgetState(false);
316             return false;
317         }
318 
319         int defaultAvailable = mTts.setLanguage(mCurrentDefaultLocale);
320         if (defaultAvailable == TextToSpeech.LANG_NOT_SUPPORTED ||
321                 defaultAvailable == TextToSpeech.LANG_MISSING_DATA ||
322                 notInAvailableLangauges) {
323             if (DBG) Log.d(TAG, "Default locale for this TTS engine is not supported.");
324             updateEngineStatus(R.string.tts_status_not_supported);
325             updateWidgetState(false);
326             return false;
327         } else {
328             if (isNetworkRequiredForSynthesis()) {
329                 updateEngineStatus(R.string.tts_status_requires_network);
330             } else {
331                 updateEngineStatus(R.string.tts_status_ok);
332             }
333             updateWidgetState(true);
334             return true;
335         }
336     }
337 
338     /**
339      * Ask the current default engine to return a string of sample text to be
340      * spoken to the user.
341      */
getSampleText()342     private void getSampleText() {
343         String currentEngine = mTts.getCurrentEngine();
344 
345         if (TextUtils.isEmpty(currentEngine)) currentEngine = mTts.getDefaultEngine();
346 
347         // TODO: This is currently a hidden private API. The intent extras
348         // and the intent action should be made public if we intend to make this
349         // a public API. We fall back to using a canned set of strings if this
350         // doesn't work.
351         Intent intent = new Intent(TextToSpeech.Engine.ACTION_GET_SAMPLE_TEXT);
352 
353         intent.putExtra("language", mCurrentDefaultLocale.getLanguage());
354         intent.putExtra("country", mCurrentDefaultLocale.getCountry());
355         intent.putExtra("variant", mCurrentDefaultLocale.getVariant());
356         intent.setPackage(currentEngine);
357 
358         try {
359             if (DBG) Log.d(TAG, "Getting sample text: " + intent.toUri(0));
360             startActivityForResult(intent, GET_SAMPLE_TEXT);
361         } catch (ActivityNotFoundException ex) {
362             Log.e(TAG, "Failed to get sample text, no activity found for " + intent + ")");
363         }
364     }
365 
366     /**
367      * Called when voice data integrity check returns
368      */
369     @Override
onActivityResult(int requestCode, int resultCode, Intent data)370     public void onActivityResult(int requestCode, int resultCode, Intent data) {
371         if (requestCode == GET_SAMPLE_TEXT) {
372             onSampleTextReceived(resultCode, data);
373         } else if (requestCode == VOICE_DATA_INTEGRITY_CHECK) {
374             onVoiceDataIntegrityCheckDone(data);
375         }
376     }
377 
getDefaultSampleString()378     private String getDefaultSampleString() {
379         if (mTts != null && mTts.getLanguage() != null) {
380             try {
381                 final String currentLang = mTts.getLanguage().getISO3Language();
382                 String[] strings = getActivity().getResources().getStringArray(
383                         R.array.tts_demo_strings);
384                 String[] langs = getActivity().getResources().getStringArray(
385                         R.array.tts_demo_string_langs);
386 
387                 for (int i = 0; i < strings.length; ++i) {
388                     if (langs[i].equals(currentLang)) {
389                         return strings[i];
390                     }
391                 }
392             } catch (MissingResourceException e) {
393                 if (DBG) Log.wtf(TAG, "MissingResourceException", e);
394                 // Ignore and fall back to default sample string
395             }
396         }
397         return getString(R.string.tts_default_sample_string);
398     }
399 
isNetworkRequiredForSynthesis()400     private boolean isNetworkRequiredForSynthesis() {
401         Set<String> features = mTts.getFeatures(mCurrentDefaultLocale);
402         return features != null &&
403                 features.contains(TextToSpeech.Engine.KEY_FEATURE_NETWORK_SYNTHESIS) &&
404                 !features.contains(TextToSpeech.Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS);
405     }
406 
onSampleTextReceived(int resultCode, Intent data)407     private void onSampleTextReceived(int resultCode, Intent data) {
408         String sample = getDefaultSampleString();
409 
410         if (resultCode == TextToSpeech.LANG_AVAILABLE && data != null) {
411             if (data.getStringExtra("sampleText") != null) {
412                 sample = data.getStringExtra("sampleText");
413             }
414             if (DBG) Log.d(TAG, "Got sample text: " + sample);
415         } else {
416             if (DBG) Log.d(TAG, "Using default sample text :" + sample);
417         }
418 
419         mSampleText = sample;
420         if (mSampleText != null) {
421             updateWidgetState(true);
422         } else {
423             Log.e(TAG, "Did not have a sample string for the requested language. Using default");
424         }
425     }
426 
speakSampleText()427     private void speakSampleText() {
428         final boolean networkRequired = isNetworkRequiredForSynthesis();
429         if (!networkRequired ||
430                 mTts.isLanguageAvailable(mCurrentDefaultLocale) >= TextToSpeech.LANG_AVAILABLE) {
431             HashMap<String, String> params = new HashMap<>();
432             params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "Sample");
433 
434             mTts.speak(mSampleText, TextToSpeech.QUEUE_FLUSH, params);
435         } else {
436             Log.w(TAG, "Network required for sample synthesis for requested language");
437             displayNetworkAlert();
438         }
439     }
440 
441     @Override
onPreferenceChange(Preference preference, Object objValue)442     public boolean onPreferenceChange(Preference preference, Object objValue) {
443         if (KEY_DEFAULT_RATE.equals(preference.getKey())) {
444             logEntrySelected(TvSettingsEnums.SYSTEM_A11Y_TTS_SPEECH_RATE);
445             // Default rate
446             mDefaultRate = Integer.parseInt((String) objValue);
447             try {
448                 android.provider.Settings.Secure.putInt(getActivity().getContentResolver(),
449                         Settings.Secure.TTS_DEFAULT_RATE, mDefaultRate);
450                 if (mTts != null) {
451                     mTts.setSpeechRate(mDefaultRate / 100.0f);
452                 }
453                 if (DBG) Log.d(TAG, "TTS default rate changed, now " + mDefaultRate);
454             } catch (NumberFormatException e) {
455                 Log.e(TAG, "could not persist default TTS rate setting", e);
456             }
457         }
458 
459         return true;
460     }
461 
462     /**
463      * Called when mPlayExample is clicked
464      */
465     @Override
onPreferenceClick(Preference preference)466     public boolean onPreferenceClick(Preference preference) {
467         if (preference == mPlayExample) {
468             logEntrySelected(TvSettingsEnums.SYSTEM_A11Y_TTS_LISTEN_EXAMPLE);
469             // Get the sample text from the TTS engine; onActivityResult will do
470             // the actual speaking
471             speakSampleText();
472             return true;
473         }
474 
475         return false;
476     }
477 
updateWidgetState(boolean enable)478     private void updateWidgetState(boolean enable) {
479         mPlayExample.setEnabled(enable);
480         mDefaultRatePref.setEnabled(enable);
481         mEngineStatus.setEnabled(enable);
482     }
483 
updateEngineStatus(int resourceId)484     private void updateEngineStatus(int resourceId) {
485         Locale locale = mCurrentDefaultLocale;
486         if (locale == null) {
487             locale = Locale.getDefault();
488         }
489         mEngineStatus.setSummary(getString(resourceId, locale.getDisplayName()));
490     }
491 
displayNetworkAlert()492     private void displayNetworkAlert() {
493         AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
494         builder.setTitle(android.R.string.dialog_alert_title)
495                 .setMessage(getActivity().getString(R.string.tts_engine_network_required))
496                 .setCancelable(false)
497                 .setPositiveButton(android.R.string.ok, null);
498 
499         AlertDialog dialog = builder.create();
500         dialog.show();
501     }
502 
updateDefaultEngine(String engine)503     private void updateDefaultEngine(String engine) {
504         if (DBG) Log.d(TAG, "Updating default synth to : " + engine);
505 
506         // Disable the "play sample text" preference and the speech
507         // rate preference while the engine is being swapped.
508         updateWidgetState(false);
509         updateEngineStatus(R.string.tts_status_checking);
510 
511         // Keep track of the previous engine that was being used. So that
512         // we can reuse the previous engine.
513         //
514         // Note that if TextToSpeech#getCurrentEngine is not null, it means at
515         // the very least that we successfully bound to the engine service.
516         mPreviousEngine = mTts.getCurrentEngine();
517 
518         // Step 1: Shut down the existing TTS engine.
519         try {
520             mTts.shutdown();
521             mTts = null;
522         } catch (Exception e) {
523             Log.e(TAG, "Error shutting down TTS engine" + e);
524         }
525 
526         // Step 2: Connect to the new TTS engine.
527         // Step 3 is continued on #onUpdateEngine (below) which is called when
528         // the app binds successfully to the engine.
529         if (DBG) Log.d(TAG, "Updating engine : Attempting to connect to engine: " + engine);
530         mTts = new TextToSpeech(getActivity().getApplicationContext(), mUpdateListener, engine);
531         setTtsUtteranceProgressListener();
532     }
533 
534     /*
535      * Step 3: We have now bound to the TTS engine the user requested. We will
536      * attempt to check voice data for the engine if we successfully bound to it,
537      * or revert to the previous engine if we didn't.
538      */
onUpdateEngine(int status)539     public void onUpdateEngine(int status) {
540         if (status == TextToSpeech.SUCCESS) {
541             if (DBG) {
542                 Log.d(TAG, "Updating engine: Successfully bound to the engine: " +
543                         mTts.getCurrentEngine());
544             }
545             checkVoiceData(mTts.getCurrentEngine());
546         } else {
547             if (DBG) Log.d(TAG, "Updating engine: Failed to bind to engine, reverting.");
548             if (mPreviousEngine != null) {
549                 // This is guaranteed to at least bind, since mPreviousEngine would be
550                 // null if the previous bind to this engine failed.
551                 mTts = new TextToSpeech(getActivity().getApplicationContext(), mInitListener,
552                         mPreviousEngine);
553                 setTtsUtteranceProgressListener();
554             }
555             mPreviousEngine = null;
556         }
557     }
558 
559     /*
560      * Step 4: Check whether the voice data for the engine is ok.
561      */
checkVoiceData(String engine)562     private void checkVoiceData(String engine) {
563         Intent intent = new Intent(TextToSpeech.Engine.ACTION_CHECK_TTS_DATA);
564         intent.setPackage(engine);
565         try {
566             if (DBG) Log.d(TAG, "Updating engine: Checking voice data: " + intent.toUri(0));
567             startActivityForResult(intent, VOICE_DATA_INTEGRITY_CHECK);
568         } catch (ActivityNotFoundException ex) {
569             Log.e(TAG, "Failed to check TTS data, no activity found for " + intent + ")");
570         }
571     }
572 
573     /*
574      * Step 5: The voice data check is complete.
575      */
onVoiceDataIntegrityCheckDone(Intent data)576     private void onVoiceDataIntegrityCheckDone(Intent data) {
577         final String engine = mTts.getCurrentEngine();
578 
579         if (engine == null) {
580             Log.e(TAG, "Voice data check complete, but no engine bound");
581             return;
582         }
583 
584         if (data == null){
585             Log.e(TAG, "Engine failed voice data integrity check (null return)" +
586                     mTts.getCurrentEngine());
587             return;
588         }
589 
590         android.provider.Settings.Secure.putString(getActivity().getContentResolver(),
591                 Settings.Secure.TTS_DEFAULT_SYNTH, engine);
592 
593         mAvailableStrLocals = data.getStringArrayListExtra(
594                 TextToSpeech.Engine.EXTRA_AVAILABLE_VOICES);
595         if (mAvailableStrLocals == null) {
596             Log.e(TAG, "Voice data check complete, but no available voices found");
597             // Set mAvailableStrLocals to empty list
598             mAvailableStrLocals = new ArrayList<>();
599         }
600         if (evaluateDefaultLocale()) {
601             getSampleText();
602         }
603 
604         final TextToSpeech.EngineInfo engineInfo = mEnginesHelper.getEngineInfo(engine);
605         TtsEngineSettingsFragment.prepareArgs(mEngineSettingsPref.getExtras(),
606                 engineInfo.name, engineInfo.label, data);
607     }
608 
609     @Override
getCurrentChecked()610     public Checkable getCurrentChecked() {
611         return mCurrentChecked;
612     }
613 
614     @Override
getCurrentKey()615     public String getCurrentKey() {
616         return mCurrentEngine;
617     }
618 
619     @Override
setCurrentChecked(Checkable current)620     public void setCurrentChecked(Checkable current) {
621         mCurrentChecked = current;
622     }
623 
624     @Override
setCurrentKey(String key)625     public void setCurrentKey(String key) {
626         mCurrentEngine = key;
627         updateDefaultEngine(mCurrentEngine);
628     }
629 
630     @Override
getPageId()631     protected int getPageId() {
632         return TvSettingsEnums.SYSTEM_A11Y_TTS;
633     }
634 }
635