craft-native 0.0.115 → 0.0.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -418,8 +418,12 @@ async function init(options) {
418
418
  if (config.renderer === "native") {
419
419
  const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
420
420
  const flatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
421
+ const capabilityTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeCapabilities.kt.template"), "utf-8");
422
+ const notificationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeNotificationReceiver.kt.template"), "utf-8");
421
423
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
422
424
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
425
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
426
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
423
427
  }
424
428
  const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
425
429
  const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
@@ -451,7 +455,7 @@ async function init(options) {
451
455
  const healthTemplate = readFileSync(join(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
452
456
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
453
457
  const manifestTemplate = readFileSync(join(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
454
- const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
458
+ const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ? ' <receiver android:name=".CraftNativeNotificationReceiver" android:exported="false" />' : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
455
459
  <package android:name="com.google.android.apps.healthdata" />
456
460
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
457
461
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -5,6 +5,14 @@
5
5
  {{PERMISSIONS}}
6
6
  {{HEALTH_CONNECT_QUERIES}}
7
7
 
8
+ <!-- craft.speech: from Android 11 an app cannot see, and so cannot bind,
9
+ a text-to-speech engine it has not declared it will look for. -->
10
+ <queries>
11
+ <intent>
12
+ <action android:name="android.intent.action.TTS_SERVICE" />
13
+ </intent>
14
+ </queries>
15
+
8
16
  <!-- Camera feature (optional) -->
9
17
  <uses-feature android:name="android.hardware.camera" android:required="false" />
10
18
  <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
@@ -37,6 +45,7 @@
37
45
  </activity>
38
46
 
39
47
  {{BACKGROUND_SERVICE}}
48
+ {{NATIVE_NOTIFICATION_RECEIVER}}
40
49
 
41
50
  </application>
42
51
 
@@ -36,6 +36,11 @@ import android.provider.Settings
36
36
  import android.speech.RecognitionListener
37
37
  import android.speech.RecognizerIntent
38
38
  import android.speech.SpeechRecognizer
39
+ import android.speech.tts.TextToSpeech
40
+ import android.speech.tts.UtteranceProgressListener
41
+ import android.media.AudioAttributes
42
+ import android.media.AudioFocusRequest
43
+ import android.media.AudioManager
39
44
  import android.util.DisplayMetrics
40
45
  import android.webkit.JavascriptInterface
41
46
  import android.webkit.WebView
@@ -199,6 +204,17 @@ internal object CraftPermissionPolicy {
199
204
  isGranted(Manifest.permission.ACCESS_COARSE_LOCATION)
200
205
  }
201
206
 
207
+ private data class SpeechRequest(
208
+ val text: String,
209
+ val rate: Float,
210
+ val language: String?,
211
+ val callbackId: Int,
212
+ val generation: Int
213
+ )
214
+
215
+ /** The navigation it belongs to, so a page that has gone hears nothing. */
216
+ private data class PendingUtterance(val callbackId: Int, val generation: Int)
217
+
202
218
  private data class PendingPermissionRequest(
203
219
  val callbackId: Int,
204
220
  val permission: String,
@@ -279,6 +295,19 @@ class CraftBridge(
279
295
  // Keep awake
280
296
  private var isKeepingAwake = false
281
297
 
298
+ // Speech synthesis, all of it touched on the main thread only. The engine
299
+ // is bound on the first speak rather than here, so an app that never
300
+ // speaks never starts a TTS service. Requests made while it binds wait in
301
+ // queuedSpeech; each utterance handed to it waits in pendingUtterances,
302
+ // under the id the engine reports back, for the page call it answers.
303
+ private var textToSpeech: TextToSpeech? = null
304
+ private var textToSpeechReady = false
305
+ private val queuedSpeech = mutableListOf<SpeechRequest>()
306
+ private val pendingUtterances = linkedMapOf<String, PendingUtterance>()
307
+ private var nextUtteranceId = 0L
308
+ private var speechFocusRequest: AudioFocusRequest? = null
309
+ private val speechHandler = Handler(Looper.getMainLooper())
310
+
282
311
  // Billing clients for in-app purchases
283
312
  private var productBillingClient: BillingClient? = null
284
313
  private var restoreBillingClient: BillingClient? = null
@@ -388,6 +417,7 @@ class CraftBridge(
388
417
  networkStatus: true,
389
418
  appReview: true,
390
419
  flashlight: {{ENABLE_CAMERA}} && ${hasFlashlight()},
420
+ speech: true,
391
421
  openURL: true,
392
422
  vibrationPattern: true,
393
423
  appState: true,
@@ -1590,6 +1620,9 @@ class CraftBridge(
1590
1620
  craft.secureStorage = {
1591
1621
  set: function(key, value) { return Promise.resolve().then(function() { legacySecureStore.set(key, value); }); },
1592
1622
  get: function(key) { return Promise.resolve().then(function() { return legacySecureStore.get(key); }); },
1623
+ // Keep the legacy public name alongside `delete` for
1624
+ // native STX callers compiled against the older contract.
1625
+ remove: function(key) { return Promise.resolve().then(function() { legacySecureStore.remove(key); }); },
1593
1626
  delete: function(key) { return Promise.resolve().then(function() { legacySecureStore.remove(key); }); },
1594
1627
  clear: function() { return Promise.resolve().then(function() { CraftAndroid.secureClear(); }); }
1595
1628
  };
@@ -1642,6 +1675,66 @@ class CraftBridge(
1642
1675
  return Promise.resolve().then(function() { CraftAndroid.openPermissionSettings(); });
1643
1676
  }
1644
1677
  };
1678
+ // Speech synthesis. speak() settles when the utterance ends:
1679
+ // true when it was spoken, false when something stopped it
1680
+ // or the device has no engine to speak with. No timeout, as
1681
+ // on iOS: a long sentence at a slow rate outlasts any
1682
+ // deadline that would be fair to a short cue.
1683
+ var speechRequestSequence = 0;
1684
+ var speechRequests = Object.create(null);
1685
+ var speechRuntimeClosed = false;
1686
+ window.__craftSpeechResult = function(id, spoken) {
1687
+ if (speechRuntimeClosed) return;
1688
+ var pending = speechRequests[id];
1689
+ if (!pending) return;
1690
+ delete speechRequests[id];
1691
+ pending.resolve(spoken === true);
1692
+ };
1693
+ window.__craftSpeechError = function(id, message, code) {
1694
+ if (speechRuntimeClosed) return;
1695
+ var pending = speechRequests[id];
1696
+ if (!pending) return;
1697
+ delete speechRequests[id];
1698
+ var error = new Error(message);
1699
+ error.code = code || 'CRAFT_ERROR';
1700
+ error.bridge = true;
1701
+ pending.reject(error);
1702
+ };
1703
+ window.__craftRejectSpeechRequests = function(message) {
1704
+ speechRuntimeClosed = true;
1705
+ Object.keys(speechRequests).forEach(function(id) {
1706
+ var pending = speechRequests[id];
1707
+ delete speechRequests[id];
1708
+ pending.reject(new Error(message || 'Android bridge closed'));
1709
+ });
1710
+ };
1711
+ craft.speech = {
1712
+ speak: function(text, options) {
1713
+ if (speechRuntimeClosed) {
1714
+ return Promise.reject(new Error('Android bridge is closed'));
1715
+ }
1716
+ options = options || {};
1717
+ var settings = JSON.stringify({
1718
+ rate: typeof options.rate === 'number' ? options.rate : null,
1719
+ language: typeof options.language === 'string' ? options.language : null,
1720
+ interrupt: options.interrupt !== false
1721
+ });
1722
+ return new Promise(function(resolve, reject) {
1723
+ speechRequestSequence += 1;
1724
+ var id = speechRequestSequence;
1725
+ speechRequests[id] = {resolve: resolve, reject: reject};
1726
+ try {
1727
+ CraftAndroid.speak(text == null ? '' : String(text), settings, id);
1728
+ } catch (error) {
1729
+ delete speechRequests[id];
1730
+ reject(error);
1731
+ }
1732
+ });
1733
+ },
1734
+ stop: function() {
1735
+ return Promise.resolve().then(function() { CraftAndroid.stopSpeaking(); });
1736
+ }
1737
+ };
1645
1738
  craft.location = {
1646
1739
  getCurrentPosition: craft.getCurrentPosition,
1647
1740
  watchPosition: craft.watchPosition,
@@ -3556,6 +3649,189 @@ class CraftBridge(
3556
3649
  return true
3557
3650
  }
3558
3651
 
3652
+ // ==================== Speech Synthesis ====================
3653
+
3654
+ /**
3655
+ * Say [text], and answer the page's call [callbackId] once it has been
3656
+ * said (`true`) or stopped (`false`).
3657
+ *
3658
+ * The audio is marked as navigation guidance and asks for transient,
3659
+ * may-duck focus, which is what makes the music dip under the cue rather
3660
+ * than stop, and come back once [abandonSpeechFocus] runs. The WebView's
3661
+ * own `speechSynthesis` has no say in either.
3662
+ */
3663
+ @JavascriptInterface
3664
+ fun speak(text: String?, optionsJson: String?, callbackId: Int) {
3665
+ activity.runOnUiThread {
3666
+ if (closed) return@runOnUiThread
3667
+ if (text.isNullOrBlank()) {
3668
+ rejectSpeech(callbackId, "speak needs some text to say", "INVALID_ARGUMENT")
3669
+ return@runOnUiThread
3670
+ }
3671
+ val options = runCatching { JSONObject(optionsJson ?: "{}") }.getOrElse { JSONObject() }
3672
+ val rate = if (options.isNull("rate")) 1.0 else options.optDouble("rate", 1.0)
3673
+ val request = SpeechRequest(
3674
+ text = text,
3675
+ // 1 is normal on both scales, so this only bounds it.
3676
+ rate = (if (rate.isFinite()) rate else 1.0).coerceIn(0.5, 2.0).toFloat(),
3677
+ language = if (options.isNull("language")) null else options.optString("language").ifBlank { null },
3678
+ callbackId = callbackId,
3679
+ generation = navigationGeneration
3680
+ )
3681
+ if (options.optBoolean("interrupt", true)) cancelPendingSpeech()
3682
+
3683
+ val engine = textToSpeech
3684
+ if (engine != null && textToSpeechReady) {
3685
+ startUtterance(engine, request)
3686
+ } else {
3687
+ queuedSpeech.add(request)
3688
+ bindTextToSpeech()
3689
+ }
3690
+ }
3691
+ }
3692
+
3693
+ @JavascriptInterface
3694
+ fun stopSpeaking(): Boolean {
3695
+ activity.runOnUiThread {
3696
+ if (closed) return@runOnUiThread
3697
+ cancelPendingSpeech()
3698
+ abandonSpeechFocus()
3699
+ }
3700
+ return true
3701
+ }
3702
+
3703
+ private val speechAudioAttributes: AudioAttributes by lazy {
3704
+ AudioAttributes.Builder()
3705
+ .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
3706
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
3707
+ .build()
3708
+ }
3709
+
3710
+ /** Main thread. Binding takes a moment, and finishes in the listener. */
3711
+ private fun bindTextToSpeech() {
3712
+ if (textToSpeech != null) return
3713
+ var engine: TextToSpeech? = null
3714
+ // Posted, never run inline: an engine that cannot bind reports so
3715
+ // from inside its own constructor, before `engine` has been assigned,
3716
+ // and runOnUiThread on the main thread would run it right there.
3717
+ engine = TextToSpeech(activity.applicationContext) { status ->
3718
+ speechHandler.post { onTextToSpeechBound(engine, status == TextToSpeech.SUCCESS) }
3719
+ }
3720
+ textToSpeech = engine
3721
+ }
3722
+
3723
+ private fun onTextToSpeechBound(engine: TextToSpeech?, succeeded: Boolean) {
3724
+ // An engine that close() or a failed earlier bind has since replaced.
3725
+ if (closed || engine == null || engine !== textToSpeech) return
3726
+ val waiting = queuedSpeech.toList()
3727
+ queuedSpeech.clear()
3728
+ if (!succeeded) {
3729
+ // No engine, or none that would start. Each call settles as
3730
+ // "nothing was said", and the next speak tries binding again.
3731
+ runCatching { engine.shutdown() }
3732
+ textToSpeech = null
3733
+ waiting.forEach { settleSpeech(it.callbackId, it.generation, false) }
3734
+ return
3735
+ }
3736
+ engine.setAudioAttributes(speechAudioAttributes)
3737
+ engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
3738
+ override fun onStart(utteranceId: String?) {}
3739
+ override fun onDone(utteranceId: String?) {
3740
+ activity.runOnUiThread { utteranceEnded(utteranceId, true) }
3741
+ }
3742
+ @Deprecated("Deprecated in Java")
3743
+ override fun onError(utteranceId: String?) {
3744
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3745
+ }
3746
+ override fun onError(utteranceId: String?, errorCode: Int) {
3747
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3748
+ }
3749
+ override fun onStop(utteranceId: String?, interrupted: Boolean) {
3750
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3751
+ }
3752
+ })
3753
+ textToSpeechReady = true
3754
+ waiting.forEach { startUtterance(engine, it) }
3755
+ }
3756
+
3757
+ private fun startUtterance(engine: TextToSpeech, request: SpeechRequest) {
3758
+ if (!requestSpeechFocus()) {
3759
+ // A call, or something else that may not be talked over.
3760
+ settleSpeech(request.callbackId, request.generation, false)
3761
+ return
3762
+ }
3763
+ // Both apply to the speak calls after them, so each utterance sets
3764
+ // its own rather than inheriting the last one's.
3765
+ engine.setSpeechRate(request.rate)
3766
+ val locale = request.language?.let { Locale.forLanguageTag(it) }
3767
+ if (locale == null || engine.setLanguage(locale) < TextToSpeech.LANG_AVAILABLE) {
3768
+ engine.setLanguage(Locale.getDefault())
3769
+ }
3770
+
3771
+ nextUtteranceId += 1
3772
+ val utteranceId = "craft-speech-$nextUtteranceId"
3773
+ pendingUtterances[utteranceId] = PendingUtterance(request.callbackId, request.generation)
3774
+ if (engine.speak(request.text, TextToSpeech.QUEUE_ADD, null, utteranceId) != TextToSpeech.SUCCESS) {
3775
+ pendingUtterances.remove(utteranceId)
3776
+ settleSpeech(request.callbackId, request.generation, false)
3777
+ if (pendingUtterances.isEmpty()) abandonSpeechFocus()
3778
+ }
3779
+ }
3780
+
3781
+ private fun utteranceEnded(utteranceId: String?, spoken: Boolean) {
3782
+ val pending = utteranceId?.let { pendingUtterances.remove(it) }
3783
+ if (pending != null) settleSpeech(pending.callbackId, pending.generation, spoken)
3784
+ if (pendingUtterances.isEmpty() && queuedSpeech.isEmpty()) abandonSpeechFocus()
3785
+ }
3786
+
3787
+ /**
3788
+ * Stop the engine and settle every call waiting on it, false.
3789
+ *
3790
+ * Settled here rather than in `onStop`, so that a request still waiting
3791
+ * for the engine to bind, which the engine has never heard of, settles
3792
+ * too.
3793
+ */
3794
+ private fun cancelPendingSpeech() {
3795
+ val waiting = pendingUtterances.values.toList() +
3796
+ queuedSpeech.map { PendingUtterance(it.callbackId, it.generation) }
3797
+ pendingUtterances.clear()
3798
+ queuedSpeech.clear()
3799
+ if (textToSpeechReady) runCatching { textToSpeech?.stop() }
3800
+ waiting.forEach { settleSpeech(it.callbackId, it.generation, false) }
3801
+ }
3802
+
3803
+ private fun requestSpeechFocus(): Boolean {
3804
+ if (speechFocusRequest != null) return true
3805
+ val manager = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return true
3806
+ val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
3807
+ .setAudioAttributes(speechAudioAttributes)
3808
+ .build()
3809
+ if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_FAILED) return false
3810
+ speechFocusRequest = request
3811
+ return true
3812
+ }
3813
+
3814
+ private fun abandonSpeechFocus() {
3815
+ val request = speechFocusRequest ?: return
3816
+ speechFocusRequest = null
3817
+ val manager = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
3818
+ runCatching { manager.abandonAudioFocusRequest(request) }
3819
+ }
3820
+
3821
+ /** An answer for a page that has since navigated away is dropped. */
3822
+ private fun settleSpeech(callbackId: Int, generation: Int, spoken: Boolean) {
3823
+ if (generation != navigationGeneration) return
3824
+ evaluateJavascriptUnlessClosed(
3825
+ "window.__craftSpeechResult && window.__craftSpeechResult($callbackId, $spoken)"
3826
+ )
3827
+ }
3828
+
3829
+ private fun rejectSpeech(callbackId: Int, message: String, code: String) {
3830
+ evaluateJavascriptUnlessClosed(
3831
+ "window.__craftSpeechError && window.__craftSpeechError($callbackId, ${jsQuote(message)}, ${jsQuote(code)})"
3832
+ )
3833
+ }
3834
+
3559
3835
  // ==================== Orientation Lock ====================
3560
3836
 
3561
3837
  @JavascriptInterface
@@ -4077,7 +4353,8 @@ class CraftBridge(
4077
4353
  runCatching {
4078
4354
  webView.evaluateJavascript(
4079
4355
  "window.__craftRejectPendingPromises && window.__craftRejectPendingPromises('Android bridge closed');" +
4080
- "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed')",
4356
+ "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed');" +
4357
+ "window.__craftRejectSpeechRequests && window.__craftRejectSpeechRequests('Android bridge closed')",
4081
4358
  null
4082
4359
  )
4083
4360
  }
@@ -4144,6 +4421,16 @@ class CraftBridge(
4144
4421
  runCatching { database?.close() }
4145
4422
  database = null
4146
4423
 
4424
+ // The page's calls were rejected above; this only silences the
4425
+ // engine and gives the music back its volume.
4426
+ queuedSpeech.clear()
4427
+ pendingUtterances.clear()
4428
+ runCatching { textToSpeech?.stop() }
4429
+ runCatching { textToSpeech?.shutdown() }
4430
+ textToSpeech = null
4431
+ textToSpeechReady = false
4432
+ abandonSpeechFocus()
4433
+
4147
4434
  if (isFlashlightOn) setFlashlight(false)
4148
4435
  activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
4149
4436
  isKeepingAwake = false