Add UtteranceProgressListener#onStop callback

New UtteranceProgressListener callback that allows
to detect a call to TextToSpeech#stop() (or QUEUE_FLUSH usage)
from the same client, or a QUEUE_DESTROY usage from any other
client (Talkback uses it to preempt other users of TextToSpeech
queue). This change is required for seamless Books read aloud
feature+Talkback usage.

+ Fixes for broken tests/TtsTests

Bug: 17901521

Change-Id: I30d2f297bb7c8d05cbeb16f63e85c1be0cca5c84
diff --git a/api/current.txt b/api/current.txt
index 05aada4..b0517689 100644
--- a/api/current.txt
+++ b/api/current.txt
@@ -27752,6 +27752,7 @@
     method public abstract deprecated void onError(java.lang.String);
     method public void onError(java.lang.String, int);
     method public abstract void onStart(java.lang.String);
+    method public void onStop(java.lang.String, boolean);
   }
 
   public class Voice implements android.os.Parcelable {
diff --git a/core/java/android/speech/tts/ITextToSpeechCallback.aidl b/core/java/android/speech/tts/ITextToSpeechCallback.aidl
index 899515f..d785c3f 100644
--- a/core/java/android/speech/tts/ITextToSpeechCallback.aidl
+++ b/core/java/android/speech/tts/ITextToSpeechCallback.aidl
@@ -40,7 +40,7 @@
      *
      * @param utteranceId Unique id identifying synthesis request.
      */
-    void onStop(String utteranceId);
+    void onStop(String utteranceId, boolean isStarted);
 
     /**
      * Tells the client that the synthesis has failed.
diff --git a/core/java/android/speech/tts/TextToSpeech.java b/core/java/android/speech/tts/TextToSpeech.java
index c59ca8a..06e9ce0 100644
--- a/core/java/android/speech/tts/TextToSpeech.java
+++ b/core/java/android/speech/tts/TextToSpeech.java
@@ -2066,10 +2066,10 @@
         private boolean mEstablished;
 
         private final ITextToSpeechCallback.Stub mCallback = new ITextToSpeechCallback.Stub() {
-            public void onStop(String utteranceId) throws RemoteException {
+            public void onStop(String utteranceId, boolean isStarted) throws RemoteException {
                 UtteranceProgressListener listener = mUtteranceProgressListener;
                 if (listener != null) {
-                    listener.onDone(utteranceId);
+                    listener.onStop(utteranceId, isStarted);
                 }
             };
 
diff --git a/core/java/android/speech/tts/TextToSpeechService.java b/core/java/android/speech/tts/TextToSpeechService.java
index 9bb7f02..02c9a36 100644
--- a/core/java/android/speech/tts/TextToSpeechService.java
+++ b/core/java/android/speech/tts/TextToSpeechService.java
@@ -455,10 +455,37 @@
     private class SynthHandler extends Handler {
         private SpeechItem mCurrentSpeechItem = null;
 
+        private ArrayList<Object> mFlushedObjects = new ArrayList<Object>();
+        private boolean mFlushAll;
+
         public SynthHandler(Looper looper) {
             super(looper);
         }
 
+        private void startFlushingSpeechItems(Object callerIdentity) {
+            synchronized (mFlushedObjects) {
+                if (callerIdentity == null) {
+                    mFlushAll = true;
+                } else {
+                    mFlushedObjects.add(callerIdentity);
+                }
+            }
+        }
+        private void endFlushingSpeechItems(Object callerIdentity) {
+            synchronized (mFlushedObjects) {
+                if (callerIdentity == null) {
+                    mFlushAll = false;
+                } else {
+                    mFlushedObjects.remove(callerIdentity);
+                }
+            }
+        }
+        private boolean isFlushed(SpeechItem speechItem) {
+            synchronized (mFlushedObjects) {
+                return mFlushAll || mFlushedObjects.contains(speechItem.getCallerIdentity());
+            }
+        }
+
         private synchronized SpeechItem getCurrentSpeechItem() {
             return mCurrentSpeechItem;
         }
@@ -522,9 +549,13 @@
             Runnable runnable = new Runnable() {
                 @Override
                 public void run() {
-                    setCurrentSpeechItem(speechItem);
-                    speechItem.play();
-                    setCurrentSpeechItem(null);
+                    if (isFlushed(speechItem)) {
+                        speechItem.stop();
+                    } else {
+                        setCurrentSpeechItem(speechItem);
+                        speechItem.play();
+                        setCurrentSpeechItem(null);
+                    }
                 }
             };
             Message msg = Message.obtain(this, runnable);
@@ -552,12 +583,14 @@
          *
          * Called on a service binder thread.
          */
-        public int stopForApp(Object callerIdentity) {
+        public int stopForApp(final Object callerIdentity) {
             if (callerIdentity == null) {
                 return TextToSpeech.ERROR;
             }
 
-            removeCallbacksAndMessages(callerIdentity);
+            // Flush pending messages from callerIdentity
+            startFlushingSpeechItems(callerIdentity);
+
             // This stops writing data to the file / or publishing
             // items to the audio playback handler.
             //
@@ -573,20 +606,39 @@
             // Remove any enqueued audio too.
             mAudioPlaybackHandler.stopForApp(callerIdentity);
 
+            // Stop flushing pending messages
+            Runnable runnable = new Runnable() {
+                @Override
+                public void run() {
+                    endFlushingSpeechItems(callerIdentity);
+                }
+            };
+            sendMessage(Message.obtain(this, runnable));
             return TextToSpeech.SUCCESS;
         }
 
         public int stopAll() {
+            // Order to flush pending messages
+            startFlushingSpeechItems(null);
+
             // Stop the current speech item unconditionally .
             SpeechItem current = setCurrentSpeechItem(null);
             if (current != null) {
                 current.stop();
             }
-            // Remove all other items from the queue.
-            removeCallbacksAndMessages(null);
             // Remove all pending playback as well.
             mAudioPlaybackHandler.stop();
 
+            // Message to stop flushing pending messages
+            Runnable runnable = new Runnable() {
+                @Override
+                public void run() {
+                    endFlushingSpeechItems(null);
+                }
+            };
+            sendMessage(Message.obtain(this, runnable));
+
+
             return TextToSpeech.SUCCESS;
         }
     }
@@ -698,7 +750,6 @@
             return mCallerIdentity;
         }
 
