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.SettingsPreferenceFragment;
24import com.android.settings.tts.TtsEnginePreference.RadioButtonGroupState;
25
26import android.app.AlertDialog;
27import android.content.ActivityNotFoundException;
28import android.content.ContentResolver;
29import android.content.DialogInterface;
30import android.content.Intent;
31import android.os.Bundle;
32import android.preference.ListPreference;
33import android.preference.Preference;
34import android.preference.PreferenceActivity;
35import android.preference.PreferenceCategory;
36import android.provider.Settings;
37import android.provider.Settings.SettingNotFoundException;
38import android.speech.tts.TextToSpeech;
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.List;
46import java.util.Locale;
47
48public class TextToSpeechSettings extends SettingsPreferenceFragment implements
49        Preference.OnPreferenceChangeListener, Preference.OnPreferenceClickListener,
50        RadioButtonGroupState {
51
52    private static final String TAG = "TextToSpeechSettings";
53    private static final boolean DBG = false;
54
55    /** Preference key for the "play TTS example" preference. */
56    private static final String KEY_PLAY_EXAMPLE = "tts_play_example";
57
58    /** Preference key for the TTS rate selection dialog. */
59    private static final String KEY_DEFAULT_RATE = "tts_default_rate";
60
61    /**
62     * Preference key for the engine selection preference.
63     */
64    private static final String KEY_ENGINE_PREFERENCE_SECTION =
65            "tts_engine_preference_section";
66
67    /**
68     * These look like birth years, but they aren't mine. I'm much younger than this.
69     */
70    private static final int GET_SAMPLE_TEXT = 1983;
71    private static final int VOICE_DATA_INTEGRITY_CHECK = 1977;
72
73    private PreferenceCategory mEnginePreferenceCategory;
74    private ListPreference mDefaultRatePref;
75    private Preference mPlayExample;
76
77    private int mDefaultRate = TextToSpeech.Engine.DEFAULT_RATE;
78
79    /**
80     * The currently selected engine.
81     */
82    private String mCurrentEngine;
83
84    /**
85     * The engine checkbox that is currently checked. Saves us a bit of effort
86     * in deducing the right one from the currently selected engine.
87     */
88    private Checkable mCurrentChecked;
89
90    /**
91     * The previously selected TTS engine. Useful for rollbacks if the users
92     * choice is not loaded or fails a voice integrity check.
93     */
94    private String mPreviousEngine;
95
96    private TextToSpeech mTts = null;
97    private TtsEngines mEnginesHelper = null;
98
99    /**
100     * The initialization listener used when we are initalizing the settings
101     * screen for the first time (as opposed to when a user changes his choice
102     * of engine).
103     */
104    private final TextToSpeech.OnInitListener mInitListener = new TextToSpeech.OnInitListener() {
105        @Override
106        public void onInit(int status) {
107            onInitEngine(status);
108        }
109    };
110
111    /**
112     * The initialization listener used when the user changes his choice of
113     * engine (as opposed to when then screen is being initialized for the first
114     * time).
115     */
116    private final TextToSpeech.OnInitListener mUpdateListener = new TextToSpeech.OnInitListener() {
117        @Override
118        public void onInit(int status) {
119            onUpdateEngine(status);
120        }
121    };
122
123    @Override
124    public void onCreate(Bundle savedInstanceState) {
125        super.onCreate(savedInstanceState);
126        addPreferencesFromResource(R.xml.tts_settings);
127
128        getActivity().setVolumeControlStream(TextToSpeech.Engine.DEFAULT_STREAM);
129
130        mPlayExample = findPreference(KEY_PLAY_EXAMPLE);
131        mPlayExample.setOnPreferenceClickListener(this);
132
133        mEnginePreferenceCategory = (PreferenceCategory) findPreference(
134                KEY_ENGINE_PREFERENCE_SECTION);
135        mDefaultRatePref = (ListPreference) findPreference(KEY_DEFAULT_RATE);
136
137        mTts = new TextToSpeech(getActivity().getApplicationContext(), mInitListener);
138        mEnginesHelper = new TtsEngines(getActivity().getApplicationContext());
139
140        initSettings();
141    }
142
143    @Override
144    public void onDestroy() {
145        super.onDestroy();
146        if (mTts != null) {
147            mTts.shutdown();
148            mTts = null;
149        }
150    }
151
152    @Override
153    public void onPause() {
154        super.onPause();
155        if ((mDefaultRatePref != null) && (mDefaultRatePref.getDialog() != null)) {
156            mDefaultRatePref.getDialog().dismiss();
157        }
158    }
159
160    private void initSettings() {
161        final ContentResolver resolver = getContentResolver();
162
163        // Set up the default rate.
164        try {
165            mDefaultRate = Settings.Secure.getInt(resolver, TTS_DEFAULT_RATE);
166        } catch (SettingNotFoundException e) {
167            // Default rate setting not found, initialize it
168            mDefaultRate = TextToSpeech.Engine.DEFAULT_RATE;
169        }
170        mDefaultRatePref.setValue(String.valueOf(mDefaultRate));
171        mDefaultRatePref.setOnPreferenceChangeListener(this);
172
173        mCurrentEngine = mTts.getCurrentEngine();
174
175        PreferenceActivity preferenceActivity = null;
176        if (getActivity() instanceof PreferenceActivity) {
177            preferenceActivity = (PreferenceActivity) getActivity();
178        } else {
179            throw new IllegalStateException("TextToSpeechSettings used outside a " +
180                    "PreferenceActivity");
181        }
182
183        mEnginePreferenceCategory.removeAll();
184
185        List<EngineInfo> engines = mEnginesHelper.getEngines();
186        for (EngineInfo engine : engines) {
187            TtsEnginePreference enginePref = new TtsEnginePreference(getActivity(), engine,
188                    this, preferenceActivity);
189            mEnginePreferenceCategory.addPreference(enginePref);
190        }
191
192        checkVoiceData(mCurrentEngine);
193    }
194
195    private void maybeUpdateTtsLanguage(String currentEngine) {
196        if (currentEngine != null && mTts != null) {
197            final String localeString = mEnginesHelper.getLocalePrefForEngine(
198                    currentEngine);
199            if (localeString != null) {
200                final String[] locale = TtsEngines.parseLocalePref(localeString);
201                final Locale newLocale = new Locale(locale[0], locale[1], locale[2]);
202                final Locale engineLocale = mTts.getLanguage();
203
204                if (!newLocale.equals(engineLocale)) {
205                    if (DBG) Log.d(TAG, "Loading language ahead of sample check : " + locale);
206                    mTts.setLanguage(newLocale);
207                }
208            }
209        }
210    }
211
212    /**
213     * Ask the current default engine to return a string of sample text to be
214     * spoken to the user.
215     */
216    private void getSampleText() {
217        String currentEngine = mTts.getCurrentEngine();
218
219        if (TextUtils.isEmpty(currentEngine)) currentEngine = mTts.getDefaultEngine();
220
221        maybeUpdateTtsLanguage(currentEngine);
222        Locale currentLocale = mTts.getLanguage();
223
224        // TODO: This is currently a hidden private API. The intent extras
225        // and the intent action should be made public if we intend to make this
226        // a public API. We fall back to using a canned set of strings if this
227        // doesn't work.
228        Intent intent = new Intent(TextToSpeech.Engine.ACTION_GET_SAMPLE_TEXT);
229
230        if (currentLocale != null) {
231            intent.putExtra("language", currentLocale.getLanguage());
232            intent.putExtra("country", currentLocale.getCountry());
233            intent.putExtra("variant", currentLocale.getVariant());
234        }
235        intent.setPackage(currentEngine);
236
237        try {
238            if (DBG) Log.d(TAG, "Getting sample text: " + intent.toUri(0));
239            startActivityForResult(intent, GET_SAMPLE_TEXT);
240        } catch (ActivityNotFoundException ex) {
241            Log.e(TAG, "Failed to get sample text, no activity found for " + intent + ")");
242        }
243    }
244
245    /**
246     * Called when the TTS engine is initialized.
247     */
248    public void onInitEngine(int status) {
249        if (status == TextToSpeech.SUCCESS) {
250            updateWidgetState(true);
251            if (DBG) Log.d(TAG, "TTS engine for settings screen initialized.");
252        } else {
253            if (DBG) Log.d(TAG, "TTS engine for settings screen failed to initialize successfully.");
254            updateWidgetState(false);
255        }
256    }
257
258    /**
259     * Called when voice data integrity check returns
260     */
261    @Override
262    public void onActivityResult(int requestCode, int resultCode, Intent data) {
263        if (requestCode == GET_SAMPLE_TEXT) {
264            onSampleTextReceived(resultCode, data);
265        } else if (requestCode == VOICE_DATA_INTEGRITY_CHECK) {
266            onVoiceDataIntegrityCheckDone(data);
267        }
268    }
269
270    private String getDefaultSampleString() {
271        if (mTts != null && mTts.getLanguage() != null) {
272            final String currentLang = mTts.getLanguage().getISO3Language();
273            String[] strings = getActivity().getResources().getStringArray(
274                    R.array.tts_demo_strings);
275            String[] langs = getActivity().getResources().getStringArray(
276                    R.array.tts_demo_string_langs);
277
278            for (int i = 0; i < strings.length; ++i) {
279                if (langs[i].equals(currentLang)) {
280                    return strings[i];
281                }
282            }
283        }
284        return null;
285    }
286
287    private void onSampleTextReceived(int resultCode, Intent data) {
288        String sample = getDefaultSampleString();
289
290        if (resultCode == TextToSpeech.LANG_AVAILABLE && data != null) {
291            if (data != null && data.getStringExtra("sampleText") != null) {
292                sample = data.getStringExtra("sampleText");
293            }
294            if (DBG) Log.d(TAG, "Got sample text: " + sample);
295        } else {
296            if (DBG) Log.d(TAG, "Using default sample text :" + sample);
297        }
298
299        if (sample != null && mTts != null) {
300            // The engine is guaranteed to have been initialized here
301            // because this preference is not enabled otherwise.
302            mTts.speak(sample, TextToSpeech.QUEUE_FLUSH, null);
303        } else {
304            // TODO: Display an error here to the user.
305            Log.e(TAG, "Did not have a sample string for the requested language");
306        }
307    }
308
309    public boolean onPreferenceChange(Preference preference, Object objValue) {
310        if (KEY_DEFAULT_RATE.equals(preference.getKey())) {
311            // Default rate
312            mDefaultRate = Integer.parseInt((String) objValue);
313            try {
314                Settings.Secure.putInt(getContentResolver(), TTS_DEFAULT_RATE, mDefaultRate);
315                if (mTts != null) {
316                    mTts.setSpeechRate(mDefaultRate / 100.0f);
317                }
318                if (DBG) Log.d(TAG, "TTS default rate changed, now " + mDefaultRate);
319            } catch (NumberFormatException e) {
320                Log.e(TAG, "could not persist default TTS rate setting", e);
321            }
322        }
323
324        return true;
325    }
326
327    /**
328     * Called when mPlayExample is clicked
329     */
330    public boolean onPreferenceClick(Preference preference) {
331        if (preference == mPlayExample) {
332            // Get the sample text from the TTS engine; onActivityResult will do
333            // the actual speaking
334            getSampleText();
335            return true;
336        }
337
338        return false;
339    }
340
341    private void updateWidgetState(boolean enable) {
342        mPlayExample.setEnabled(enable);
343        mDefaultRatePref.setEnabled(enable);
344    }
345
346    private void displayDataAlert(final String key) {
347        Log.i(TAG, "Displaying data alert for :" + key);
348        AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
349        builder.setTitle(android.R.string.dialog_alert_title);
350        builder.setIcon(android.R.drawable.ic_dialog_alert);
351        builder.setMessage(getActivity().getString(
352                R.string.tts_engine_security_warning, mEnginesHelper.getEngineInfo(key).label));
353        builder.setCancelable(true);
354        builder.setPositiveButton(android.R.string.ok,
355                new DialogInterface.OnClickListener() {
356                    public void onClick(DialogInterface dialog, int which) {
357                       updateDefaultEngine(key);
358                    }
359                });
360        builder.setNegativeButton(android.R.string.cancel, null);
361
362        AlertDialog dialog = builder.create();
363        dialog.show();
364    }
365
366    private void updateDefaultEngine(String engine) {
367        if (DBG) Log.d(TAG, "Updating default synth to : " + engine);
368
369        // Disable the "play sample text" preference and the speech
370        // rate preference while the engine is being swapped.
371        updateWidgetState(false);
372
373        // Keep track of the previous engine that was being used. So that
374        // we can reuse the previous engine.
375        //
376        // Note that if TextToSpeech#getCurrentEngine is not null, it means at
377        // the very least that we successfully bound to the engine service.
378        mPreviousEngine = mTts.getCurrentEngine();
379
380        // Step 1: Shut down the existing TTS engine.
381        if (mTts != null) {
382            try {
383                mTts.shutdown();
384                mTts = null;
385            } catch (Exception e) {
386                Log.e(TAG, "Error shutting down TTS engine" + e);
387            }
388        }
389
390        // Step 2: Connect to the new TTS engine.
391        // Step 3 is continued on #onUpdateEngine (below) which is called when
392        // the app binds successfully to the engine.
393        if (DBG) Log.d(TAG, "Updating engine : Attempting to connect to engine: " + engine);
394        mTts = new TextToSpeech(getActivity().getApplicationContext(), mUpdateListener, engine);
395    }
396
397    /*
398     * Step 3: We have now bound to the TTS engine the user requested. We will
399     * attempt to check voice data for the engine if we successfully bound to it,
400     * or revert to the previous engine if we didn't.
401     */
402    public void onUpdateEngine(int status) {
403        if (status == TextToSpeech.SUCCESS) {
404            if (DBG) {
405                Log.d(TAG, "Updating engine: Successfully bound to the engine: " +
406                        mTts.getCurrentEngine());
407            }
408            checkVoiceData(mTts.getCurrentEngine());
409        } else {
410            if (DBG) Log.d(TAG, "Updating engine: Failed to bind to engine, reverting.");
411            if (mPreviousEngine != null) {
412                // This is guaranteed to at least bind, since mPreviousEngine would be
413                // null if the previous bind to this engine failed.
414                mTts = new TextToSpeech(getActivity().getApplicationContext(), mInitListener,
415                        mPreviousEngine);
416            }
417            mPreviousEngine = null;
418        }
419    }
420
421    /*
422     * Step 4: Check whether the voice data for the engine is ok.
423     */
424    private void checkVoiceData(String engine) {
425        Intent intent = new Intent(TextToSpeech.Engine.ACTION_CHECK_TTS_DATA);
426        intent.setPackage(engine);
427        try {
428            if (DBG) Log.d(TAG, "Updating engine: Checking voice data: " + intent.toUri(0));
429            startActivityForResult(intent, VOICE_DATA_INTEGRITY_CHECK);
430        } catch (ActivityNotFoundException ex) {
431            Log.e(TAG, "Failed to check TTS data, no activity found for " + intent + ")");
432        }
433    }
434
435    /*
436     * Step 5: The voice data check is complete.
437     */
438    private void onVoiceDataIntegrityCheckDone(Intent data) {
439        final String engine = mTts.getCurrentEngine();
440
441        if (engine == null) {
442            Log.e(TAG, "Voice data check complete, but no engine bound");
443            return;
444        }
445
446        if (data == null){
447            Log.e(TAG, "Engine failed voice data integrity check (null return)" +
448                    mTts.getCurrentEngine());
449            return;
450        }
451
452        Settings.Secure.putString(getContentResolver(), TTS_DEFAULT_SYNTH, engine);
453
454        final int engineCount = mEnginePreferenceCategory.getPreferenceCount();
455        for (int i = 0; i < engineCount; ++i) {
456            final Preference p = mEnginePreferenceCategory.getPreference(i);
457            if (p instanceof TtsEnginePreference) {
458                TtsEnginePreference enginePref = (TtsEnginePreference) p;
459                if (enginePref.getKey().equals(engine)) {
460                    enginePref.setVoiceDataDetails(data);
461                    break;
462                }
463            }
464        }
465
466        updateWidgetState(true);
467    }
468
469    private boolean shouldDisplayDataAlert(String engine) {
470        final EngineInfo info = mEnginesHelper.getEngineInfo(engine);
471        return !info.system;
472    }
473
474    @Override
475    public Checkable getCurrentChecked() {
476        return mCurrentChecked;
477    }
478
479    @Override
480    public String getCurrentKey() {
481        return mCurrentEngine;
482    }
483
484    @Override
485    public void setCurrentChecked(Checkable current) {
486        mCurrentChecked = current;
487    }
488
489    @Override
490    public void setCurrentKey(String key) {
491        mCurrentEngine = key;
492        if (shouldDisplayDataAlert(mCurrentEngine)) {
493            displayDataAlert(mCurrentEngine);
494        } else {
495            updateDefaultEngine(mCurrentEngine);
496        }
497    }
498
499}
500