1 package org.robolectric.shadows; 2 3 import static android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1; 4 import static android.os.Build.VERSION_CODES.LOLLIPOP; 5 import static java.nio.charset.StandardCharsets.UTF_8; 6 import static org.robolectric.util.reflector.Reflector.reflector; 7 8 import android.content.Context; 9 import android.os.Bundle; 10 import android.os.Handler; 11 import android.os.Looper; 12 import android.speech.tts.TextToSpeech; 13 import android.speech.tts.TextToSpeech.Engine; 14 import android.speech.tts.UtteranceProgressListener; 15 import android.speech.tts.Voice; 16 import com.google.common.collect.ImmutableList; 17 import java.io.File; 18 import java.io.IOException; 19 import java.io.PrintWriter; 20 import java.util.ArrayList; 21 import java.util.HashMap; 22 import java.util.HashSet; 23 import java.util.List; 24 import java.util.Locale; 25 import java.util.Set; 26 import org.robolectric.RuntimeEnvironment; 27 import org.robolectric.annotation.Implementation; 28 import org.robolectric.annotation.Implements; 29 import org.robolectric.annotation.RealObject; 30 import org.robolectric.annotation.Resetter; 31 import org.robolectric.shadow.api.Shadow; 32 import org.robolectric.shadows.ShadowMediaPlayer.MediaInfo; 33 import org.robolectric.shadows.util.DataSource; 34 import org.robolectric.util.ReflectionHelpers; 35 import org.robolectric.util.ReflectionHelpers.ClassParameter; 36 import org.robolectric.util.reflector.Direct; 37 import org.robolectric.util.reflector.ForType; 38 39 @Implements(TextToSpeech.class) 40 public class ShadowTextToSpeech { 41 42 private static final Set<Locale> languageAvailabilities = new HashSet<>(); 43 private static final Set<Voice> voices = new HashSet<>(); 44 private static TextToSpeech lastTextToSpeechInstance; 45 46 @RealObject private TextToSpeech tts; 47 48 private Context context; 49 private TextToSpeech.OnInitListener listener; 50 private String lastSpokenText; 51 private boolean shutdown = false; 52 private boolean stopped = true; 53 private int queueMode = -1; 54 private Locale language = null; 55 private File lastSynthesizeToFile; 56 private String lastSynthesizeToFileText; 57 private Voice currentVoice = null; 58 59 // This is not the value returned by synthesizeToFile, but rather controls the callbacks. 60 // See 61 // http://cs/android/frameworks/base/core/java/android/speech/tts/TextToSpeech.java?rcl=db6d9c1ced6b9af1de8f12e912a223f3c7f42ecd&l=1874. 62 private int synthesizeToFileResult = TextToSpeech.SUCCESS; 63 64 private boolean completeSynthesis = false; 65 66 private final List<String> spokenTextList = new ArrayList<>(); 67 68 @Implementation __constructor__( Context context, TextToSpeech.OnInitListener listener, String engine, String packageName, boolean useFallback)69 protected void __constructor__( 70 Context context, 71 TextToSpeech.OnInitListener listener, 72 String engine, 73 String packageName, 74 boolean useFallback) { 75 this.context = context; 76 this.listener = listener; 77 lastTextToSpeechInstance = tts; 78 Shadow.invokeConstructor( 79 TextToSpeech.class, 80 tts, 81 ClassParameter.from(Context.class, context), 82 ClassParameter.from(TextToSpeech.OnInitListener.class, listener), 83 ClassParameter.from(String.class, engine), 84 ClassParameter.from(String.class, packageName), 85 ClassParameter.from(boolean.class, useFallback)); 86 } 87 88 /** 89 * Sets up synthesizeToFile to succeed or fail in the synthesis operation. 90 * 91 * <p>This controls calls the relevant callbacks but does not set the return value of 92 * synthesizeToFile. 93 * 94 * @param result TextToSpeech enum (SUCCESS, ERROR, or one of the ERROR_ codes from TextToSpeech) 95 */ simulateSynthesizeToFileResult(int result)96 public void simulateSynthesizeToFileResult(int result) { 97 this.synthesizeToFileResult = result; 98 this.completeSynthesis = true; 99 } 100 101 @Implementation initTts()102 protected int initTts() { 103 // Has to be overridden because the real code attempts to connect to a non-existent TTS 104 // system service. 105 return TextToSpeech.SUCCESS; 106 } 107 108 /** 109 * Speaks the string using the specified queuing strategy and speech parameters. 110 * 111 * @param params The real implementation converts the hashmap into a bundle, but the bundle 112 * argument is not used in the shadow implementation. 113 */ 114 @Implementation speak( final String text, final int queueMode, final HashMap<String, String> params)115 protected int speak( 116 final String text, final int queueMode, final HashMap<String, String> params) { 117 if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) { 118 return reflector(TextToSpeechReflector.class, tts).speak(text, queueMode, params); 119 } 120 return speak( 121 text, queueMode, null, params == null ? null : params.get(Engine.KEY_PARAM_UTTERANCE_ID)); 122 } 123 124 @Implementation(minSdk = LOLLIPOP) speak( final CharSequence text, final int queueMode, final Bundle params, final String utteranceId)125 protected int speak( 126 final CharSequence text, final int queueMode, final Bundle params, final String utteranceId) { 127 stopped = false; 128 lastSpokenText = text.toString(); 129 spokenTextList.add(text.toString()); 130 this.queueMode = queueMode; 131 132 if (RuntimeEnvironment.getApiLevel() >= ICE_CREAM_SANDWICH_MR1) { 133 if (utteranceId != null) { 134 // The onStart and onDone callbacks are normally delivered asynchronously. Since in 135 // Robolectric we don't need the wait for TTS package, the asynchronous callbacks are 136 // simulated by posting it on a handler. The behavior of the callback can be changed for 137 // each individual test by changing the idling mode of the foreground scheduler. 138 Handler handler = new Handler(Looper.getMainLooper()); 139 handler.post( 140 () -> { 141 UtteranceProgressListener utteranceProgressListener = getUtteranceProgressListener(); 142 if (utteranceProgressListener != null) { 143 utteranceProgressListener.onStart(utteranceId); 144 } 145 // The onDone callback is posted in a separate run-loop from onStart, so that tests 146 // can pause the scheduler and test the behavior between these two callbacks. 147 handler.post( 148 () -> { 149 UtteranceProgressListener utteranceProgressListener2 = 150 getUtteranceProgressListener(); 151 if (utteranceProgressListener2 != null) { 152 utteranceProgressListener2.onDone(utteranceId); 153 } 154 }); 155 }); 156 } 157 } 158 return TextToSpeech.SUCCESS; 159 } 160 161 @Implementation shutdown()162 protected void shutdown() { 163 shutdown = true; 164 } 165 166 @Implementation stop()167 protected int stop() { 168 stopped = true; 169 return TextToSpeech.SUCCESS; 170 } 171 172 @Implementation isLanguageAvailable(Locale lang)173 protected int isLanguageAvailable(Locale lang) { 174 for (Locale locale : languageAvailabilities) { 175 if (locale.getISO3Language().equals(lang.getISO3Language())) { 176 if (locale.getISO3Country().equals(lang.getISO3Country())) { 177 if (locale.getVariant().equals(lang.getVariant())) { 178 return TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE; 179 } 180 return TextToSpeech.LANG_COUNTRY_AVAILABLE; 181 } 182 return TextToSpeech.LANG_AVAILABLE; 183 } 184 } 185 return TextToSpeech.LANG_NOT_SUPPORTED; 186 } 187 188 @Implementation setLanguage(Locale locale)189 protected int setLanguage(Locale locale) { 190 this.language = locale; 191 return isLanguageAvailable(locale); 192 } 193 194 /** 195 * Stores {@code text} and returns {@link TextToSpeech#SUCCESS}. 196 * 197 * @see #getLastSynthesizeToFileText() 198 */ 199 @Implementation(minSdk = LOLLIPOP) synthesizeToFile(CharSequence text, Bundle params, File file, String utteranceId)200 protected int synthesizeToFile(CharSequence text, Bundle params, File file, String utteranceId) 201 throws IOException { 202 this.lastSynthesizeToFileText = text.toString(); 203 204 if (!Boolean.getBoolean("robolectric.enableShadowTtsSynthesisToFileWriteToFileSuppression")) { 205 this.lastSynthesizeToFile = file; 206 try (PrintWriter writer = new PrintWriter(file, UTF_8.name())) { 207 writer.println(text); 208 } 209 210 ShadowMediaPlayer.addMediaInfo( 211 DataSource.toDataSource(file.getAbsolutePath()), new MediaInfo()); 212 } 213 214 UtteranceProgressListener utteranceProgressListener = getUtteranceProgressListener(); 215 216 /* 217 * The Java system property robolectric.shadowTtsEnableSynthesisToFileCallbackSuppression can be 218 * used by test targets that fail due to tests relying on previous behavior of this fake, where 219 * the listeners were not called. 220 */ 221 if (completeSynthesis 222 && utteranceProgressListener != null 223 && !Boolean.getBoolean("robolectric.enableShadowTtsSynthesisToFileCallbackSuppression")) { 224 switch (synthesizeToFileResult) { 225 // Right now this only supports success an error though there are other possible 226 // situations. 227 case TextToSpeech.SUCCESS: 228 utteranceProgressListener.onStart(utteranceId); 229 utteranceProgressListener.onDone(utteranceId); 230 break; 231 default: 232 utteranceProgressListener.onError(utteranceId, synthesizeToFileResult); 233 break; 234 } 235 } 236 237 // This refers to the result of the queueing operation. 238 // See 239 // http://cs/android/frameworks/base/core/java/android/speech/tts/TextToSpeech.java?rcl=db6d9c1ced6b9af1de8f12e912a223f3c7f42ecd&l=1890. 240 return TextToSpeech.SUCCESS; 241 } 242 243 @Implementation(minSdk = LOLLIPOP) setVoice(Voice voice)244 protected int setVoice(Voice voice) { 245 this.currentVoice = voice; 246 return TextToSpeech.SUCCESS; 247 } 248 249 @Implementation(minSdk = LOLLIPOP) getVoices()250 protected Set<Voice> getVoices() { 251 return voices; 252 } 253 getUtteranceProgressListener()254 public UtteranceProgressListener getUtteranceProgressListener() { 255 return ReflectionHelpers.getField(tts, "mUtteranceProgressListener"); 256 } 257 getContext()258 public Context getContext() { 259 return context; 260 } 261 getOnInitListener()262 public TextToSpeech.OnInitListener getOnInitListener() { 263 return listener; 264 } 265 getLastSpokenText()266 public String getLastSpokenText() { 267 return lastSpokenText; 268 } 269 clearLastSpokenText()270 public void clearLastSpokenText() { 271 lastSpokenText = null; 272 } 273 isShutdown()274 public boolean isShutdown() { 275 return shutdown; 276 } 277 278 /** @return {@code true} if the TTS is stopped. */ isStopped()279 public boolean isStopped() { 280 return stopped; 281 } 282 getQueueMode()283 public int getQueueMode() { 284 return queueMode; 285 } 286 287 /** 288 * Returns {@link Locale} set using {@link TextToSpeech#setLanguage(Locale)} or null if not set. 289 */ getCurrentLanguage()290 public Locale getCurrentLanguage() { 291 return language; 292 } 293 294 /** 295 * Returns last text {@link CharSequence} passed to {@link 296 * TextToSpeech#synthesizeToFile(CharSequence, Bundle, File, String)}. 297 */ getLastSynthesizeToFileText()298 public String getLastSynthesizeToFileText() { 299 return lastSynthesizeToFileText; 300 } 301 302 /** 303 * Returns last file {@link File} written to by {@link TextToSpeech#synthesizeToFile(CharSequence, 304 * Bundle, File, String)}. 305 */ getLastSynthesizeToFile()306 public File getLastSynthesizeToFile() { 307 return lastSynthesizeToFile; 308 } 309 310 /** Returns list of all the text spoken by {@link #speak}. */ getSpokenTextList()311 public ImmutableList<String> getSpokenTextList() { 312 return ImmutableList.copyOf(spokenTextList); 313 } 314 315 /** 316 * Makes {@link Locale} an available language returned by {@link 317 * TextToSpeech#isLanguageAvailable(Locale)}. The value returned by {@link 318 * #isLanguageAvailable(Locale)} will vary depending on language, country, and variant. 319 */ addLanguageAvailability(Locale locale)320 public static void addLanguageAvailability(Locale locale) { 321 languageAvailabilities.add(locale); 322 } 323 324 /** Makes {@link Voice} an available voice returned by {@link TextToSpeech#getVoices()}. */ addVoice(Voice voice)325 public static void addVoice(Voice voice) { 326 voices.add(voice); 327 } 328 329 /** Returns {@link Voice} set using {@link TextToSpeech#setVoice(Voice)}, or null if not set. */ getCurrentVoice()330 public Voice getCurrentVoice() { 331 return currentVoice; 332 } 333 334 /** Returns the most recently instantiated {@link TextToSpeech} or null if none exist. */ getLastTextToSpeechInstance()335 public static TextToSpeech getLastTextToSpeechInstance() { 336 return lastTextToSpeechInstance; 337 } 338 339 @Resetter reset()340 public static void reset() { 341 languageAvailabilities.clear(); 342 voices.clear(); 343 lastTextToSpeechInstance = null; 344 } 345 346 @ForType(TextToSpeech.class) 347 interface TextToSpeechReflector { 348 349 @Direct speak(final String text, final int queueMode, final HashMap params)350 int speak(final String text, final int queueMode, final HashMap params); 351 } 352 } 353