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++; + } + } +}