Add support for voices in TTS API.

Voices allow to expose multiple backends/voice packs for a single
Locale. This is an attempt to port this feature from V2 API.

Bug: 15834470
Change-Id: I0117de238cfcf028bcec5344b8d65c960b96b98c
diff --git a/api/current.txt b/api/current.txt
index fb598fd..4eea35d 100644
--- a/api/current.txt
+++ b/api/current.txt
@@ -26609,6 +26609,7 @@
     method public int getSpeechRate();
     method public deprecated java.lang.String getText();
     method public java.lang.String getVariant();
+    method public java.lang.String getVoiceName();
   }
 
   public class TextToSpeech {
@@ -26620,13 +26621,17 @@
     method public int addSpeech(java.lang.CharSequence, java.lang.String, int);
     method public int addSpeech(java.lang.String, java.lang.String);
     method public int addSpeech(java.lang.CharSequence, java.lang.String);
-    method public boolean areDefaultsEnforced();
+    method public deprecated boolean areDefaultsEnforced();
+    method public java.util.Set<java.util.Locale> getAvailableLanguages();
     method public java.lang.String getDefaultEngine();
-    method public java.util.Locale getDefaultLanguage();
+    method public deprecated java.util.Locale getDefaultLanguage();
+    method public android.speech.tts.Voice getDefaultVoice();
     method public java.util.List<android.speech.tts.TextToSpeech.EngineInfo> getEngines();
-    method public java.util.Set<java.lang.String> getFeatures(java.util.Locale);
-    method public java.util.Locale getLanguage();
+    method public deprecated java.util.Set<java.lang.String> getFeatures(java.util.Locale);
+    method public deprecated java.util.Locale getLanguage();
     method public static int getMaxSpeechInputLength();
+    method public android.speech.tts.Voice getVoice();
+    method public java.util.Set<android.speech.tts.Voice> getVoices();
     method public int isLanguageAvailable(java.util.Locale);
     method public boolean isSpeaking();
     method public int playEarcon(java.lang.String, int, java.util.HashMap<java.lang.String, java.lang.String>, java.lang.String);
@@ -26639,6 +26644,7 @@
     method public int setOnUtteranceProgressListener(android.speech.tts.UtteranceProgressListener);
     method public int setPitch(float);
     method public int setSpeechRate(float);
+    method public int setVoice(android.speech.tts.Voice);
     method public void shutdown();
     method public int speak(java.lang.CharSequence, int, java.util.HashMap<java.lang.String, java.lang.String>, java.lang.String);
     method public deprecated int speak(java.lang.String, int, java.util.HashMap<java.lang.String, java.lang.String>);
@@ -26650,6 +26656,7 @@
     field public static final int ERROR_INVALID_REQUEST = -8; // 0xfffffff8
     field public static final int ERROR_NETWORK = -6; // 0xfffffffa
     field public static final int ERROR_NETWORK_TIMEOUT = -7; // 0xfffffff9
+    field public static final int ERROR_NOT_INSTALLED_YET = -9; // 0xfffffff7
     field public static final int ERROR_OUTPUT = -5; // 0xfffffffb
     field public static final int ERROR_SERVICE = -4; // 0xfffffffc
     field public static final int ERROR_SYNTHESIS = -3; // 0xfffffffd
@@ -26685,8 +26692,11 @@
     field public static final deprecated java.lang.String EXTRA_VOICE_DATA_FILES_INFO = "dataFilesInfo";
     field public static final deprecated java.lang.String EXTRA_VOICE_DATA_ROOT_DIRECTORY = "dataRoot";
     field public static final java.lang.String INTENT_ACTION_TTS_SERVICE = "android.intent.action.TTS_SERVICE";
-    field public static final java.lang.String KEY_FEATURE_EMBEDDED_SYNTHESIS = "embeddedTts";
-    field public static final java.lang.String KEY_FEATURE_NETWORK_SYNTHESIS = "networkTts";
+    field public static final deprecated java.lang.String KEY_FEATURE_EMBEDDED_SYNTHESIS = "embeddedTts";
+    field public static final java.lang.String KEY_FEATURE_NETWORK_RETRIES_COUNT = "networkRetriesCount";
+    field public static final deprecated java.lang.String KEY_FEATURE_NETWORK_SYNTHESIS = "networkTts";
+    field public static final java.lang.String KEY_FEATURE_NETWORK_TIMEOUT_MS = "networkTimeoutMs";
+    field public static final java.lang.String KEY_FEATURE_NOT_INSTALLED = "notInstalled";
     field public static final java.lang.String KEY_PARAM_PAN = "pan";
     field public static final java.lang.String KEY_PARAM_SESSION_ID = "sessionId";
     field public static final java.lang.String KEY_PARAM_STREAM = "streamType";
@@ -26712,11 +26722,15 @@
 
   public abstract class TextToSpeechService extends android.app.Service {
     ctor public TextToSpeechService();
+    method protected int isValidVoiceName(java.lang.String);
     method public android.os.IBinder onBind(android.content.Intent);
+    method protected java.lang.String onGetDefaultVoiceNameFor(java.lang.String, java.lang.String, java.lang.String);
     method protected java.util.Set<java.lang.String> onGetFeaturesForLanguage(java.lang.String, java.lang.String, java.lang.String);
     method protected abstract java.lang.String[] onGetLanguage();
+    method protected java.util.List<android.speech.tts.Voice> onGetVoices();
     method protected abstract int onIsLanguageAvailable(java.lang.String, java.lang.String, java.lang.String);
     method protected abstract int onLoadLanguage(java.lang.String, java.lang.String, java.lang.String);
+    method protected int onLoadVoice(java.lang.String);
     method protected abstract void onStop();
     method protected abstract void onSynthesizeText(android.speech.tts.SynthesisRequest, android.speech.tts.SynthesisCallback);
   }
@@ -26808,6 +26822,28 @@
     method public abstract void onStart(java.lang.String);
   }
 
