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