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