+  public class Voice implements android.os.Parcelable {
+    ctor public Voice(java.lang.String, java.util.Locale, int, int, boolean, java.util.Set<java.lang.String>);
+    method public int describeContents();
+    method public java.util.Set<java.lang.String> getFeatures();
+    method public int getLatency();
+    method public java.util.Locale getLocale();
+    method public java.lang.String getName();
+    method public int getQuality();
+    method public boolean getRequiresNetworkConnection();
+    method public void writeToParcel(android.os.Parcel, int);
+    field public static final int LATENCY_HIGH = 400; // 0x190
+    field public static final int LATENCY_LOW = 200; // 0xc8
+    field public static final int LATENCY_NORMAL = 300; // 0x12c
+    field public static final int LATENCY_VERY_HIGH = 500; // 0x1f4
+    field public static final int LATENCY_VERY_LOW = 100; // 0x64
+    field public static final int QUALITY_HIGH = 400; // 0x190
+    field public static final int QUALITY_LOW = 200; // 0xc8
+    field public static final int QUALITY_NORMAL = 300; // 0x12c
+    field public static final int QUALITY_VERY_HIGH = 500; // 0x1f4
+    field public static final int QUALITY_VERY_LOW = 100; // 0x64
+  }
+
 }
 
 package android.system {
diff --git a/core/java/android/speech/tts/ITextToSpeechService.aidl b/core/java/android/speech/tts/ITextToSpeechService.aidl
index 694f25a..4faa67f 100644
--- a/core/java/android/speech/tts/ITextToSpeechService.aidl
+++ b/core/java/android/speech/tts/ITextToSpeechService.aidl
@@ -20,6 +20,7 @@
 import android.os.Bundle;
 import android.os.ParcelFileDescriptor;
 import android.speech.tts.ITextToSpeechCallback;
+import android.speech.tts.Voice;
 
 /**
  * Interface for TextToSpeech to talk to TextToSpeechService.
@@ -173,4 +174,37 @@
      * @param cb The callback.
      */
     void setCallback(in IBinder caller, ITextToSpeechCallback cb);
+
+    /**
+     * Get the array of available voices.
+     */
+    List<Voice> getVoices();
+
+    /**
+     * Notifies the engine that it should load a speech synthesis voice.
+     *
+     * @param caller a binder representing the identity of the calling
+     *        TextToSpeech object.
+     * @param voiceName Unique voice of the name.
+     * @return {@link TextToSpeech#SUCCESS} or {@link TextToSpeech#ERROR}.
+     */
+    int loadVoice(in IBinder caller, in String voiceName);
+
+    /**
+     * Return a name of the default voice for a given locale.
+     *
+     * This allows {@link TextToSpeech#getVoice} to return a sensible value after a client calls
+     * {@link TextToSpeech#setLanguage}.
+     *
+     * @param lang ISO 3-character language code.
+     * @param country ISO 3-character country code. May be empty or null.
+     * @param variant Language variant. May be empty or null.
+     * @return Code indicating the support status for the locale.
+     *         One of {@link TextToSpeech#LANG_AVAILABLE},
+     *         {@link TextToSpeech#LANG_COUNTRY_AVAILABLE},
+     *         {@link TextToSpeech#LANG_COUNTRY_VAR_AVAILABLE},
+     *         {@link TextToSpeech#LANG_MISSING_DATA}
+     *         {@link TextToSpeech#LANG_NOT_SUPPORTED}.
+     */
+    String getDefaultVoiceNameFor(in String lang, in String country, in String variant);
 }
