1 /*
2  * Copyright (C) 2019 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.car.settings.tts;
18 
19 import android.car.drivingstate.CarUxRestrictions;
20 import android.content.ActivityNotFoundException;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.os.Handler;
24 import android.os.HandlerThread;
25 import android.os.Looper;
26 import android.provider.Settings;
27 import android.speech.tts.TextToSpeech;
28 import android.speech.tts.TtsEngines;
29 import android.text.TextUtils;
30 
31 import androidx.annotation.NonNull;
32 import androidx.annotation.Nullable;
33 import androidx.annotation.VisibleForTesting;
34 import androidx.preference.ListPreference;
35 import androidx.preference.Preference;
36 import androidx.preference.PreferenceGroup;
37 
38 import com.android.car.settings.R;
39 import com.android.car.settings.common.ActivityResultCallback;
40 import com.android.car.settings.common.FragmentController;
41 import com.android.car.settings.common.Logger;
42 import com.android.car.settings.common.PreferenceController;
43 import com.android.car.settings.common.SeekBarPreference;
44 
45 import java.util.ArrayList;
46 import java.util.Collections;
47 import java.util.Locale;
48 import java.util.Objects;
49 
50 /**
51  * Business logic for configuring and listening to the current TTS voice. This preference controller
52  * handles the following:
53  *
54  * <ol>
55  * <li>Changing the TTS language
56  * <li>Changing the TTS speech rate
57  * <li>Changing the TTS voice pitch
58  * <li>Resetting the TTS configuration
59  * </ol>
60  */
61 public class TtsPlaybackPreferenceController extends
62         PreferenceController<PreferenceGroup> implements ActivityResultCallback {
63 
64     private static final Logger LOG = new Logger(TtsPlaybackPreferenceController.class);
65 
66     @VisibleForTesting
67     static final int VOICE_DATA_CHECK = 1;
68     @VisibleForTesting
69     static final int GET_SAMPLE_TEXT = 2;
70 
71     private TtsEngines mEnginesHelper;
72     private TtsPlaybackSettingsManager mTtsPlaybackManager;
73     private TextToSpeech mTts;
74     private int mSelectedLocaleIndex;
75 
76     private ListPreference mDefaultLanguagePreference;
77     private SeekBarPreference mSpeechRatePreference;
78     private SeekBarPreference mVoicePitchPreference;
79     private Preference mResetPreference;
80 
81     private String mSampleText;
82     private Locale mSampleTextLocale;
83 
84     private Handler mUiHandler;
85     @VisibleForTesting
86     Handler mBackgroundHandler;
87     private HandlerThread mBackgroundHandlerThread;
88 
89     /** True if initialized with no errors. */
90     private boolean mTtsInitialized = false;
91 
92     private final TextToSpeech.OnInitListener mOnInitListener = status -> {
93         if (status == TextToSpeech.SUCCESS) {
94             mTtsInitialized = true;
95             mTtsPlaybackManager = new TtsPlaybackSettingsManager(getContext(), mTts,
96                     mEnginesHelper);
97             mTts.setSpeechRate(mTtsPlaybackManager.getCurrentSpeechRate()
98                     / TtsPlaybackSettingsManager.SCALING_FACTOR);
99             mTts.setPitch(mTtsPlaybackManager.getCurrentVoicePitch()
100                     / TtsPlaybackSettingsManager.SCALING_FACTOR);
101             startEngineVoiceDataCheck(mTts.getCurrentEngine());
102             mBackgroundHandler.post(() -> {
103                 checkOrUpdateSampleText();
104                 mUiHandler.post(this::refreshUi);
105             });
106         }
107     };
108 
TtsPlaybackPreferenceController(Context context, String preferenceKey, FragmentController fragmentController, CarUxRestrictions uxRestrictions)109     public TtsPlaybackPreferenceController(Context context, String preferenceKey,
110             FragmentController fragmentController, CarUxRestrictions uxRestrictions) {
111         super(context, preferenceKey, fragmentController, uxRestrictions);
112         mEnginesHelper = new TtsEngines(context);
113     }
114 
115     @Override
getPreferenceType()116     protected Class<PreferenceGroup> getPreferenceType() {
117         return PreferenceGroup.class;
118     }
119 
120     @Override
onCreateInternal()121     protected void onCreateInternal() {
122         mDefaultLanguagePreference = initDefaultLanguagePreference();
123         mSpeechRatePreference = initSpeechRatePreference();
124         mVoicePitchPreference = initVoicePitchPreference();
125         mResetPreference = initResetTtsPlaybackPreference();
126 
127         mUiHandler = new Handler(Looper.getMainLooper());
128         mBackgroundHandlerThread = new HandlerThread(/* name= */"BackgroundHandlerThread");
129         mBackgroundHandlerThread.start();
130         mBackgroundHandler = new Handler(mBackgroundHandlerThread.getLooper());
131 
132         mTts = new TextToSpeech(getContext(), mOnInitListener);
133     }
134 
135     @Override
onDestroyInternal()136     protected void onDestroyInternal() {
137         if (mBackgroundHandlerThread != null) {
138             mBackgroundHandlerThread.quit();
139             mBackgroundHandler = null;
140             mBackgroundHandlerThread = null;
141         }
142         if (mTts != null) {
143             mTts.shutdown();
144             mTts = null;
145             mTtsPlaybackManager = null;
146         }
147     }
148 
149     @Override
updateState(PreferenceGroup preference)150     protected void updateState(PreferenceGroup preference) {
151         boolean isValid = isDefaultLocaleValid();
152         mDefaultLanguagePreference.setEnabled(isValid);
153         // Always hide default language preference for now.
154         // TODO: Unhide once product requirements are clarified.
155         mDefaultLanguagePreference.setVisible(false);
156         mSpeechRatePreference.setEnabled(isValid);
157         mVoicePitchPreference.setEnabled(isValid);
158         mResetPreference.setEnabled(isValid);
159         if (!isValid && mDefaultLanguagePreference.getEntries() != null) {
160             mDefaultLanguagePreference.setEnabled(true);
161         }
162 
163         if (mDefaultLanguagePreference.getEntries() != null) {
164             mDefaultLanguagePreference.setValueIndex(mSelectedLocaleIndex);
165             mDefaultLanguagePreference.setSummary(
166                     mDefaultLanguagePreference.getEntries()[mSelectedLocaleIndex]);
167         }
168 
169         if (mTtsInitialized) {
170             mSpeechRatePreference.setValue(mTtsPlaybackManager.getCurrentSpeechRate());
171             mVoicePitchPreference.setValue(mTtsPlaybackManager.getCurrentVoicePitch());
172         }
173     }
174 
175     @Override
processActivityResult(int requestCode, int resultCode, @Nullable Intent data)176     public void processActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
177         switch (requestCode) {
178             case VOICE_DATA_CHECK:
179                 onVoiceDataIntegrityCheckDone(resultCode, data);
180                 break;
181             case GET_SAMPLE_TEXT:
182                 onSampleTextReceived(resultCode, data);
183                 break;
184             default:
185                 LOG.e("Got unknown activity result");
186         }
187     }
188 
startEngineVoiceDataCheck(String engine)189     private void startEngineVoiceDataCheck(String engine) {
190         Intent intent = new Intent(TextToSpeech.Engine.ACTION_CHECK_TTS_DATA);
191         intent.setPackage(engine);
192         try {
193             LOG.d("Updating engine: Checking voice data: " + intent.toUri(0));
194             getFragmentController().startActivityForResult(intent, VOICE_DATA_CHECK,
195                     this);
196         } catch (ActivityNotFoundException ex) {
197             LOG.e("Failed to check TTS data, no activity found for " + intent);
198         }
199     }
200 
201     /**
202      * Ask the current default engine to return a string of sample text to be
203      * spoken to the user.
204      */
startGetSampleText()205     private void startGetSampleText() {
206         String currentEngine = mTts.getCurrentEngine();
207         if (TextUtils.isEmpty(currentEngine)) {
208             currentEngine = mTts.getDefaultEngine();
209         }
210 
211         Intent intent = new Intent(TextToSpeech.Engine.ACTION_GET_SAMPLE_TEXT);
212         mSampleTextLocale = mTtsPlaybackManager.getEffectiveTtsLocale();
213         if (mSampleTextLocale == null) {
214             return;
215         }
216         intent.putExtra(TextToSpeech.Engine.KEY_PARAM_LANGUAGE, mSampleTextLocale.getLanguage());
217         intent.putExtra(TextToSpeech.Engine.KEY_PARAM_COUNTRY, mSampleTextLocale.getCountry());
218         intent.putExtra(TextToSpeech.Engine.KEY_PARAM_VARIANT, mSampleTextLocale.getVariant());
219         intent.setPackage(currentEngine);
220 
221         try {
222             LOG.d("Getting sample text: " + intent.toUri(0));
223             getFragmentController().startActivityForResult(intent, GET_SAMPLE_TEXT, this);
224         } catch (ActivityNotFoundException ex) {
225             LOG.e("Failed to get sample text, no activity found for " + intent + ")");
226         }
227     }
228 
229     /** The voice data check is complete. */
onVoiceDataIntegrityCheckDone(int resultCode, Intent data)230     private void onVoiceDataIntegrityCheckDone(int resultCode, Intent data) {
231         String engine = mTts.getCurrentEngine();
232         if (engine == null) {
233             LOG.e("Voice data check complete, but no engine bound");
234             return;
235         }
236 
237         if (data == null || resultCode == TextToSpeech.Engine.CHECK_VOICE_DATA_FAIL) {
238             LOG.e("Engine failed voice data integrity check (null return or invalid result code)"
239                     + mTts.getCurrentEngine());
240             return;
241         }
242 
243         Settings.Secure.putString(getContext().getContentResolver(),
244                 Settings.Secure.TTS_DEFAULT_SYNTH, engine);
245 
246         ArrayList<String> availableLangs =
247                 data.getStringArrayListExtra(TextToSpeech.Engine.EXTRA_AVAILABLE_VOICES);
248         if (availableLangs == null || availableLangs.size() == 0) {
249             refreshUi();
250             return;
251         }
252         updateDefaultLanguagePreference(availableLangs);
253         mSelectedLocaleIndex = findLocaleIndex(mTtsPlaybackManager.getStoredTtsLocale());
254         if (mSelectedLocaleIndex < 0) {
255             mSelectedLocaleIndex = 0;
256         }
257         mBackgroundHandler.post(() -> {
258             startGetSampleText();
259             mUiHandler.post(this::refreshUi);
260         });
261     }
262 
onSampleTextReceived(int resultCode, Intent data)263     private void onSampleTextReceived(int resultCode, Intent data) {
264         String sample = getContext().getString(R.string.tts_default_sample_string);
265 
266         if (resultCode == TextToSpeech.LANG_AVAILABLE && data != null) {
267             String tmp = data.getStringExtra(TextToSpeech.Engine.EXTRA_SAMPLE_TEXT);
268             if (!TextUtils.isEmpty(tmp)) {
269                 sample = tmp;
270             }
271             LOG.d("Got sample text: " + sample);
272         } else {
273             LOG.d("Using default sample text :" + sample);
274         }
275 
276         mSampleText = sample;
277     }
278 
updateLanguageTo(Locale locale)279     private void updateLanguageTo(Locale locale) {
280         int selectedLocaleIndex = findLocaleIndex(locale);
281         if (selectedLocaleIndex == -1) {
282             LOG.w("updateLanguageTo called with unknown locale argument");
283             return;
284         }
285 
286         if (mTtsPlaybackManager.updateTtsLocale(locale)) {
287             mSelectedLocaleIndex = selectedLocaleIndex;
288             checkOrUpdateSampleText();
289             refreshUi();
290         } else {
291             LOG.e("updateLanguageTo failed to update tts language");
292         }
293     }
294 
findLocaleIndex(Locale locale)295     private int findLocaleIndex(Locale locale) {
296         String localeString = (locale != null) ? locale.toString() : "";
297         return mDefaultLanguagePreference.findIndexOfValue(localeString);
298     }
299 
isDefaultLocaleValid()300     private boolean isDefaultLocaleValid() {
301         if (!mTtsInitialized) {
302             return false;
303         }
304 
305         if (mSampleTextLocale == null) {
306             LOG.e("Default language was not retrieved from engine " + mTts.getCurrentEngine());
307             return false;
308         }
309 
310         if (mDefaultLanguagePreference.getEntries() == null) {
311             return false;
312         }
313 
314         int index = mDefaultLanguagePreference.findIndexOfValue(mSampleTextLocale.toString());
315         if (index < 0) {
316             return false;
317         }
318         return true;
319     }
320 
checkOrUpdateSampleText()321     private void checkOrUpdateSampleText() {
322         if (!mTtsInitialized) {
323             return;
324         }
325         Locale defaultLocale = mTtsPlaybackManager.getEffectiveTtsLocale();
326         if (defaultLocale == null) {
327             LOG.e("Failed to get default language from engine " + mTts.getCurrentEngine());
328             return;
329         }
330 
331         if (!Objects.equals(defaultLocale, mSampleTextLocale)) {
332             mSampleText = null;
333             mSampleTextLocale = null;
334         }
335 
336         if (mSampleText == null) {
337             startGetSampleText();
338         }
339     }
340 
341     @VisibleForTesting
getSampleText()342     String getSampleText() {
343         return mSampleText;
344     }
345 
346     /* ***************************************************************************************** *
347      * Preference initialization/update code.                                                    *
348      * ***************************************************************************************** */
349 
initDefaultLanguagePreference()350     private ListPreference initDefaultLanguagePreference() {
351         ListPreference defaultLanguagePreference = (ListPreference) getPreference().findPreference(
352                 getContext().getString(R.string.pk_tts_default_language));
353         defaultLanguagePreference.setOnPreferenceChangeListener((preference, newValue) -> {
354             String localeString = (String) newValue;
355             updateLanguageTo(!TextUtils.isEmpty(localeString) ? mEnginesHelper.parseLocaleString(
356                     localeString) : null);
357             checkOrUpdateSampleText();
358             return true;
359         });
360         return defaultLanguagePreference;
361     }
362 
updateDefaultLanguagePreference(@onNull ArrayList<String> availableLangs)363     private void updateDefaultLanguagePreference(@NonNull ArrayList<String> availableLangs) {
364         // Sort locales by display name.
365         ArrayList<Locale> locales = new ArrayList<>();
366         for (int i = 0; i < availableLangs.size(); i++) {
367             Locale locale = mEnginesHelper.parseLocaleString(availableLangs.get(i));
368             if (locale != null) {
369                 locales.add(locale);
370             }
371         }
372         Collections.sort(locales,
373                 (lhs, rhs) -> lhs.getDisplayName().compareToIgnoreCase(rhs.getDisplayName()));
374 
375         // Separate pairs into two separate arrays.
376         CharSequence[] entries = new CharSequence[availableLangs.size() + 1];
377         CharSequence[] entryValues = new CharSequence[availableLangs.size() + 1];
378 
379         entries[0] = getContext().getString(R.string.tts_lang_use_system);
380         entryValues[0] = "";
381 
382         int i = 1;
383         for (Locale locale : locales) {
384             entries[i] = locale.getDisplayName();
385             entryValues[i++] = locale.toString();
386         }
387 
388         mDefaultLanguagePreference.setEntries(entries);
389         mDefaultLanguagePreference.setEntryValues(entryValues);
390     }
391 
initSpeechRatePreference()392     private SeekBarPreference initSpeechRatePreference() {
393         SeekBarPreference speechRatePreference = (SeekBarPreference) getPreference().findPreference(
394                 getContext().getString(R.string.pk_tts_speech_rate));
395         speechRatePreference.setMin(TtsPlaybackSettingsManager.MIN_SPEECH_RATE);
396         speechRatePreference.setMax(TtsPlaybackSettingsManager.MAX_SPEECH_RATE);
397         speechRatePreference.setShowSeekBarValue(false);
398         speechRatePreference.setContinuousUpdate(false);
399         speechRatePreference.setOnPreferenceChangeListener((preference, newValue) -> {
400             if (mTtsPlaybackManager != null) {
401                 mTtsPlaybackManager.updateSpeechRate((Integer) newValue);
402                 mTtsPlaybackManager.speakSampleText(mSampleText);
403                 return true;
404             }
405             LOG.e("speech rate preference enabled before it is allowed");
406             return false;
407         });
408 
409         // Initially disable.
410         speechRatePreference.setEnabled(false);
411         return speechRatePreference;
412     }
413 
initVoicePitchPreference()414     private SeekBarPreference initVoicePitchPreference() {
415         SeekBarPreference pitchPreference = (SeekBarPreference) getPreference().findPreference(
416                 getContext().getString(R.string.pk_tts_pitch));
417         pitchPreference.setMin(TtsPlaybackSettingsManager.MIN_VOICE_PITCH);
418         pitchPreference.setMax(TtsPlaybackSettingsManager.MAX_VOICE_PITCH);
419         pitchPreference.setShowSeekBarValue(false);
420         pitchPreference.setContinuousUpdate(false);
421         pitchPreference.setOnPreferenceChangeListener((preference, newValue) -> {
422             if (mTtsPlaybackManager != null) {
423                 mTtsPlaybackManager.updateVoicePitch((Integer) newValue);
424                 mTtsPlaybackManager.speakSampleText(mSampleText);
425                 return true;
426             }
427             LOG.e("speech pitch preference enabled before it is allowed");
428             return false;
429         });
430 
431         // Initially disable.
432         pitchPreference.setEnabled(false);
433         return pitchPreference;
434     }
435 
initResetTtsPlaybackPreference()436     private Preference initResetTtsPlaybackPreference() {
437         Preference resetPreference = getPreference().findPreference(
438                 getContext().getString(R.string.pk_tts_reset));
439         resetPreference.setOnPreferenceClickListener(preference -> {
440             if (mTtsPlaybackManager != null) {
441                 mTtsPlaybackManager.resetVoicePitch();
442                 mTtsPlaybackManager.resetSpeechRate();
443                 refreshUi();
444                 return true;
445             }
446             LOG.e("reset preference enabled before it is allowed");
447             return false;
448         });
449 
450         // Initially disable.
451         resetPreference.setEnabled(false);
452         return resetPreference;
453     }
454 }
455