-
         public int getCallerUid() {
             return mCallerUid;
         }
@@ -752,6 +803,10 @@
         protected synchronized boolean isStopped() {
              return mStopped;
         }
+
+        protected synchronized boolean isStarted() {
+            return mStarted;
+       }
     }
 
     /**
@@ -777,7 +832,7 @@
         public void dispatchOnStop() {
             final String utteranceId = getUtteranceId();
             if (utteranceId != null) {
-                mCallbacks.dispatchOnStop(getCallerIdentity(), utteranceId);
+                mCallbacks.dispatchOnStop(getCallerIdentity(), utteranceId, isStarted());
             }
         }
 
@@ -940,6 +995,8 @@
                 // turn implies that synthesis would not have started.
                 synthesisCallback.stop();
                 TextToSpeechService.this.onStop();
+            } else {
+                dispatchOnStop();
             }
         }
 
@@ -1345,11 +1402,11 @@
             }
         }
 
-        public void dispatchOnStop(Object callerIdentity, String utteranceId) {
+        public void dispatchOnStop(Object callerIdentity, String utteranceId, boolean started) {
             ITextToSpeechCallback cb = getCallbackFor(callerIdentity);
             if (cb == null) return;
             try {
-                cb.onStop(utteranceId);
+                cb.onStop(utteranceId, started);
             } catch (RemoteException e) {
                 Log.e(TAG, "Callback onStop failed: " + e);
             }
diff --git a/core/java/android/speech/tts/UtteranceProgressListener.java b/core/java/android/speech/tts/UtteranceProgressListener.java
index 6769794..9eb22ef 100644
--- a/core/java/android/speech/tts/UtteranceProgressListener.java
+++ b/core/java/android/speech/tts/UtteranceProgressListener.java
@@ -60,6 +60,20 @@
     }
 
     /**
+     * Called when an utterance has been stopped while in progress or flushed from the
+     * synthesis queue. This can happen if client calls {@link TextToSpeech#stop()}
+     * or use {@link TextToSpeech#QUEUE_FLUSH} as an argument in
+     * {@link TextToSpeech#speak} or {@link TextToSpeech#synthesizeToFile} methods.
+     *
+     * @param utteranceId the utterance ID of the utterance.
+     * @param isStarted If true, then utterance was interrupted while being synthesized
+     *        and it's output is incomplete. If it's false, then utterance was flushed
+     *        before the synthesis started.
+     */
+    public void onStop(String utteranceId, boolean isStarted) {
+    }
+
+    /**
      * Wraps an old deprecated OnUtteranceCompletedListener with a shiny new
      * progress listener.
      *
@@ -83,6 +97,11 @@
                 // Left unimplemented, has no equivalent in the old
                 // API.
             }
+
+            @Override
+            public void onStop(String utteranceId, boolean isStarted) {
+                listener.onUtteranceCompleted(utteranceId);
+            }
         };
     }
 }
diff --git a/tests/TtsTests/src/com/android/speech/tts/MockableTextToSpeechService.java b/tests/TtsTests/src/com/android/speech/tts/MockableTextToSpeechService.java
index 20648a4..06fbcdc 100644
--- a/tests/TtsTests/src/com/android/speech/tts/MockableTextToSpeechService.java
+++ b/tests/TtsTests/src/com/android/speech/tts/MockableTextToSpeechService.java
@@ -19,8 +19,10 @@
 import android.speech.tts.SynthesisCallback;
 import android.speech.tts.SynthesisRequest;
 import android.speech.tts.TextToSpeechService;
+import android.util.Log;
 
 import java.util.ArrayList;
+import java.util.logging.Logger;
 
 public class MockableTextToSpeechService extends TextToSpeechService {
 
diff --git a/tests/TtsTests/src/com/android/speech/tts/TextToSpeechTests.java b/tests/TtsTests/src/com/android/speech/tts/TextToSpeechTests.java
index 78d4f966..faf6827 100644
--- a/tests/TtsTests/src/com/android/speech/tts/TextToSpeechTests.java
+++ b/tests/TtsTests/src/com/android/speech/tts/TextToSpeechTests.java
@@ -43,11 +43,18 @@
         IDelegate passThrough = LittleMock.mock(IDelegate.class);
         MockableTextToSpeechService.setMocker(passThrough);
 
+        // For the default voice selection
+        LittleMock.doReturn(TextToSpeech.LANG_COUNTRY_AVAILABLE).when(passThrough)
+            .onIsLanguageAvailable(
+                    LittleMock.anyString(), LittleMock.anyString(), LittleMock.anyString());
+        LittleMock.doReturn(TextToSpeech.LANG_COUNTRY_AVAILABLE).when(passThrough)
+            .onLoadLanguage(
+                    LittleMock.anyString(), LittleMock.anyString(), LittleMock.anyString());
+
         blockingInitAndVerify(MOCK_ENGINE, TextToSpeech.SUCCESS);
         assertEquals(MOCK_ENGINE, mTts.getCurrentEngine());
     }
 
-
     @Override
     public void tearDown() {
         if (mTts != null) {
@@ -77,7 +84,7 @@
         assertEquals(TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE, mTts.setLanguage(new Locale("eng", "USA", "variant")));
         LittleMock.verify(delegate, LittleMock.anyTimes()).onIsLanguageAvailable(
             "eng", "USA", "variant");
-        LittleMock.verify(delegate, LittleMock.times(1)).onLoadLanguage(
+        LittleMock.verify(delegate, LittleMock.anyTimes()).onLoadLanguage(
             "eng", "USA", "variant");
     }
 
@@ -147,26 +154,34 @@
     public void testDefaultLanguage_setsVoiceName() throws Exception {
         IDelegate delegate = LittleMock.mock(IDelegate.class);
         MockableTextToSpeechService.setMocker(delegate);
+        Locale defaultLocale = Locale.getDefault();
 
         // ---------------------------------------------------------
         // Test that default language also sets the default voice
         // name
-        LittleMock.doReturn(TextToSpeech.LANG_COUNTRY_AVAILABLE).when(delegate).onIsLanguageAvailable(
-            LittleMock.anyString(), LittleMock.anyString(), LittleMock.anyString());
-        LittleMock.doReturn(TextToSpeech.LANG_COUNTRY_AVAILABLE).when(delegate).onLoadLanguage(
-            LittleMock.anyString(), LittleMock.anyString(), LittleMock.anyString());
+        LittleMock.doReturn(TextToSpeech.LANG_COUNTRY_AVAILABLE).
+            when(delegate).onIsLanguageAvailable(
+                defaultLocale.getISO3Language(),
+                defaultLocale.getISO3Country().toUpperCase(),
+                defaultLocale.getVariant());
+        LittleMock.doReturn(TextToSpeech.LANG_COUNTRY_AVAILABLE).
+            when(delegate).onLoadLanguage(
+                defaultLocale.getISO3Language(),
+                defaultLocale.getISO3Country(),
+                defaultLocale.getVariant());
+
         blockingCallSpeak("foo bar", delegate);
         ArgumentCaptor<SynthesisRequest> req = LittleMock.createCaptor();
         LittleMock.verify(delegate, LittleMock.times(1)).onSynthesizeText(req.capture(),
                 LittleMock.<SynthesisCallback>anyObject());
 
-        Locale defaultLocale = Locale.getDefault();
         assertEquals(defaultLocale.getISO3Language(), req.getValue().getLanguage());
         assertEquals(defaultLocale.getISO3Country(), req.getValue().getCountry());
         assertEquals("", req.getValue().getVariant());
         assertEquals(defaultLocale.toLanguageTag(), req.getValue().getVoiceName());
     }
 
+
     private void blockingCallSpeak(String speech, IDelegate mock) throws
             InterruptedException {
         final CountDownLatch latch = new CountDownLatch(1);