diff --git a/core/java/android/speech/tts/SynthesisRequest.java b/core/java/android/speech/tts/SynthesisRequest.java
index eaacc06..d41aa67 100644
--- a/core/java/android/speech/tts/SynthesisRequest.java
+++ b/core/java/android/speech/tts/SynthesisRequest.java
@@ -18,12 +18,15 @@
 import android.os.Bundle;
 
 /**
- * Contains data required by engines to synthesize speech. This data is :
+ * Contains data required by engines to synthesize speech. This data is:
  * <ul>
  *   <li>The text to synthesize</li>
  *   <li>The synthesis locale, represented as a language, country and a variant.
  *   The language is an ISO 639-3 letter language code, and the country is an
  *   ISO 3166 alpha 3 code. The variant is not specified.</li>
+ *   <li>The name of the voice requested for this synthesis. May be empty if
+ *   the client uses {@link TextToSpeech#setLanguage} instead of
+ *   {@link TextToSpeech#setVoice}</li>
  *   <li>The synthesis speech rate, with 100 being the normal, and
  *   higher values representing higher speech rates.</li>
  *   <li>The voice pitch, with 100 being the default pitch.</li>
@@ -36,6 +39,7 @@
 public final class SynthesisRequest {
     private final CharSequence mText;
     private final Bundle mParams;
+    private String mVoiceName;
     private String mLanguage;
     private String mCountry;
     private String mVariant;
@@ -72,6 +76,13 @@
     }
 
     /**
+     * Gets the name of the voice to use.
+     */
+    public String getVoiceName() {
+        return mVoiceName;
+    }
+
+    /**
      * Gets the ISO 3-letter language code for the language to use.
      */
     public String getLanguage() {
@@ -130,6 +141,13 @@
     }
 
     /**
+     * Sets the voice name for the request.
+     */
+    void setVoiceName(String voiceName) {
+        mVoiceName = voiceName;
+    }
+
+    /**
      * Sets the speech rate.
      */
     void setSpeechRate(int speechRate) {
diff --git a/core/java/android/speech/tts/TextToSpeech.java b/core/java/android/speech/tts/TextToSpeech.java
index e1c1767..ac9044a 100644
--- a/core/java/android/speech/tts/TextToSpeech.java
+++ b/core/java/android/speech/tts/TextToSpeech.java
@@ -36,6 +36,7 @@
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -44,6 +45,7 @@
 import java.util.Map;
 import java.util.MissingResourceException;
 import java.util.Set;
+import java.util.TreeSet;
 
 /**
  *
@@ -104,6 +106,12 @@
     public static final int ERROR_INVALID_REQUEST = -8;
 
     /**
+     * Denotes a failure caused by an unfinished download of the voice data.
+     * @see Engine#KEY_FEATURE_NOT_INSTALLED
+     */
+    public static final int ERROR_NOT_INSTALLED_YET = -9;
+
+    /**
      * Queue mode where all entries in the playback queue (media to be played
      * and text to be synthesized) are dropped and replaced by the new entry.
      * Queues are flushed with respect to a given calling app. Entries in the queue
@@ -478,6 +486,11 @@
         /**
          * @hide
          */
+        public static final String KEY_PARAM_VOICE_NAME = "voiceName";
+
+        /**
+         * @hide
+         */
         public static final String KEY_PARAM_LANGUAGE = "language";
 
         /**
@@ -550,7 +563,13 @@
          * @see TextToSpeech#speak(String, int, java.util.HashMap)
          * @see TextToSpeech#synthesizeToFile(String, java.util.HashMap, String)
          * @see TextToSpeech#getFeatures(java.util.Locale)
+         *
+         * @deprecated Starting from API level 20, to select network synthesis, call
+         * ({@link TextToSpeech#getVoices()}, find a suitable network voice
+         * ({@link Voice#getRequiresNetworkConnection()}) and pass it
+         * to {@link TextToSpeech#setVoice(Voice)}).
          */
+        @Deprecated
         public static final String KEY_FEATURE_NETWORK_SYNTHESIS = "networkTts";
 
         /**
@@ -562,7 +581,13 @@
          * @see TextToSpeech#speak(String, int, java.util.HashMap)
          * @see TextToSpeech#synthesizeToFile(String, java.util.HashMap, String)
          * @see TextToSpeech#getFeatures(java.util.Locale)
+
+         * @deprecated Starting from API level 20, to select embedded synthesis, call
+         * ({@link TextToSpeech#getVoices()}, find a suitable embedded voice
+         * ({@link Voice#getRequiresNetworkConnection()}) and pass it
+         * to {@link TextToSpeech#setVoice(Voice)}).
          */
+        @Deprecated
         public static final String KEY_FEATURE_EMBEDDED_SYNTHESIS = "embeddedTts";
 
         /**
@@ -575,6 +600,43 @@
          * @see TextToSpeech#playEarcon(String, int, HashMap)
          */
         public static final String KEY_PARAM_SESSION_ID = "sessionId";
+
+        /**
+         * Feature key that indicates that the voice may need to download additional data to be fully
+         * functional. The download will be triggered by calling
+         * {@link TextToSpeech#setVoice(Voice)} or {@link TextToSpeech#setLanguage(Locale)}.
+         * Until download is complete, each synthesis request will either report
+         * {@link TextToSpeech#ERROR_NOT_INSTALLED_YET} error, or use a different voice to synthesize
+         * the request. This feature should NOT be used as a key of a request parameter.
+         *
+         * @see TextToSpeech#getFeatures(java.util.Locale)
+         * @see Voice#getFeatures()
+         */
+        public static final String KEY_FEATURE_NOT_INSTALLED = "notInstalled";
+
+        /**
+         * Feature key that indicate that a network timeout can be set for the request. If set and
+         * supported as per {@link TextToSpeech#getFeatures(Locale)} or {@link Voice#getFeatures()},
+         * it can be used as request parameter to set the maximum allowed time for a single
+         * request attempt, in milliseconds, before synthesis fails. When used as a key of
+         * a request parameter, its value should be a string with an integer value.
+         *
+         * @see TextToSpeech#getFeatures(java.util.Locale)
+         * @see Voice#getFeatures()
+         */
+        public static final String KEY_FEATURE_NETWORK_TIMEOUT_MS = "networkTimeoutMs";
+
+        /**
+         * Feature key that indicates that network request retries count can be set for the request.
+         * If set and supported as per {@link TextToSpeech#getFeatures(Locale)} or
+         * {@link Voice#getFeatures()}, it can be used as a request parameter to set the
+         * number of network request retries that are attempted in case of failure. When used as
+         * a key of a request parameter, its value should be a string with an integer value.
+         *
+         * @see TextToSpeech#getFeatures(java.util.Locale)
+         * @see Voice#getFeatures()
+         */
+        public static final String KEY_FEATURE_NETWORK_RETRIES_COUNT = "networkRetriesCount";
     }
 
     private final Context mContext;
@@ -596,7 +658,6 @@
     private final Map<CharSequence, Uri> mUtterances;
     private final Bundle mParams = new Bundle();
     private final TtsEngines mEnginesHelper;
-    private final String mPackageName;
     private volatile String mCurrentEngine = null;
 
     /**
@@ -648,11 +709,6 @@
         mUtteranceProgressListener = null;
 
         mEnginesHelper = new TtsEngines(mContext);
-        if (packageName != null) {
-            mPackageName = packageName;
-        } else {
-            mPackageName = mContext.getPackageName();
-        }
         initTts();
     }
 
@@ -1186,12 +1242,16 @@
      * {@link TextToSpeech#speak(String, int, java.util.HashMap)} and
      * {@link TextToSpeech#synthesizeToFile(String, java.util.HashMap, String)}.
      *
-     * Features are boolean flags, and their values in the synthesis parameters
-     * must be behave as per {@link Boolean#parseBoolean(String)}.
+     * Features values are strings and their values must meet restrictions described in their
+     * documentation.
      *
      * @param locale The locale to query features for.
      * @return Set instance. May return {@code null} on error.
+     * @deprecated As of API level 20, please use voices. In order to query features of the voice,
+     * call {@link #getVoices()} to retrieve the list of available voices and
+     * {@link Voice#getFeatures()} to retrieve the set of features.
      */
