diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index aa785952a..8fe7c3f7e 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -35,6 +35,9 @@
+
+
+
.
+ ******************************************************************************/
+
+package org.quantumbadger.redreader.audio;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.speech.tts.TextToSpeech;
+import android.speech.tts.UtteranceProgressListener;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayDeque;
+import java.util.List;
+import java.util.Locale;
+import java.util.Queue;
+
+/**
+ * Reads a list of text items aloud using the system text-to-speech engine.
+ * All methods must be called on the main thread; listener callbacks are also delivered there.
+ */
+public class NativeTTSManager {
+
+ private static final String TAG = "NativeTTSManager";
+
+ private static NativeTTSManager sInstance;
+
+ private final Context mContext;
+ private final Handler mHandler = new Handler(Looper.getMainLooper());
+ private final Queue mPendingItems = new ArrayDeque<>();
+
+ @Nullable private TextToSpeech mTTS;
+ private boolean mIsInitialized;
+ private boolean mIsSpeaking;
+ private float mSpeechRate = 1f;
+
+ // Incremented on every stop/start so that late callbacks from old utterances are ignored
+ private int mGeneration;
+
+ @Nullable private Listener mListener;
+
+ public static final class TTSItem {
+ public final String text;
+ public final int position;
+
+ public TTSItem(final String text, final int position) {
+ this.text = text;
+ this.position = position;
+ }
+ }
+
+ public interface Listener {
+ void onTTSStateChanged(boolean isSpeaking);
+
+ /** Called when an item with a non-negative position starts being spoken. */
+ void onUtteranceStarted(int position);
+
+ void onTTSError();
+ }
+
+ private NativeTTSManager(final Context context) {
+ mContext = context.getApplicationContext();
+ }
+
+ public static synchronized NativeTTSManager getInstance(final Context context) {
+ if(sInstance == null) {
+ sInstance = new NativeTTSManager(context);
+ }
+ return sInstance;
+ }
+
+ public void setListener(@Nullable final Listener listener) {
+ mListener = listener;
+ if(listener != null) {
+ listener.onTTSStateChanged(mIsSpeaking);
+ }
+ }
+
+ /** Removes the listener, only if it is still the active one, and stops speaking. */
+ public void clearListener(final Listener listener) {
+ if(mListener == listener) {
+ mListener = null;
+ stop();
+ }
+ }
+
+ public boolean isSpeaking() {
+ return mIsSpeaking;
+ }
+
+ public void setSpeed(final float speed) {
+ mSpeechRate = speed;
+ if(mTTS != null && mIsInitialized) {
+ mTTS.setSpeechRate(speed);
+ }
+ }
+
+ public void readAloud(@Nullable final List items) {
+
+ stopInternal();
+
+ if(items != null) {
+ for(final TTSItem item : items) {
+ if(item != null && !item.text.trim().isEmpty()) {
+ mPendingItems.add(item);
+ }
+ }
+ }
+
+ if(mPendingItems.isEmpty()) {
+ notifyState();
+ return;
+ }
+
+ mIsSpeaking = true;
+ notifyState();
+
+ if(mTTS == null) {
+ initialize();
+ } else if(mIsInitialized) {
+ speakNext();
+ }
+ }
+
+ public void stop() {
+ final boolean wasSpeaking = mIsSpeaking;
+ stopInternal();
+ if(wasSpeaking) {
+ notifyState();
+ }
+ }
+
+ private void stopInternal() {
+ mGeneration++;
+ mPendingItems.clear();
+ mIsSpeaking = false;
+ if(mTTS != null && mIsInitialized) {
+ mTTS.stop();
+ }
+ }
+
+ private void initialize() {
+
+ final TextToSpeech tts = new TextToSpeech(
+ mContext,
+ status -> mHandler.post(() -> onInitialized(status)));
+
+ tts.setOnUtteranceProgressListener(new UtteranceProgressListener() {
+ @Override
+ public void onStart(final String utteranceId) {
+ mHandler.post(() -> onUtteranceStart(utteranceId));
+ }
+
+ @Override
+ public void onDone(final String utteranceId) {
+ mHandler.post(() -> onUtteranceEnd(utteranceId, false));
+ }
+
+ @Override
+ public void onError(final String utteranceId) {
+ mHandler.post(() -> onUtteranceEnd(utteranceId, true));
+ }
+ });
+
+ mTTS = tts;
+ }
+
+ private void onInitialized(final int status) {
+
+ final TextToSpeech tts = mTTS;
+
+ if(tts == null) {
+ return;
+ }
+
+ if(status != TextToSpeech.SUCCESS || tts.setLanguage(Locale.getDefault()) < 0) {
+ Log.e(TAG, "TTS initialization failed (status " + status + ")");
+ tts.shutdown();
+ mTTS = null;
+ mIsInitialized = false;
+ fail();
+ return;
+ }
+
+ mIsInitialized = true;
+ tts.setSpeechRate(mSpeechRate);
+
+ // The user may have stopped or restarted playback while the engine was starting
+ if(mIsSpeaking) {
+ speakNext();
+ }
+ }
+
+ private void speakNext() {
+
+ final TextToSpeech tts = mTTS;
+ final TTSItem item = mPendingItems.poll();
+
+ if(item == null || tts == null) {
+ mIsSpeaking = false;
+ notifyState();
+ return;
+ }
+
+ // Engines reject input over a maximum length, so split long items up
+ final int limit = TextToSpeech.getMaxSpeechInputLength();
+ final String text = item.text;
+ int start = 0;
+ boolean first = true;
+
+ while(start < text.length()) {
+
+ int end = Math.min(start + limit, text.length());
+
+ // Don't split a surrogate pair
+ if(end < text.length() && Character.isHighSurrogate(text.charAt(end - 1))) {
+ end--;
+ }
+
+ final int result = tts.speak(
+ text.substring(start, end),
+ first ? TextToSpeech.QUEUE_FLUSH : TextToSpeech.QUEUE_ADD,
+ null,
+ utteranceId(item.position, end >= text.length(), first));
+
+ if(result != TextToSpeech.SUCCESS) {
+ Log.e(TAG, "TTS speak() failed with code " + result);
+ fail();
+ return;
+ }
+
+ first = false;
+ start = end;
+ }
+ }
+
+ // Format: generation:position:isLastChunk:isFirstChunk
+ private String utteranceId(final int position, final boolean last, final boolean first) {
+ return mGeneration + ":" + position + ":" + (last ? 1 : 0) + ":" + (first ? 1 : 0);
+ }
+
+ private void onUtteranceStart(final String utteranceId) {
+
+ final String[] parts = utteranceId.split(":");
+
+ if(parts.length != 4 || Integer.parseInt(parts[0]) != mGeneration) {
+ return;
+ }
+
+ final int position = Integer.parseInt(parts[1]);
+
+ if(parts[3].equals("1") && position >= 0 && mListener != null) {
+ mListener.onUtteranceStarted(position);
+ }
+ }
+
+ private void onUtteranceEnd(final String utteranceId, final boolean error) {
+
+ final String[] parts = utteranceId.split(":");
+
+ if(parts.length != 4 || Integer.parseInt(parts[0]) != mGeneration) {
+ return;
+ }
+
+ if(error) {
+ fail();
+
+ } else if(parts[2].equals("1")) {
+ speakNext();
+ }
+ }
+
+ private void fail() {
+ stopInternal();
+ notifyState();
+ if(mListener != null) {
+ mListener.onTTSError();
+ }
+ }
+
+ private void notifyState() {
+ if(mListener != null) {
+ mListener.onTTSStateChanged(mIsSpeaking);
+ }
+ }
+}
diff --git a/src/main/java/org/quantumbadger/redreader/fragments/CommentListingFragment.java b/src/main/java/org/quantumbadger/redreader/fragments/CommentListingFragment.java
index 7e8ec428a..afdf2bc1d 100644
--- a/src/main/java/org/quantumbadger/redreader/fragments/CommentListingFragment.java
+++ b/src/main/java/org/quantumbadger/redreader/fragments/CommentListingFragment.java
@@ -36,6 +36,7 @@
import android.widget.LinearLayout;
import android.widget.TextView;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.OneShotPreDrawListener;
@@ -49,6 +50,7 @@
import org.quantumbadger.redreader.activities.OptionsMenuUtility;
import org.quantumbadger.redreader.adapters.FilteredCommentListingManager;
import org.quantumbadger.redreader.adapters.GroupedRecyclerViewAdapter;
+import org.quantumbadger.redreader.audio.NativeTTSManager;
import org.quantumbadger.redreader.cache.downloadstrategy.DownloadStrategy;
import org.quantumbadger.redreader.cache.downloadstrategy.DownloadStrategyAlways;
import org.quantumbadger.redreader.cache.downloadstrategy.DownloadStrategyIfNotCached;
@@ -65,6 +67,7 @@
import org.quantumbadger.redreader.reddit.api.RedditAPICommentAction;
import org.quantumbadger.redreader.reddit.api.RedditPostActions;
import org.quantumbadger.redreader.reddit.prepared.RedditChangeDataManager;
+import org.quantumbadger.redreader.reddit.prepared.RedditParsedComment;
import org.quantumbadger.redreader.reddit.prepared.RedditPreparedPost;
import org.quantumbadger.redreader.reddit.prepared.RedditRenderableComment;
import org.quantumbadger.redreader.reddit.url.RedditURLParser;
@@ -80,6 +83,7 @@
import java.util.ArrayList;
import java.util.LinkedList;
+import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -117,6 +121,8 @@ public class CommentListingFragment extends RRFragment
private Integer mPreviousFirstVisibleItemPosition;
private int mParentJumpCount = 0;
+ private @Nullable NativeTTSManager.Listener mTTSListener;
+
public CommentListingFragment(
final AppCompatActivity parent,
final Bundle savedInstanceState,
@@ -185,6 +191,20 @@ public CommentListingFragment(
mRecyclerView.setAdapter(mCommentListingManager.getAdapter());
mListingView = recyclerViewManager.getOuterView();
+ mListingView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(@NonNull final View v) {
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(@NonNull final View v) {
+ if(mTTSListener != null) {
+ NativeTTSManager.getInstance(v.getContext()).clearListener(mTTSListener);
+ mTTSListener = null;
+ }
+ }
+ });
+
mRecyclerView.setItemAnimator(null);
if(!PrefsUtility.pref_appearance_comments_show_floating_toolbar()) {
@@ -270,6 +290,30 @@ public CommentListingFragment(
return true;
});
}
+
+ {
+ final ImageButton ttsButton = (ImageButton)LayoutInflater.from(context)
+ .inflate(
+ R.layout.flat_image_button,
+ mFloatingToolbar,
+ false);
+
+ ttsButton.setPadding(
+ buttonHPadding,
+ buttonVPadding,
+ buttonHPadding,
+ buttonVPadding);
+ ttsButton.setImageResource(R.drawable.icon_play);
+ ttsButton.setContentDescription(getString(R.string.action_read_aloud));
+ mFloatingToolbar.addView(ttsButton);
+
+ ttsButton.setOnClickListener(view -> toggleReadAloud(ttsButton));
+
+ ttsButton.setOnLongClickListener(view -> {
+ General.quickToast(context, ttsButton.getContentDescription().toString());
+ return true;
+ });
+ }
}
final SideToolbarOverlay toolbarOverlay = new SideToolbarOverlay(context);
@@ -822,4 +866,112 @@ private void setAccessibilityFocusAfterLayout(
}
});
}
+
+ private void toggleReadAloud(final ImageButton ttsButton) {
+
+ final NativeTTSManager tts = NativeTTSManager.getInstance(ttsButton.getContext());
+
+ if(tts.isSpeaking()) {
+ tts.stop();
+ return;
+ }
+
+ if(mTTSListener != null) {
+ tts.clearListener(mTTSListener);
+ }
+
+ mTTSListener = new NativeTTSManager.Listener() {
+ @Override
+ public void onTTSStateChanged(final boolean isSpeaking) {
+ ttsButton.setImageResource(isSpeaking
+ ? R.drawable.icon_pause
+ : R.drawable.icon_play);
+ ttsButton.setContentDescription(getString(isSpeaking
+ ? R.string.action_stop_reading
+ : R.string.action_read_aloud));
+ }
+
+ @Override
+ public void onUtteranceStarted(final int position) {
+ final LinearLayoutManager layoutManager
+ = (LinearLayoutManager)mRecyclerView.getLayoutManager();
+ if(layoutManager != null && position < layoutManager.getItemCount()) {
+ layoutManager.scrollToPositionWithOffset(position, 0);
+ }
+ }
+
+ @Override
+ public void onTTSError() {
+ General.quickToast(ttsButton.getContext(), R.string.tts_error);
+ }
+ };
+
+ tts.setListener(mTTSListener);
+ tts.readAloud(getReadAloudItems());
+ }
+
+ private List getReadAloudItems() {
+
+ final List items = new ArrayList<>();
+
+ final LinearLayoutManager layoutManager
+ = (LinearLayoutManager)mRecyclerView.getLayoutManager();
+
+ int startIndex = 0;
+
+ if(layoutManager != null) {
+ final int firstVisible = layoutManager.findFirstVisibleItemPosition();
+ if(firstVisible != RecyclerView.NO_POSITION) {
+ startIndex = firstVisible;
+ }
+ }
+
+ // The post is only read if the user is at the top of the listing
+ if(startIndex == 0 && mPost != null && mPost.src != null) {
+
+ final String title = mPost.src.getTitle();
+ if(title != null) {
+ items.add(new NativeTTSManager.TTSItem(title, -1));
+ }
+
+ final String selfText = mPost.src.getRawSelfTextMarkdown();
+ if(selfText != null) {
+ items.add(new NativeTTSManager.TTSItem(selfText, -1));
+ }
+ }
+
+ final RedditChangeDataManager changeDataManager
+ = RedditChangeDataManager.getInstance(mUser);
+
+ final int itemCount = mCommentListingManager.getAdapter().getItemCount();
+
+ for(int i = startIndex; i < itemCount; i++) {
+
+ final GroupedRecyclerViewAdapter.Item item
+ = mCommentListingManager.getItemAtPosition(i);
+
+ if(!(item instanceof RedditCommentListItem)
+ || !((RedditCommentListItem)item).isComment()) {
+ continue;
+ }
+
+ final RedditRenderableComment comment = ((RedditCommentListItem)item).asComment();
+
+ if(comment.isCollapsed(changeDataManager)) {
+ continue;
+ }
+
+ final RedditParsedComment parsed = comment.getParsedComment();
+
+ if(parsed != null
+ && parsed.getRawComment() != null
+ && parsed.getRawComment().getBody() != null) {
+ items.add(new NativeTTSManager.TTSItem(
+ parsed.getRawComment().getBody().getDecoded(),
+ i));
+ }
+ }
+
+ return items;
+ }
}
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index dc78648de..ca36f725c 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -1996,4 +1996,8 @@
Navigation bar opacity
Automatic
+
+ Read aloud
+ Stop reading
+ Unable to read aloud. Check your text-to-speech settings.
diff --git a/src/test/java/org/quantumbadger/redreader/test/audio/NativeTTSManagerTest.java b/src/test/java/org/quantumbadger/redreader/test/audio/NativeTTSManagerTest.java
new file mode 100644
index 000000000..37e98c1ba
--- /dev/null
+++ b/src/test/java/org/quantumbadger/redreader/test/audio/NativeTTSManagerTest.java
@@ -0,0 +1,465 @@
+/*******************************************************************************
+ * This file is part of RedReader.
+ *
+ * RedReader is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * RedReader is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with RedReader. If not, see .
+ ******************************************************************************/
+
+package org.quantumbadger.redreader.test.audio;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.Looper;
+import android.speech.tts.TextToSpeech;
+import android.speech.tts.UtteranceProgressListener;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.quantumbadger.redreader.audio.NativeTTSManager;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 35, shadows = {NativeTTSManagerTest.TestTTS.class})
+@LooperMode(LooperMode.Mode.PAUSED)
+public class NativeTTSManagerTest {
+
+ private static final int MAX_LENGTH = 4000;
+
+ private NativeTTSManager mManager;
+ private RecordingListener mListener;
+
+ @Before
+ public void setUp() throws Exception {
+
+ TestTTS.reset();
+
+ // Each test needs a fresh manager, and therefore a fresh (fake) engine
+ final Field singleton = NativeTTSManager.class.getDeclaredField("sInstance");
+ singleton.setAccessible(true);
+ singleton.set(null, null);
+
+ mManager = NativeTTSManager.getInstance(RuntimeEnvironment.getApplication());
+ mListener = new RecordingListener();
+ mManager.setListener(mListener);
+ }
+
+ private void read(final String... texts) {
+
+ final List items = new ArrayList<>();
+
+ for(int i = 0; i < texts.length; i++) {
+ items.add(new NativeTTSManager.TTSItem(texts[i], i));
+ }
+
+ mManager.readAloud(items);
+ }
+
+ private static void idle() {
+ Shadows.shadowOf(Looper.getMainLooper()).idle();
+ }
+
+ private static void finishUtterance(final int index) {
+ TestTTS.sProgress.onDone(TestTTS.sIds.get(index));
+ idle();
+ }
+
+ @Test
+ public void testNothingSpokenUntilEngineInitialized() {
+
+ read("one");
+
+ Assert.assertTrue(mManager.isSpeaking());
+ Assert.assertTrue(TestTTS.sSpoken.isEmpty());
+
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertEquals(Arrays.asList("one"), TestTTS.sSpoken);
+ }
+
+ @Test
+ public void testItemsSpokenInOrderOneAtATime() {
+
+ read("one", "two", "three");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertEquals(Arrays.asList("one"), TestTTS.sSpoken);
+
+ finishUtterance(0);
+ Assert.assertEquals(Arrays.asList("one", "two"), TestTTS.sSpoken);
+
+ finishUtterance(1);
+ Assert.assertEquals(Arrays.asList("one", "two", "three"), TestTTS.sSpoken);
+ Assert.assertTrue(mManager.isSpeaking());
+
+ finishUtterance(2);
+ Assert.assertFalse(mManager.isSpeaking());
+ Assert.assertEquals(Boolean.FALSE, mListener.mLastState);
+ }
+
+ @Test
+ public void testBlankItemsAreSkipped() {
+
+ read("one", " ", "", "two");
+ TestTTS.init(TextToSpeech.SUCCESS);
+ finishUtterance(0);
+
+ Assert.assertEquals(Arrays.asList("one", "two"), TestTTS.sSpoken);
+ }
+
+ @Test
+ public void testOnlyBlankItemsDoesNotStartSpeaking() {
+
+ read(" ", "");
+
+ Assert.assertFalse(mManager.isSpeaking());
+ Assert.assertNull(TestTTS.sInitListener);
+ }
+
+ @Test
+ public void testNullItemsStops() {
+
+ read("one");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ mManager.readAloud(null);
+
+ Assert.assertFalse(mManager.isSpeaking());
+ }
+
+ @Test
+ public void testLongItemIsSplitAndNextItemWaitsForLastChunk() {
+
+ final String text = "a".repeat(MAX_LENGTH * 2 + 10);
+
+ read(text, "next");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertEquals(3, TestTTS.sSpoken.size());
+ Assert.assertEquals(text, String.join("", TestTTS.sSpoken));
+
+ for(final String chunk : TestTTS.sSpoken) {
+ Assert.assertTrue(chunk.length() <= MAX_LENGTH);
+ }
+
+ Assert.assertEquals(TextToSpeech.QUEUE_FLUSH, (int)TestTTS.sQueueModes.get(0));
+ Assert.assertEquals(TextToSpeech.QUEUE_ADD, (int)TestTTS.sQueueModes.get(1));
+
+ // Finishing the earlier chunks must not move on to the next item
+ finishUtterance(0);
+ finishUtterance(1);
+ Assert.assertEquals(3, TestTTS.sSpoken.size());
+
+ finishUtterance(2);
+ Assert.assertEquals("next", TestTTS.sSpoken.get(3));
+ }
+
+ @Test
+ public void testSurrogatePairsAreNotSplit() {
+
+ final String text = "a".repeat(MAX_LENGTH - 1) + "😀" + "end";
+
+ read(text);
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertEquals(2, TestTTS.sSpoken.size());
+ Assert.assertEquals(text, String.join("", TestTTS.sSpoken));
+
+ for(final String chunk : TestTTS.sSpoken) {
+ Assert.assertFalse(Character.isHighSurrogate(chunk.charAt(chunk.length() - 1)));
+ }
+ }
+
+ @Test
+ public void testPositionReportedOncePerItem() {
+
+ final List items = new ArrayList<>();
+ items.add(new NativeTTSManager.TTSItem("title", -1));
+ items.add(new NativeTTSManager.TTSItem("a".repeat(MAX_LENGTH + 1), 7));
+
+ mManager.readAloud(items);
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ TestTTS.sProgress.onStart(TestTTS.sIds.get(0));
+ idle();
+ Assert.assertTrue(mListener.mPositions.isEmpty());
+
+ finishUtterance(0);
+
+ TestTTS.sProgress.onStart(TestTTS.sIds.get(1));
+ TestTTS.sProgress.onStart(TestTTS.sIds.get(2));
+ idle();
+
+ Assert.assertEquals(Arrays.asList(7), mListener.mPositions);
+ }
+
+ @Test
+ public void testStopSilencesAndIgnoresLateCallbacks() {
+
+ read("one", "two");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ mManager.stop();
+
+ Assert.assertFalse(mManager.isSpeaking());
+ Assert.assertEquals(Boolean.FALSE, mListener.mLastState);
+ Assert.assertEquals(1, TestTTS.sStops);
+
+ // The engine may still report the old utterance finishing after stop()
+ finishUtterance(0);
+ TestTTS.sProgress.onStart(TestTTS.sIds.get(0));
+ idle();
+
+ Assert.assertEquals(Arrays.asList("one"), TestTTS.sSpoken);
+ Assert.assertTrue(mListener.mPositions.isEmpty());
+ }
+
+ @Test
+ public void testRestartIgnoresCallbacksFromPreviousRun() {
+
+ read("old one", "old two");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ read("new one");
+ Assert.assertEquals(Arrays.asList("old one", "new one"), TestTTS.sSpoken);
+
+ finishUtterance(0);
+
+ Assert.assertEquals(Arrays.asList("old one", "new one"), TestTTS.sSpoken);
+ Assert.assertTrue(mManager.isSpeaking());
+ }
+
+ @Test
+ public void testStopBeforeInitializationDoesNotSpeakLater() {
+
+ read("one");
+ mManager.stop();
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertTrue(TestTTS.sSpoken.isEmpty());
+ Assert.assertFalse(mManager.isSpeaking());
+ }
+
+ @Test
+ public void testInitializationFailureReportsErrorAndAllowsRetry() {
+
+ read("one");
+ TestTTS.init(TextToSpeech.ERROR);
+
+ Assert.assertFalse(mManager.isSpeaking());
+ Assert.assertEquals(1, mListener.mErrors);
+ Assert.assertEquals(1, TestTTS.sShutdowns);
+
+ read("one");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertEquals(Arrays.asList("one"), TestTTS.sSpoken);
+ }
+
+ @Test
+ public void testUnsupportedLanguageReportsError() {
+
+ TestTTS.sLanguage = TextToSpeech.LANG_NOT_SUPPORTED;
+
+ read("one");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertFalse(mManager.isSpeaking());
+ Assert.assertEquals(1, mListener.mErrors);
+ }
+
+ @Test
+ public void testSpeakFailureReportsError() {
+
+ TestTTS.sSpeakResult = TextToSpeech.ERROR;
+
+ read("one", "two");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertFalse(mManager.isSpeaking());
+ Assert.assertEquals(1, mListener.mErrors);
+ Assert.assertEquals(Arrays.asList("one"), TestTTS.sSpoken);
+ }
+
+ @Test
+ public void testEngineErrorCallbackReportsError() {
+
+ read("one", "two");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ TestTTS.sProgress.onError(TestTTS.sIds.get(0));
+ idle();
+
+ Assert.assertFalse(mManager.isSpeaking());
+ Assert.assertEquals(1, mListener.mErrors);
+ Assert.assertEquals(Arrays.asList("one"), TestTTS.sSpoken);
+ }
+
+ @Test
+ public void testClearingActiveListenerStopsSpeech() {
+
+ read("one");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ mManager.clearListener(mListener);
+
+ Assert.assertFalse(mManager.isSpeaking());
+ }
+
+ @Test
+ public void testClearingStaleListenerKeepsSpeaking() {
+
+ read("one");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ final RecordingListener replacement = new RecordingListener();
+ mManager.setListener(replacement);
+ mManager.clearListener(mListener);
+
+ Assert.assertTrue(mManager.isSpeaking());
+ }
+
+ @Test
+ public void testSpeedAppliedAfterInitialization() {
+
+ mManager.setSpeed(1.5f);
+ read("one");
+ TestTTS.init(TextToSpeech.SUCCESS);
+
+ Assert.assertEquals(1.5f, TestTTS.sSpeechRate, 0f);
+
+ mManager.setSpeed(0.5f);
+ Assert.assertEquals(0.5f, TestTTS.sSpeechRate, 0f);
+ }
+
+ private static final class RecordingListener implements NativeTTSManager.Listener {
+
+ private Boolean mLastState;
+ private int mErrors;
+ private final List mPositions = new ArrayList<>();
+
+ @Override
+ public void onTTSStateChanged(final boolean isSpeaking) {
+ mLastState = isSpeaking;
+ }
+
+ @Override
+ public void onUtteranceStarted(final int position) {
+ mPositions.add(position);
+ }
+
+ @Override
+ public void onTTSError() {
+ mErrors++;
+ }
+ }
+
+ /** A fake engine, so the tests don't depend on a real text-to-speech service. */
+ @Implements(value = TextToSpeech.class, callThroughByDefault = false)
+ public static class TestTTS {
+
+ static TextToSpeech.OnInitListener sInitListener;
+ static UtteranceProgressListener sProgress;
+ static int sLanguage;
+ static int sSpeakResult;
+ static int sStops;
+ static int sShutdowns;
+ static float sSpeechRate;
+ static final List sSpoken = new ArrayList<>();
+ static final List sIds = new ArrayList<>();
+ static final List sQueueModes = new ArrayList<>();
+
+ static void reset() {
+ sInitListener = null;
+ sProgress = null;
+ sLanguage = TextToSpeech.LANG_AVAILABLE;
+ sSpeakResult = TextToSpeech.SUCCESS;
+ sStops = 0;
+ sShutdowns = 0;
+ sSpeechRate = 0;
+ sSpoken.clear();
+ sIds.clear();
+ sQueueModes.clear();
+ }
+
+ static void init(final int status) {
+ sInitListener.onInit(status);
+ idle();
+ }
+
+ @Implementation
+ protected void __constructor__(
+ final Context context,
+ final TextToSpeech.OnInitListener callback) {
+ sInitListener = callback;
+ }
+
+ @Implementation
+ protected static int getMaxSpeechInputLength() {
+ return MAX_LENGTH;
+ }
+
+ @Implementation
+ protected int setLanguage(final Locale locale) {
+ return sLanguage;
+ }
+
+ @Implementation
+ protected int setOnUtteranceProgressListener(final UtteranceProgressListener listener) {
+ sProgress = listener;
+ return TextToSpeech.SUCCESS;
+ }
+
+ @Implementation
+ protected int setSpeechRate(final float rate) {
+ sSpeechRate = rate;
+ return TextToSpeech.SUCCESS;
+ }
+
+ @Implementation
+ protected int speak(
+ final CharSequence text,
+ final int queueMode,
+ final Bundle params,
+ final String utteranceId) {
+ sSpoken.add(text.toString());
+ sIds.add(utteranceId);
+ sQueueModes.add(queueMode);
+ return sSpeakResult;
+ }
+
+ @Implementation
+ protected int stop() {
+ sStops++;
+ return TextToSpeech.SUCCESS;
+ }
+
+ @Implementation
+ protected void shutdown() {
+ sShutdowns++;
+ }
+ }
+}