+    @Deprecated
     public Set<String> getFeatures(final Locale locale) {
         return runAction(new Action<Set<String>>() {
             @Override
@@ -1308,9 +1368,15 @@
      * Returns a Locale instance describing the language currently being used as the default
      * Text-to-speech language.
      *
+     * The locale object returned by this method is NOT a valid one. It has identical form to the
+     * one in {@link #getLanguage()}. Please refer to {@link #getLanguage()} for more information.
+     *
      * @return language, country (if any) and variant (if any) used by the client stored in a
      *     Locale instance, or {@code null} on error.
+     * @deprecated As of API Level 20, use <code>getDefaultVoice().getLocale()</code> ({@link
+     *   #getDefaultVoice()})
      */
+    @Deprecated
     public Locale getDefaultLanguage() {
         return runAction(new Action<Locale>() {
             @Override
@@ -1329,6 +1395,9 @@
      * will be used. Use {@link #isLanguageAvailable(Locale)} to check the level of support
      * before choosing the language to use for the next utterances.
      *
+     * This method sets the current voice to the default one for the given Locale;
+     * {@link #getVoice()} can be used to retrieve it.
+     *
      * @param loc The locale describing the language to be used.
      *
      * @return Code indicating the support status for the locale. See {@link #LANG_AVAILABLE},
@@ -1359,12 +1428,12 @@
 
                 String variant = loc.getVariant();
 
-                // Check if the language, country, variant are available, and cache
-                // the available parts.
-                // Note that the language is not actually set here, instead it is cached so it
-                // will be associated with all upcoming utterances.
+                // As of API level 20, setLanguage is implemented using setVoice.
+                // (which, in the default implementation, will call loadLanguage on the service
+                // interface).
 
-                int result = service.loadLanguage(getCallerIdentity(), language, country, variant);
+                // Sanitize locale using isLanguageAvailable.
+                int result = service.isLanguageAvailable( language, country, variant);
                 if (result >= LANG_AVAILABLE){
                     if (result < LANG_COUNTRY_VAR_AVAILABLE) {
                         variant = "";
@@ -1372,6 +1441,20 @@
                             country = "";
                         }
                     }
+                    // Get the default voice for the locale.
+                    String voiceName = service.getDefaultVoiceNameFor(language, country, variant);
+                    if (TextUtils.isEmpty(voiceName)) {
+                        Log.w(TAG, "Couldn't find the default voice for " + language + "/" +
+                                country + "/" + variant);
+                        return LANG_NOT_SUPPORTED;
+                    }
+
+                    // Load it.
+                    if (service.loadVoice(getCallerIdentity(), voiceName) == TextToSpeech.ERROR) {
+                        return LANG_NOT_SUPPORTED;
+                    }
+
+                    mParams.putString(Engine.KEY_PARAM_VOICE_NAME, voiceName);
                     mParams.putString(Engine.KEY_PARAM_LANGUAGE, language);
                     mParams.putString(Engine.KEY_PARAM_COUNTRY, country);
                     mParams.putString(Engine.KEY_PARAM_VARIANT, variant);
@@ -1393,9 +1476,21 @@
      * used for the synthesis requests sent from this client. That is the last language set
      * by a {@link TextToSpeech#setLanguage} call on this instance.
      *
+     * If a voice is set (by {@link #setVoice(Voice)}), getLanguage will return the language of
+     * the currently set voice.
+     *
+     * Please note that the Locale object returned by this method is NOT a valid Locale object. Its
+     * language field contains a three-letter ISO 639-2/T code (where a proper Locale would use
+     * a two-letter ISO 639-1 code), and the country field contains a three-letter ISO 3166 country
+     * code (where a proper Locale would use a two-letter ISO 3166-1 code).
+     *
      * @return language, country (if any) and variant (if any) used by the client stored in a
      *     Locale instance, or {@code null} on error.
+     *
+     * @deprecated As of API level 20, please use <code>getVoice().getLocale()</code>
+     * ({@link #getVoice()}).
      */
+    @Deprecated
     public Locale getLanguage() {
         return runAction(new Action<Locale>() {
             @Override
@@ -1411,6 +1506,178 @@
     }
 
     /**
+     * Query the engine about the set of available languages.
+     */
+    public Set<Locale> getAvailableLanguages() {
+        return runAction(new Action<Set<Locale>>() {
+            @Override
+            public Set<Locale> run(ITextToSpeechService service) throws RemoteException {
+                List<Voice> voices = service.getVoices();
+                if (voices != null) {
+                    return new TreeSet<Locale>();
+                }
+                TreeSet<Locale> locales = new TreeSet<Locale>();
+                for (Voice voice : voices) {
+                    locales.add(voice.getLocale());
+                }
+                return locales;
+            }
+        }, null, "getAvailableLanguages");
+    }
+
+    /**
+     * Query the engine about the set of available voices.
+     *
+     * Each TTS Engine can expose multiple voices for each locale, each with a different set of
+     * features.
+     *
+     * @see #setVoice(Voice)
+     * @see Voice
+     */
+    public Set<Voice> getVoices() {
+        return runAction(new Action<Set<Voice>>() {
+            @Override
+            public Set<Voice> run(ITextToSpeechService service) throws RemoteException {
+                List<Voice> voices = service.getVoices();
+                return (voices != null)  ? new TreeSet<Voice>(voices) : new TreeSet<Voice>();
+            }
+        }, null, "getVoices");
+    }
+
+    /**
+     * Sets the text-to-speech voice.
+     *
+     * @param voice One of objects returned by {@link #getVoices()}.
+     *
+     * @return {@link #ERROR} or {@link #SUCCESS}.
+     *
+     * @see #getVoices
+     * @see Voice
+     */
+    public int setVoice(final Voice voice) {
+        return runAction(new Action<Integer>() {
+            @Override
+            public Integer run(ITextToSpeechService service) throws RemoteException {
+                int result = service.loadVoice(getCallerIdentity(), voice.getName());
+                if (result == SUCCESS) {
+                    mParams.putString(Engine.KEY_PARAM_VOICE_NAME, voice.getName());
+
+                    // Set the language/country/variant, so #getLanguage will return the voice
+                    // locale when called.
+                    String language = "";
+                    try {
+                        language = voice.getLocale().getISO3Language();
+                    } catch (MissingResourceException e) {
+                        Log.w(TAG, "Couldn't retrieve ISO 639-2/T language code for locale: " +
+                                voice.getLocale(), e);
+                    }
+
+                    String country = "";
+                    try {
+                        country = voice.getLocale().getISO3Country();
+                    } catch (MissingResourceException e) {
+                        Log.w(TAG, "Couldn't retrieve ISO 3166 country code for locale: " +
+                                voice.getLocale(), e);
+                    }
+                    mParams.putString(Engine.KEY_PARAM_LANGUAGE, language);
+                    mParams.putString(Engine.KEY_PARAM_COUNTRY, country);
+                    mParams.putString(Engine.KEY_PARAM_VARIANT, voice.getLocale().getVariant());
+                }
+                return result;
+            }
+        }, LANG_NOT_SUPPORTED, "setVoice");
+    }
+
+    /**
+     * Returns a Voice instance describing the voice currently being used for synthesis
+     * requests sent to the TextToSpeech engine.
+     *
+     * @return Voice instance used by the client, or {@code null} if not set or on error.
+     *
+     * @see #getVoices
+     * @see #setVoice
+     * @see Voice
+     */
+    public Voice getVoice() {
+        return runAction(new Action<Voice>() {
+            @Override
+            public Voice run(ITextToSpeechService service) throws RemoteException {
+                String voiceName = mParams.getString(Engine.KEY_PARAM_VOICE_NAME, "");
+                if (TextUtils.isEmpty(voiceName)) {
+                    return null;
+                }
+                List<Voice> voices = service.getVoices();
+                if (voices == null) {
+                    return null;
+                }
+                for (Voice voice : voices) {
+                    if (voice.getName().equals(voiceName)) {
+                        return voice;
+                    }
+                }
+                return null;
+            }
+        }, null, "getVoice");
+    }
+
+    /**
+     * Returns a Voice instance that's the default voice for the default Text-to-speech language.
+     * @return The default voice instance for the default language, or {@code null} if not set or
+     *     on error.
+     */
+    public Voice getDefaultVoice() {
+        return runAction(new Action<Voice>() {
+            @Override
+            public Voice run(ITextToSpeechService service) throws RemoteException {
+
+                String[] defaultLanguage = service.getClientDefaultLanguage();
+
+                if (defaultLanguage == null || defaultLanguage.length == 0) {
+                    Log.e(TAG, "service.getClientDefaultLanguage() returned empty array");
+                    return null;
+                }
+                String language = defaultLanguage[0];
+                String country = (defaultLanguage.length > 1) ? defaultLanguage[1] : "";
+                String variant = (defaultLanguage.length > 2) ? defaultLanguage[2] : "";
+
+                // Sanitize the locale using isLanguageAvailable.
+                int result = service.isLanguageAvailable(language, country, variant);
+                if (result >= LANG_AVAILABLE){
+                    if (result < LANG_COUNTRY_VAR_AVAILABLE) {
+                        variant = "";
+                        if (result < LANG_COUNTRY_AVAILABLE) {
+                            country = "";
+                        }
+                    }
+                } else {
+                    // The default language is not supported.
+                    return null;
+                }
+
+                // Get the default voice name
+                String voiceName = service.getDefaultVoiceNameFor(language, country, variant);
+                if (TextUtils.isEmpty(voiceName)) {
+                    return null;
+                }
+
+                // Find it
+                List<Voice> voices = service.getVoices();
+                if (voices == null) {
+                    return null;
+                }
+                for (Voice voice : voices) {
+                    if (voice.getName().equals(voiceName)) {
+                        return voice;
+                    }
+                }
+                return null;
+            }
+        }, null, "getDefaultVoice");
+    }
+
+
+
+    /**
      * Checks if the specified language as represented by the Locale is available and supported.
      *
      * @param loc The Locale describing the language to be used.
@@ -1538,6 +1805,8 @@
             // Copy feature strings defined by the framework.
             copyStringParam(bundle, params, Engine.KEY_FEATURE_NETWORK_SYNTHESIS);
             copyStringParam(bundle, params, Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS);
+            copyIntParam(bundle, params, Engine.KEY_FEATURE_NETWORK_TIMEOUT_MS);
+            copyIntParam(bundle, params, Engine.KEY_FEATURE_NETWORK_RETRIES_COUNT);
 
             // Copy over all parameters that start with the name of the
             // engine that we are currently connected to. The engine is
@@ -1653,6 +1922,7 @@
      * by the calling application. As of the Ice cream sandwich release,
      * user settings never forcibly override the app's settings.
      */
+    @Deprecated
     public boolean areDefaultsEnforced() {
         return false;
     }
diff --git a/core/java/android/speech/tts/TextToSpeechService.java b/core/java/android/speech/tts/TextToSpeechService.java
index 017be93..ecfb8e0 100644
--- a/core/java/android/speech/tts/TextToSpeechService.java
+++ b/core/java/android/speech/tts/TextToSpeechService.java
@@ -39,6 +39,7 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -49,7 +50,7 @@
 
 /**
  * Abstract base class for TTS engine implementations. The following methods
- * need to be implemented for V1 API ({@link TextToSpeech}) implementation.
+ * need to be implemented:
  * <ul>
  * <li>{@link #onIsLanguageAvailable}</li>
  * <li>{@link #onLoadLanguage}</li>
@@ -76,6 +77,29 @@
  *
  * {@link #onGetLanguage} is not required as of JELLYBEAN_MR2 (API 18) and later, it is only
  * called on earlier versions of Android.
+ *
+ * API Level 20 adds support for Voice objects. Voices are an abstraction that allow the TTS
+ * service to expose multiple backends for a single locale. Each one of them can have a different
+ * features set. In order to fully take advantage of voices, an engine should implement
+ * the following methods:
+ * <ul>
+ * <li>{@link #onGetVoices()}</li>
+ * <li>{@link #isValidVoiceName(String)}</li>
+ * <li>{@link #onLoadVoice(String)}</li>
+ * <li>{@link #onGetDefaultVoiceNameFor(String, String, String)}</li>
+ * </ul>
+ * The first three methods are siblings of the {@link #onGetLanguage},
+ * {@link #onIsLanguageAvailable} and {@link #onLoadLanguage} methods. The last one,
+ * {@link #onGetDefaultVoiceNameFor(String, String, String)} is a link between locale and voice
+ * based methods. Since API level 20 {@link TextToSpeech#setLanguage} is implemented by
+ * calling {@link TextToSpeech#setVoice} with the voice returned by
+ * {@link #onGetDefaultVoiceNameFor(String, String, String)}.
+ *
+ * If the client uses a voice instead of a locale, {@link SynthesisRequest} will contain the
+ * requested voice name.
+ *
+ * The default implementations of Voice-related methods implement them using the
+ * pre-existing locale-based implementation.
  */
 public abstract class TextToSpeechService extends Service {
 
@@ -228,6 +252,160 @@
         return null;
     }
 
+    private int getExpectedLanguageAvailableStatus(Locale locale) {
+        int expectedStatus = TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE;
+        if (locale.getVariant().isEmpty()) {
+            if (locale.getCountry().isEmpty()) {
+                expectedStatus = TextToSpeech.LANG_AVAILABLE;
+            } else {
+                expectedStatus = TextToSpeech.LANG_COUNTRY_AVAILABLE;
+            }
+        }
+        return expectedStatus;
+    }
+
+    /**
+     * Queries the service for a set of supported voices.
+     *
+     * Can be called on multiple threads.
+     *
+     * The default implementation tries to enumerate all available locales, pass them to
+     * {@link #onIsLanguageAvailable(String, String, String)} and create Voice instances (using
+     * the locale's BCP-47 language tag as the voice name) for the ones that are supported.
+     * Note, that this implementation is suitable only for engines that don't have multiple voices
+     * for a single locale. Also, this implementation won't work with Locales not listed in the
+     * set returned by the {@link Locale#getAvailableLocales()} method.
+     *
+     * @return A list of voices supported.
+     */
+    protected List<Voice> onGetVoices() {
+        // Enumerate all locales and check if they are available
+        ArrayList<Voice> voices = new ArrayList<Voice>();
+        for (Locale locale : Locale.getAvailableLocales()) {
+            int expectedStatus = getExpectedLanguageAvailableStatus(locale);
+            try {
+                int localeStatus = onIsLanguageAvailable(locale.getISO3Language(),
+                        locale.getISO3Country(), locale.getVariant());
+                if (localeStatus != expectedStatus) {
+                    continue;
+                }
+            } catch (MissingResourceException e) {
+                // Ignore locale without iso 3 codes
+                continue;
+            }
+            Set<String> features = onGetFeaturesForLanguage(locale.getISO3Language(),
+                    locale.getISO3Country(), locale.getVariant());
+            voices.add(new Voice(locale.toLanguageTag(), locale, Voice.QUALITY_NORMAL,
+                    Voice.LATENCY_NORMAL, false, features));
+        }
+        return voices;
+    }
+
+    /**
+     * Return a name of the default voice for a given locale.
+     *
+     * This method provides a mapping between locales and available voices. This method is
+     * used in {@link TextToSpeech#setLanguage}, which calls this method and then calls
+     * {@link TextToSpeech#setVoice} with the voice returned by this method.
+     *
+     * Also, it's used by {@link TextToSpeech#getDefaultVoice()} to find a default voice for
+     * the default locale.
+     *
+     * @param lang ISO-3 language code.
+     * @param country ISO-3 country code. May be empty or null.
+     * @param variant Language variant. May be empty or null.
+
+     * @return A name of the default voice for a given locale.
+     */
+    protected String onGetDefaultVoiceNameFor(String lang, String country, String variant) {
+        int localeStatus = onIsLanguageAvailable(lang, country, variant);
+        Locale iso3Locale = null;
+        switch (localeStatus) {
+            case TextToSpeech.LANG_AVAILABLE:
+                iso3Locale = new Locale(lang);
+                break;
+            case TextToSpeech.LANG_COUNTRY_AVAILABLE:
+                iso3Locale = new Locale(lang, country);
+                break;
+            case TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE:
+                iso3Locale = new Locale(lang, country, variant);
+                break;
+            default:
+                return null;
+        }
+        Locale properLocale = TtsEngines.normalizeTTSLocale(iso3Locale);
+        String voiceName = properLocale.toLanguageTag();
+        if (isValidVoiceName(voiceName) == TextToSpeech.SUCCESS) {
+            return voiceName;
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Notifies the engine that it should load a speech synthesis voice. There is no guarantee
+     * that this method is always called before the voice is used for synthesis. It is merely
+     * a hint to the engine that it will probably get some synthesis requests for this voice
+     * at some point in the future.
+     *
+     * Will be called only on synthesis thread.
+     *
+     * The default implementation creates a Locale from the voice name (by interpreting the name as
+     * a BCP-47 tag for the locale), and passes it to
+     * {@link #onLoadLanguage(String, String, String)}.
+     *
+     * @param voiceName Name of the voice.
+     * @return {@link TextToSpeech#ERROR} or {@link TextToSpeech#SUCCESS}.
+     */
+    protected int onLoadVoice(String voiceName) {
+        Locale locale = Locale.forLanguageTag(voiceName);
+        if (locale == null) {
+            return TextToSpeech.ERROR;
+        }
+        int expectedStatus = getExpectedLanguageAvailableStatus(locale);
+        try {
+            int localeStatus = onIsLanguageAvailable(locale.getISO3Language(),
+                    locale.getISO3Country(), locale.getVariant());
+            if (localeStatus != expectedStatus) {
+                return TextToSpeech.ERROR;
+            }
+            onLoadLanguage(locale.getISO3Language(),
+                    locale.getISO3Country(), locale.getVariant());
+            return TextToSpeech.SUCCESS;
+        } catch (MissingResourceException e) {
+            return TextToSpeech.ERROR;
+        }
+    }
+
+    /**
+     * Checks whether the engine supports a voice with a given name.
+     *
+     * Can be called on multiple threads.
+     *
+     * The default implementation treats the voice name as a language tag, creating a Locale from
+     * the voice name, and passes it to {@link #onIsLanguageAvailable(String, String, String)}.
+     *
+     * @param voiceName Name of the voice.
+     * @return {@link TextToSpeech#ERROR} or {@link TextToSpeech#SUCCESS}.
+     */
+    protected int isValidVoiceName(String voiceName) {
+        Locale locale = Locale.forLanguageTag(voiceName);
+        if (locale == null) {
+            return TextToSpeech.ERROR;
+        }
+        int expectedStatus = getExpectedLanguageAvailableStatus(locale);
+        try {
+            int localeStatus = onIsLanguageAvailable(locale.getISO3Language(),
+                    locale.getISO3Country(), locale.getVariant());
+            if (localeStatus != expectedStatus) {
+                return TextToSpeech.ERROR;
+            }
+            return TextToSpeech.SUCCESS;
+        } catch (MissingResourceException e) {
+            return TextToSpeech.ERROR;
+        }
+    }
+
     private int getDefaultSpeechRate() {
         return getSecureSettingInt(Settings.Secure.TTS_DEFAULT_RATE, Engine.DEFAULT_RATE);
     }
@@ -736,7 +914,11 @@
         }
 
         private void setRequestParams(SynthesisRequest request) {
+            String voiceName = getVoiceName();
             request.setLanguage(getLanguage(), getCountry(), getVariant());
+            if (!TextUtils.isEmpty(voiceName)) {
+                request.setVoiceName(getVoiceName());
+            }
             request.setSpeechRate(getSpeechRate());
             request.setCallerUid(mCallerUid);
             request.setPitch(getPitch());
@@ -770,6 +952,10 @@
         public String getLanguage() {
             return getStringParam(mParams, Engine.KEY_PARAM_LANGUAGE, mDefaultLocale[0]);
         }
+
+        public String getVoiceName() {
+            return getStringParam(mParams, Engine.KEY_PARAM_VOICE_NAME, "");
+        }
     }
 
     private class SynthesisToFileOutputStreamSpeechItemV1 extends SynthesisSpeechItemV1 {
@@ -896,6 +1082,35 @@
         }
     }
 
+    /**
+     * Call {@link TextToSpeechService#onLoadLanguage} on synth thread.
+     */
+    private class LoadVoiceItem extends SpeechItem {
+        private final String mVoiceName;
+
+        public LoadVoiceItem(Object callerIdentity, int callerUid, int callerPid,
+                String voiceName) {
+            super(callerIdentity, callerUid, callerPid);
+            mVoiceName = voiceName;
+        }
+
+        @Override
+        public boolean isValid() {
+            return true;
+        }
+
+        @Override
+        protected void playImpl() {
+            TextToSpeechService.this.onLoadVoice(mVoiceName);
+        }
+
+        @Override
+        protected void stopImpl() {
+            // No-op
+        }
+    }
+
+
     @Override
     public IBinder onBind(Intent intent) {
         if (TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE.equals(intent.getAction())) {
@@ -1042,6 +1257,44 @@
         }
 
         @Override
+        public List<Voice> getVoices() {
+            return onGetVoices();
+        }
+
+        @Override
+        public int loadVoice(IBinder caller, String voiceName) {
+            if (!checkNonNull(voiceName)) {
+                return TextToSpeech.ERROR;
+            }
+            int retVal = isValidVoiceName(voiceName);
+
+            if (retVal == TextToSpeech.SUCCESS) {
+                SpeechItem item = new LoadVoiceItem(caller, Binder.getCallingUid(),
+                        Binder.getCallingPid(), voiceName);
+                if (mSynthHandler.enqueueSpeechItem(TextToSpeech.QUEUE_ADD, item) !=
+                        TextToSpeech.SUCCESS) {
+                    return TextToSpeech.ERROR;
+                }
+            }
+            return retVal;
+        }
+
+        public String getDefaultVoiceNameFor(String lang, String country, String variant) {
+            if (!checkNonNull(lang)) {
+                return null;
+            }
+            int retVal = onIsLanguageAvailable(lang, country, variant);
+
+            if (retVal == TextToSpeech.LANG_AVAILABLE ||
+                    retVal == TextToSpeech.LANG_COUNTRY_AVAILABLE ||
+                    retVal == TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE) {
+                return onGetDefaultVoiceNameFor(lang, country, variant);
+            } else {
+                return null;
+            }
+        }
+
+        @Override
         public void setCallback(IBinder caller, ITextToSpeechCallback cb) {
             // Note that passing in a null callback is a valid use case.
             if (!checkNonNull(caller)) {
diff --git a/core/java/android/speech/tts/TtsEngines.java b/core/java/android/speech/tts/TtsEngines.java
index 7474efe..df6c010 100644
--- a/core/java/android/speech/tts/TtsEngines.java
+++ b/core/java/android/speech/tts/TtsEngines.java
@@ -427,6 +427,36 @@
     }
 
     /**
+     * This method tries its best to return a valid {@link Locale} object from the TTS-specific
+     * Locale input (returned by {@link TextToSpeech#getLanguage}
+     * and {@link TextToSpeech#getDefaultLanguage}). A TTS Locale language field contains
+     * a three-letter ISO 639-2/T code (where a proper Locale would use a two-letter ISO 639-1
+     * code), and the country field contains a three-letter ISO 3166 country code (where a proper
+     * Locale would use a two-letter ISO 3166-1 code).
+     *
+     * This method tries to convert three-letter language and country codes into their two-letter
+     * equivalents. If it fails to do so, it keeps the value from the TTS locale.
+     */
+    public static Locale normalizeTTSLocale(Locale ttsLocale) {
+        String language = ttsLocale.getLanguage();
+        if (!TextUtils.isEmpty(language)) {
+            String normalizedLanguage = sNormalizeLanguage.get(language);
+            if (normalizedLanguage != null) {
+                language = normalizedLanguage;
+            }
+        }
+
+        String country = ttsLocale.getCountry();
+        if (!TextUtils.isEmpty(country)) {
+            String normalizedCountry= sNormalizeCountry.get(country);
+            if (normalizedCountry != null) {
+                country = normalizedCountry;
+            }
+        }
+        return new Locale(language, country, ttsLocale.getVariant());
+    }
+
+    /**
      * Return the old-style string form of the locale. It consists of 3 letter codes:
      * <ul>
      *   <li>"ISO 639-2/T language code" if the locale has no country entry</li>
diff --git a/core/java/android/speech/tts/Voice.aidl b/core/java/android/speech/tts/Voice.aidl
new file mode 100644
index 0000000..ca51ff2
--- /dev/null
+++ b/core/java/android/speech/tts/Voice.aidl
@@ -0,0 +1,20 @@
+/*
+**
+** Copyright 2014, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+**     http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+** See the License for the specific language governing permissions and
+** limitations under the License.
+*/
+
+package android.speech.tts;
+
+parcelable Voice;
\ No newline at end of file
diff --git a/core/java/android/speech/tts/Voice.java b/core/java/android/speech/tts/Voice.java
new file mode 100644
index 0000000..a97141c
--- /dev/null
+++ b/core/java/android/speech/tts/Voice.java
@@ -0,0 +1,263 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package android.speech.tts;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Characteristics and features of a Text-To-Speech Voice. Each TTS Engine can expose
+ * multiple voices for each locale, with different set of features.
+ */
+public class Voice implements Parcelable {
+    /** Very low, but still intelligible quality of speech synthesis */
+    public static final int QUALITY_VERY_LOW = 100;
+
+    /** Low, not human-like quality of speech synthesis */
+    public static final int QUALITY_LOW = 200;
+
+    /** Normal quality of speech synthesis */
+    public static final int QUALITY_NORMAL = 300;
+
+    /** High, human-like quality of speech synthesis */
+    public static final int QUALITY_HIGH = 400;
+
+    /** Very high, almost human-indistinguishable quality of speech synthesis */
+    public static final int QUALITY_VERY_HIGH = 500;
+
+    /** Very low expected synthesizer latency (< 20ms) */
+    public static final int LATENCY_VERY_LOW = 100;
+
+    /** Low expected synthesizer latency (~20ms) */
+    public static final int LATENCY_LOW = 200;
+
+    /** Normal expected synthesizer latency (~50ms) */
+    public static final int LATENCY_NORMAL = 300;
+
+    /** Network based expected synthesizer latency (~200ms) */
+    public static final int LATENCY_HIGH = 400;
+
+    /** Very slow network based expected synthesizer latency (> 200ms) */
+    public static final int LATENCY_VERY_HIGH = 500;
+
+    private final String mName;
+    private final Locale mLocale;
+    private final int mQuality;
+    private final int mLatency;
+    private final boolean mRequiresNetworkConnection;
+    private final Set<String> mFeatures;
+
+    public Voice(String name,
+            Locale locale,
+            int quality,
+            int latency,
+            boolean requiresNetworkConnection,
+            Set<String> features) {
+        this.mName = name;
+        this.mLocale = locale;
+        this.mQuality = quality;
+        this.mLatency = latency;
+        this.mRequiresNetworkConnection = requiresNetworkConnection;
+        this.mFeatures = features;
+    }
+
+    private Voice(Parcel in) {
+        this.mName = in.readString();
+        this.mLocale = (Locale)in.readSerializable();
+        this.mQuality = in.readInt();
+        this.mLatency = in.readInt();
+        this.mRequiresNetworkConnection = (in.readByte() == 1);
+        this.mFeatures = new HashSet<String>();
+        Collections.addAll(this.mFeatures, in.readStringArray());
+    }
+
+    /**
+     * @hide
+     */
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(mName);
+        dest.writeSerializable(mLocale);
+        dest.writeInt(mQuality);
+        dest.writeInt(mLatency);
+        dest.writeByte((byte) (mRequiresNetworkConnection ? 1 : 0));
+        dest.writeStringList(new ArrayList<String>(mFeatures));
+    }
+
+    /**
+     * @hide
+     */
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    /**
+     * @hide
+     */
+    public static final Parcelable.Creator<Voice> CREATOR = new Parcelable.Creator<Voice>() {
+        @Override
+        public Voice createFromParcel(Parcel in) {
+            return new Voice(in);
+        }
+
+        @Override
+        public Voice[] newArray(int size) {
+            return new Voice[size];
+        }
+    };
+
+
+    /**
+     * @return The voice's locale
+     */
+    public Locale getLocale() {
+        return mLocale;
+    }
+
+    /**
+     * @return The voice's quality (higher is better)
+     * @see #QUALITY_VERY_HIGH
+     * @see #QUALITY_HIGH
+     * @see #QUALITY_NORMAL
+     * @see #QUALITY_LOW
+     * @see #QUALITY_VERY_LOW
+     */
+    public int getQuality() {
+        return mQuality;
+    }
+
+    /**
+     * @return The voice's latency (lower is better)
+     * @see #LATENCY_VERY_LOW
+     * @see #LATENCY_LOW
+     * @see #LATENCY_NORMAL
+     * @see #LATENCY_HIGH
+     * @see #LATENCY_VERY_HIGH
+     */
+    public int getLatency() {
+        return mLatency;
+    }
+
+    /**
+     * @return Does the Voice require a network connection to work.
+     */
+    public boolean getRequiresNetworkConnection() {
+        return mRequiresNetworkConnection;
+    }
+
+    /**
+     * @return Unique voice name.
+     */
+    public String getName() {
+        return mName;
+    }
+
+    /**
+     * Returns the set of features it supports for a given voice.
+     * Features can either be framework defined, e.g.
+     * {@link TextToSpeech.Engine#KEY_FEATURE_NETWORK_TIMEOUT_MS} or engine specific.
+     * Engine specific keys must be prefixed by the name of the engine they
+     * are intended for. These keys can be used as parameters to
+     * {@link TextToSpeech#speak(String, int, java.util.HashMap)} and
+     * {@link TextToSpeech#synthesizeToFile(String, java.util.HashMap, String)}.
+     *
+     * Features values are strings and their values must met restrictions described in their
+     * documentation.
+     *
+     * @return Set instance. May return {@code null} on error.
+     */
+    public Set<String> getFeatures() {
+        return mFeatures;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder builder = new StringBuilder(64);
+        return builder.append("Voice[Name: ").append(mName)
+                .append(", locale: ").append(mLocale)
+                .append(", quality: ").append(mQuality)
+                .append(", latency: ").append(mLatency)
+                .append(", requiresNetwork: ").append(mRequiresNetworkConnection)
+                .append(", features: ").append(mFeatures.toString())
+                .append("]").toString();
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        result = prime * result + ((mFeatures == null) ? 0 : mFeatures.hashCode());
+        result = prime * result + mLatency;
+        result = prime * result + ((mLocale == null) ? 0 : mLocale.hashCode());
+        result = prime * result + ((mName == null) ? 0 : mName.hashCode());
+        result = prime * result + mQuality;
+        result = prime * result + (mRequiresNetworkConnection ? 1231 : 1237);
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        Voice other = (Voice) obj;
+        if (mFeatures == null) {
+            if (other.mFeatures != null) {
+                return false;
+            }
+        } else if (!mFeatures.equals(other.mFeatures)) {
+            return false;
+        }
+        if (mLatency != other.mLatency) {
+            return false;
+        }
+        if (mLocale == null) {
+            if (other.mLocale != null) {
+                return false;
+            }
+        } else if (!mLocale.equals(other.mLocale)) {
+            return false;
+        }
+        if (mName == null) {
+            if (other.mName != null) {
+                return false;
+            }
+        } else if (!mName.equals(other.mName)) {
+            return false;
+        }
+        if (mQuality != other.mQuality) {
+            return false;
+        }
+        if (mRequiresNetworkConnection != other.mRequiresNetworkConnection) {
+            return false;
+        }
+        return true;
+    }
+}
diff --git a/tests/TtsTests/src/com/android/speech/tts/TtsEnginesTests.java b/tests/TtsTests/src/com/android/speech/tts/TtsEnginesTests.java
index 45e5216..3fbc44b 100644
--- a/tests/TtsTests/src/com/android/speech/tts/TtsEnginesTests.java
+++ b/tests/TtsTests/src/com/android/speech/tts/TtsEnginesTests.java
@@ -40,6 +40,19 @@
                 TtsEngines.toOldLocaleStringFormat(new Locale("foo")));
     }
 
+    public void testNormalizeLocale() {
+        assertEquals(Locale.UK,
+                TtsEngines.normalizeTTSLocale(new Locale("eng", "gbr")));
+        assertEquals(Locale.UK,
+                TtsEngines.normalizeTTSLocale(new Locale("eng", "GBR")));
+        assertEquals(Locale.GERMANY,
+                TtsEngines.normalizeTTSLocale(new Locale("deu", "deu")));
+        assertEquals(Locale.GERMAN,
+                TtsEngines.normalizeTTSLocale(new Locale("deu")));
+        assertEquals(new Locale("yyy", "DE"),
+                TtsEngines.normalizeTTSLocale(new Locale("yyy", "DE")));
+    }
+
     public void testGetLocalePrefForEngine() {
         assertEquals(new Locale("en", "US"),
                 mTtsHelper.getLocalePrefForEngine("foo","foo:en-US"));