craft-native 0.0.115 → 0.0.118
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.
- package/dist/android/templates/AndroidManifest.xml.template +8 -0
- package/dist/android/templates/CraftBridge.kt.template +285 -1
- package/dist/api/mobile.d.ts +29 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +2 -1
- package/dist/index.js +2 -1
- package/dist/ios/templates/CraftApp.swift +221 -0
- package/dist/mobile.js +58 -0
- package/package.json +1 -1
|
@@ -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" />
|
|
@@ -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,
|
|
@@ -1642,6 +1672,66 @@ class CraftBridge(
|
|
|
1642
1672
|
return Promise.resolve().then(function() { CraftAndroid.openPermissionSettings(); });
|
|
1643
1673
|
}
|
|
1644
1674
|
};
|
|
1675
|
+
// Speech synthesis. speak() settles when the utterance ends:
|
|
1676
|
+
// true when it was spoken, false when something stopped it
|
|
1677
|
+
// or the device has no engine to speak with. No timeout, as
|
|
1678
|
+
// on iOS: a long sentence at a slow rate outlasts any
|
|
1679
|
+
// deadline that would be fair to a short cue.
|
|
1680
|
+
var speechRequestSequence = 0;
|
|
1681
|
+
var speechRequests = Object.create(null);
|
|
1682
|
+
var speechRuntimeClosed = false;
|
|
1683
|
+
window.__craftSpeechResult = function(id, spoken) {
|
|
1684
|
+
if (speechRuntimeClosed) return;
|
|
1685
|
+
var pending = speechRequests[id];
|
|
1686
|
+
if (!pending) return;
|
|
1687
|
+
delete speechRequests[id];
|
|
1688
|
+
pending.resolve(spoken === true);
|
|
1689
|
+
};
|
|
1690
|
+
window.__craftSpeechError = function(id, message, code) {
|
|
1691
|
+
if (speechRuntimeClosed) return;
|
|
1692
|
+
var pending = speechRequests[id];
|
|
1693
|
+
if (!pending) return;
|
|
1694
|
+
delete speechRequests[id];
|
|
1695
|
+
var error = new Error(message);
|
|
1696
|
+
error.code = code || 'CRAFT_ERROR';
|
|
1697
|
+
error.bridge = true;
|
|
1698
|
+
pending.reject(error);
|
|
1699
|
+
};
|
|
1700
|
+
window.__craftRejectSpeechRequests = function(message) {
|
|
1701
|
+
speechRuntimeClosed = true;
|
|
1702
|
+
Object.keys(speechRequests).forEach(function(id) {
|
|
1703
|
+
var pending = speechRequests[id];
|
|
1704
|
+
delete speechRequests[id];
|
|
1705
|
+
pending.reject(new Error(message || 'Android bridge closed'));
|
|
1706
|
+
});
|
|
1707
|
+
};
|
|
1708
|
+
craft.speech = {
|
|
1709
|
+
speak: function(text, options) {
|
|
1710
|
+
if (speechRuntimeClosed) {
|
|
1711
|
+
return Promise.reject(new Error('Android bridge is closed'));
|
|
1712
|
+
}
|
|
1713
|
+
options = options || {};
|
|
1714
|
+
var settings = JSON.stringify({
|
|
1715
|
+
rate: typeof options.rate === 'number' ? options.rate : null,
|
|
1716
|
+
language: typeof options.language === 'string' ? options.language : null,
|
|
1717
|
+
interrupt: options.interrupt !== false
|
|
1718
|
+
});
|
|
1719
|
+
return new Promise(function(resolve, reject) {
|
|
1720
|
+
speechRequestSequence += 1;
|
|
1721
|
+
var id = speechRequestSequence;
|
|
1722
|
+
speechRequests[id] = {resolve: resolve, reject: reject};
|
|
1723
|
+
try {
|
|
1724
|
+
CraftAndroid.speak(text == null ? '' : String(text), settings, id);
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
delete speechRequests[id];
|
|
1727
|
+
reject(error);
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
},
|
|
1731
|
+
stop: function() {
|
|
1732
|
+
return Promise.resolve().then(function() { CraftAndroid.stopSpeaking(); });
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1645
1735
|
craft.location = {
|
|
1646
1736
|
getCurrentPosition: craft.getCurrentPosition,
|
|
1647
1737
|
watchPosition: craft.watchPosition,
|
|
@@ -3556,6 +3646,189 @@ class CraftBridge(
|
|
|
3556
3646
|
return true
|
|
3557
3647
|
}
|
|
3558
3648
|
|
|
3649
|
+
// ==================== Speech Synthesis ====================
|
|
3650
|
+
|
|
3651
|
+
/**
|
|
3652
|
+
* Say [text], and answer the page's call [callbackId] once it has been
|
|
3653
|
+
* said (`true`) or stopped (`false`).
|
|
3654
|
+
*
|
|
3655
|
+
* The audio is marked as navigation guidance and asks for transient,
|
|
3656
|
+
* may-duck focus, which is what makes the music dip under the cue rather
|
|
3657
|
+
* than stop, and come back once [abandonSpeechFocus] runs. The WebView's
|
|
3658
|
+
* own `speechSynthesis` has no say in either.
|
|
3659
|
+
*/
|
|
3660
|
+
@JavascriptInterface
|
|
3661
|
+
fun speak(text: String?, optionsJson: String?, callbackId: Int) {
|
|
3662
|
+
activity.runOnUiThread {
|
|
3663
|
+
if (closed) return@runOnUiThread
|
|
3664
|
+
if (text.isNullOrBlank()) {
|
|
3665
|
+
rejectSpeech(callbackId, "speak needs some text to say", "INVALID_ARGUMENT")
|
|
3666
|
+
return@runOnUiThread
|
|
3667
|
+
}
|
|
3668
|
+
val options = runCatching { JSONObject(optionsJson ?: "{}") }.getOrElse { JSONObject() }
|
|
3669
|
+
val rate = if (options.isNull("rate")) 1.0 else options.optDouble("rate", 1.0)
|
|
3670
|
+
val request = SpeechRequest(
|
|
3671
|
+
text = text,
|
|
3672
|
+
// 1 is normal on both scales, so this only bounds it.
|
|
3673
|
+
rate = (if (rate.isFinite()) rate else 1.0).coerceIn(0.5, 2.0).toFloat(),
|
|
3674
|
+
language = if (options.isNull("language")) null else options.optString("language").ifBlank { null },
|
|
3675
|
+
callbackId = callbackId,
|
|
3676
|
+
generation = navigationGeneration
|
|
3677
|
+
)
|
|
3678
|
+
if (options.optBoolean("interrupt", true)) cancelPendingSpeech()
|
|
3679
|
+
|
|
3680
|
+
val engine = textToSpeech
|
|
3681
|
+
if (engine != null && textToSpeechReady) {
|
|
3682
|
+
startUtterance(engine, request)
|
|
3683
|
+
} else {
|
|
3684
|
+
queuedSpeech.add(request)
|
|
3685
|
+
bindTextToSpeech()
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
@JavascriptInterface
|
|
3691
|
+
fun stopSpeaking(): Boolean {
|
|
3692
|
+
activity.runOnUiThread {
|
|
3693
|
+
if (closed) return@runOnUiThread
|
|
3694
|
+
cancelPendingSpeech()
|
|
3695
|
+
abandonSpeechFocus()
|
|
3696
|
+
}
|
|
3697
|
+
return true
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
private val speechAudioAttributes: AudioAttributes by lazy {
|
|
3701
|
+
AudioAttributes.Builder()
|
|
3702
|
+
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
|
|
3703
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
3704
|
+
.build()
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
/** Main thread. Binding takes a moment, and finishes in the listener. */
|
|
3708
|
+
private fun bindTextToSpeech() {
|
|
3709
|
+
if (textToSpeech != null) return
|
|
3710
|
+
var engine: TextToSpeech? = null
|
|
3711
|
+
// Posted, never run inline: an engine that cannot bind reports so
|
|
3712
|
+
// from inside its own constructor, before `engine` has been assigned,
|
|
3713
|
+
// and runOnUiThread on the main thread would run it right there.
|
|
3714
|
+
engine = TextToSpeech(activity.applicationContext) { status ->
|
|
3715
|
+
speechHandler.post { onTextToSpeechBound(engine, status == TextToSpeech.SUCCESS) }
|
|
3716
|
+
}
|
|
3717
|
+
textToSpeech = engine
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
private fun onTextToSpeechBound(engine: TextToSpeech?, succeeded: Boolean) {
|
|
3721
|
+
// An engine that close() or a failed earlier bind has since replaced.
|
|
3722
|
+
if (closed || engine == null || engine !== textToSpeech) return
|
|
3723
|
+
val waiting = queuedSpeech.toList()
|
|
3724
|
+
queuedSpeech.clear()
|
|
3725
|
+
if (!succeeded) {
|
|
3726
|
+
// No engine, or none that would start. Each call settles as
|
|
3727
|
+
// "nothing was said", and the next speak tries binding again.
|
|
3728
|
+
runCatching { engine.shutdown() }
|
|
3729
|
+
textToSpeech = null
|
|
3730
|
+
waiting.forEach { settleSpeech(it.callbackId, it.generation, false) }
|
|
3731
|
+
return
|
|
3732
|
+
}
|
|
3733
|
+
engine.setAudioAttributes(speechAudioAttributes)
|
|
3734
|
+
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
|
3735
|
+
override fun onStart(utteranceId: String?) {}
|
|
3736
|
+
override fun onDone(utteranceId: String?) {
|
|
3737
|
+
activity.runOnUiThread { utteranceEnded(utteranceId, true) }
|
|
3738
|
+
}
|
|
3739
|
+
@Deprecated("Deprecated in Java")
|
|
3740
|
+
override fun onError(utteranceId: String?) {
|
|
3741
|
+
activity.runOnUiThread { utteranceEnded(utteranceId, false) }
|
|
3742
|
+
}
|
|
3743
|
+
override fun onError(utteranceId: String?, errorCode: Int) {
|
|
3744
|
+
activity.runOnUiThread { utteranceEnded(utteranceId, false) }
|
|
3745
|
+
}
|
|
3746
|
+
override fun onStop(utteranceId: String?, interrupted: Boolean) {
|
|
3747
|
+
activity.runOnUiThread { utteranceEnded(utteranceId, false) }
|
|
3748
|
+
}
|
|
3749
|
+
})
|
|
3750
|
+
textToSpeechReady = true
|
|
3751
|
+
waiting.forEach { startUtterance(engine, it) }
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
private fun startUtterance(engine: TextToSpeech, request: SpeechRequest) {
|
|
3755
|
+
if (!requestSpeechFocus()) {
|
|
3756
|
+
// A call, or something else that may not be talked over.
|
|
3757
|
+
settleSpeech(request.callbackId, request.generation, false)
|
|
3758
|
+
return
|
|
3759
|
+
}
|
|
3760
|
+
// Both apply to the speak calls after them, so each utterance sets
|
|
3761
|
+
// its own rather than inheriting the last one's.
|
|
3762
|
+
engine.setSpeechRate(request.rate)
|
|
3763
|
+
val locale = request.language?.let { Locale.forLanguageTag(it) }
|
|
3764
|
+
if (locale == null || engine.setLanguage(locale) < TextToSpeech.LANG_AVAILABLE) {
|
|
3765
|
+
engine.setLanguage(Locale.getDefault())
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
nextUtteranceId += 1
|
|
3769
|
+
val utteranceId = "craft-speech-$nextUtteranceId"
|
|
3770
|
+
pendingUtterances[utteranceId] = PendingUtterance(request.callbackId, request.generation)
|
|
3771
|
+
if (engine.speak(request.text, TextToSpeech.QUEUE_ADD, null, utteranceId) != TextToSpeech.SUCCESS) {
|
|
3772
|
+
pendingUtterances.remove(utteranceId)
|
|
3773
|
+
settleSpeech(request.callbackId, request.generation, false)
|
|
3774
|
+
if (pendingUtterances.isEmpty()) abandonSpeechFocus()
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
private fun utteranceEnded(utteranceId: String?, spoken: Boolean) {
|
|
3779
|
+
val pending = utteranceId?.let { pendingUtterances.remove(it) }
|
|
3780
|
+
if (pending != null) settleSpeech(pending.callbackId, pending.generation, spoken)
|
|
3781
|
+
if (pendingUtterances.isEmpty() && queuedSpeech.isEmpty()) abandonSpeechFocus()
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
/**
|
|
3785
|
+
* Stop the engine and settle every call waiting on it, false.
|
|
3786
|
+
*
|
|
3787
|
+
* Settled here rather than in `onStop`, so that a request still waiting
|
|
3788
|
+
* for the engine to bind, which the engine has never heard of, settles
|
|
3789
|
+
* too.
|
|
3790
|
+
*/
|
|
3791
|
+
private fun cancelPendingSpeech() {
|
|
3792
|
+
val waiting = pendingUtterances.values.toList() +
|
|
3793
|
+
queuedSpeech.map { PendingUtterance(it.callbackId, it.generation) }
|
|
3794
|
+
pendingUtterances.clear()
|
|
3795
|
+
queuedSpeech.clear()
|
|
3796
|
+
if (textToSpeechReady) runCatching { textToSpeech?.stop() }
|
|
3797
|
+
waiting.forEach { settleSpeech(it.callbackId, it.generation, false) }
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
private fun requestSpeechFocus(): Boolean {
|
|
3801
|
+
if (speechFocusRequest != null) return true
|
|
3802
|
+
val manager = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return true
|
|
3803
|
+
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
|
|
3804
|
+
.setAudioAttributes(speechAudioAttributes)
|
|
3805
|
+
.build()
|
|
3806
|
+
if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_FAILED) return false
|
|
3807
|
+
speechFocusRequest = request
|
|
3808
|
+
return true
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
private fun abandonSpeechFocus() {
|
|
3812
|
+
val request = speechFocusRequest ?: return
|
|
3813
|
+
speechFocusRequest = null
|
|
3814
|
+
val manager = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
|
|
3815
|
+
runCatching { manager.abandonAudioFocusRequest(request) }
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
/** An answer for a page that has since navigated away is dropped. */
|
|
3819
|
+
private fun settleSpeech(callbackId: Int, generation: Int, spoken: Boolean) {
|
|
3820
|
+
if (generation != navigationGeneration) return
|
|
3821
|
+
evaluateJavascriptUnlessClosed(
|
|
3822
|
+
"window.__craftSpeechResult && window.__craftSpeechResult($callbackId, $spoken)"
|
|
3823
|
+
)
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
private fun rejectSpeech(callbackId: Int, message: String, code: String) {
|
|
3827
|
+
evaluateJavascriptUnlessClosed(
|
|
3828
|
+
"window.__craftSpeechError && window.__craftSpeechError($callbackId, ${jsQuote(message)}, ${jsQuote(code)})"
|
|
3829
|
+
)
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3559
3832
|
// ==================== Orientation Lock ====================
|
|
3560
3833
|
|
|
3561
3834
|
@JavascriptInterface
|
|
@@ -4077,7 +4350,8 @@ class CraftBridge(
|
|
|
4077
4350
|
runCatching {
|
|
4078
4351
|
webView.evaluateJavascript(
|
|
4079
4352
|
"window.__craftRejectPendingPromises && window.__craftRejectPendingPromises('Android bridge closed');" +
|
|
4080
|
-
"window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed')"
|
|
4353
|
+
"window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed');" +
|
|
4354
|
+
"window.__craftRejectSpeechRequests && window.__craftRejectSpeechRequests('Android bridge closed')",
|
|
4081
4355
|
null
|
|
4082
4356
|
)
|
|
4083
4357
|
}
|
|
@@ -4144,6 +4418,16 @@ class CraftBridge(
|
|
|
4144
4418
|
runCatching { database?.close() }
|
|
4145
4419
|
database = null
|
|
4146
4420
|
|
|
4421
|
+
// The page's calls were rejected above; this only silences the
|
|
4422
|
+
// engine and gives the music back its volume.
|
|
4423
|
+
queuedSpeech.clear()
|
|
4424
|
+
pendingUtterances.clear()
|
|
4425
|
+
runCatching { textToSpeech?.stop() }
|
|
4426
|
+
runCatching { textToSpeech?.shutdown() }
|
|
4427
|
+
textToSpeech = null
|
|
4428
|
+
textToSpeechReady = false
|
|
4429
|
+
abandonSpeechFocus()
|
|
4430
|
+
|
|
4147
4431
|
if (isFlashlightOn) setFlashlight(false)
|
|
4148
4432
|
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
4149
4433
|
isKeepingAwake = false
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -687,6 +687,34 @@ export declare const keepAwake: {
|
|
|
687
687
|
enable(): Promise<void>;
|
|
688
688
|
disable(): Promise<void>;
|
|
689
689
|
};
|
|
690
|
+
export interface SpeechOptions {
|
|
691
|
+
/** Speaking rate, 0.5 to 2. 1 is the voice's normal pace. */
|
|
692
|
+
rate?: number;
|
|
693
|
+
/** BCP-47 language tag, such as 'en-US'. Defaults to the device's language. */
|
|
694
|
+
language?: string;
|
|
695
|
+
/** Stop whatever is being said first. Defaults to true; false queues behind it. */
|
|
696
|
+
interrupt?: boolean;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Short spoken cues: "Rest, 15 seconds. Up next: Dead Bug".
|
|
700
|
+
*
|
|
701
|
+
* Inside a Craft app this is native text-to-speech, which ducks the person's
|
|
702
|
+
* music rather than stopping it and, on iOS, plays with the silent switch on.
|
|
703
|
+
* In a browser it falls back to the Web Speech API, which does neither, and
|
|
704
|
+
* where there is no speech at all it answers `false` rather than throwing, so
|
|
705
|
+
* a cue never breaks the flow around it.
|
|
706
|
+
*/
|
|
707
|
+
export declare const speech: {
|
|
708
|
+
isAvailable(): boolean;
|
|
709
|
+
/**
|
|
710
|
+
* Speak `text`. Resolves when the utterance ends: `true` when it was
|
|
711
|
+
* spoken, `false` when it was stopped, interrupted or could not be spoken.
|
|
712
|
+
* Rejects with code `INVALID_ARGUMENT` when `text` is empty.
|
|
713
|
+
*/
|
|
714
|
+
speak(text: string, options?: SpeechOptions): Promise<boolean>;
|
|
715
|
+
/** Stop speaking at once. Every pending `speak()` resolves `false`. */
|
|
716
|
+
stop(): Promise<void>;
|
|
717
|
+
};
|
|
690
718
|
export declare const deepLinks: {
|
|
691
719
|
getInitialURL(): Promise<string | null>;
|
|
692
720
|
/**
|
|
@@ -852,6 +880,7 @@ declare const mobile: {
|
|
|
852
880
|
lifecycle: typeof lifecycle;
|
|
853
881
|
notifications: typeof notifications;
|
|
854
882
|
keepAwake: typeof keepAwake;
|
|
883
|
+
speech: typeof speech;
|
|
855
884
|
deepLinks: typeof deepLinks;
|
|
856
885
|
network: typeof network;
|
|
857
886
|
appReview: typeof appReview;
|
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.118";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
|
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
310
310
|
`);
|
|
311
311
|
}
|
|
312
312
|
// package.json
|
|
313
|
-
var version = "0.0.
|
|
313
|
+
var version = "0.0.118";
|
|
314
314
|
|
|
315
315
|
// src/sdk-version.ts
|
|
316
316
|
var SDK_VERSION = version;
|
|
@@ -5284,6 +5284,7 @@ var notifications = {
|
|
|
5284
5284
|
}
|
|
5285
5285
|
}
|
|
5286
5286
|
};
|
|
5287
|
+
var speakingOnTheWeb = new Set;
|
|
5287
5288
|
function detectPlatform2() {
|
|
5288
5289
|
if (typeof window === "undefined")
|
|
5289
5290
|
return "linux";
|
package/dist/index.js
CHANGED
|
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
58
58
|
`);
|
|
59
59
|
}
|
|
60
60
|
// package.json
|
|
61
|
-
var version = "0.0.
|
|
61
|
+
var version = "0.0.118";
|
|
62
62
|
|
|
63
63
|
// src/sdk-version.ts
|
|
64
64
|
var SDK_VERSION = version;
|
|
@@ -5044,6 +5044,7 @@ var notifications = {
|
|
|
5044
5044
|
}
|
|
5045
5045
|
}
|
|
5046
5046
|
};
|
|
5047
|
+
var speakingOnTheWeb = new Set;
|
|
5047
5048
|
function detectPlatform2() {
|
|
5048
5049
|
if (typeof window === "undefined")
|
|
5049
5050
|
return "linux";
|
|
@@ -722,6 +722,24 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
722
722
|
// Keep awake
|
|
723
723
|
private var isKeepingAwake = false
|
|
724
724
|
|
|
725
|
+
// Speech synthesis. One synthesizer for the life of the web view, so a
|
|
726
|
+
// cue can interrupt the one before it; each utterance it has not
|
|
727
|
+
// finished yet is held beside the call waiting on it.
|
|
728
|
+
private lazy var speechSynthesizer: AVSpeechSynthesizer = {
|
|
729
|
+
let synthesizer = AVSpeechSynthesizer()
|
|
730
|
+
synthesizer.delegate = self
|
|
731
|
+
return synthesizer
|
|
732
|
+
}()
|
|
733
|
+
private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
|
|
734
|
+
/// What the session was before speech claimed it; nil while speech
|
|
735
|
+
/// does not hold it.
|
|
736
|
+
private var speechAudioSessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
|
|
737
|
+
/// Activating and deactivating the audio session blocks until the
|
|
738
|
+
/// audio hardware answers, which iOS flags as a hang risk on the main
|
|
739
|
+
/// thread. They run here instead, in order, and the session state
|
|
740
|
+
/// above is only touched here.
|
|
741
|
+
private let speechAudioQueue = DispatchQueue(label: "craft.speech.audio")
|
|
742
|
+
|
|
725
743
|
// Orientation lock
|
|
726
744
|
private var lockedOrientation: UIInterfaceOrientationMask?
|
|
727
745
|
|
|
@@ -1058,6 +1076,16 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1058
1076
|
} else {
|
|
1059
1077
|
rejectCallback(callbackId, error: "setFlashlight needs true or false", code: "INVALID_ARGUMENT")
|
|
1060
1078
|
}
|
|
1079
|
+
// Speech synthesis. Ungated, like the flashlight: speaking needs no
|
|
1080
|
+
// permission and no entitlement.
|
|
1081
|
+
case "speak":
|
|
1082
|
+
if let text = body["text"] as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
1083
|
+
speak(text, body: body, callbackId: callbackId)
|
|
1084
|
+
} else {
|
|
1085
|
+
rejectCallback(callbackId, error: "speak needs some text to say", code: "INVALID_ARGUMENT")
|
|
1086
|
+
}
|
|
1087
|
+
case "stopSpeaking":
|
|
1088
|
+
stopSpeaking(callbackId: callbackId)
|
|
1061
1089
|
// Vibrate pattern
|
|
1062
1090
|
case "vibrate":
|
|
1063
1091
|
if let pattern = body["pattern"] as? [Int] {
|
|
@@ -1850,6 +1878,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1850
1878
|
orientationLock: \(config.enableOrientationLock),
|
|
1851
1879
|
deepLinks: \(config.enableDeepLinks),
|
|
1852
1880
|
flashlight: true,
|
|
1881
|
+
speech: true,
|
|
1853
1882
|
network: true,
|
|
1854
1883
|
deviceInfo: true,
|
|
1855
1884
|
badge: true,
|
|
@@ -2788,6 +2817,37 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2788
2817
|
});
|
|
2789
2818
|
},
|
|
2790
2819
|
|
|
2820
|
+
// Speech synthesis. speak() settles when the utterance ends:
|
|
2821
|
+
// true when it was spoken, false when something stopped it.
|
|
2822
|
+
// No timeout, because a long sentence at a slow rate can
|
|
2823
|
+
// outlast any deadline that would be fair to a short one.
|
|
2824
|
+
speech: {
|
|
2825
|
+
speak: function(text, options) {
|
|
2826
|
+
var self = window.craft;
|
|
2827
|
+
var id = 'cb_' + (++self._callbackId);
|
|
2828
|
+
options = options || {};
|
|
2829
|
+
window.webkit.messageHandlers.craft.postMessage({
|
|
2830
|
+
action: 'speak',
|
|
2831
|
+
text: text == null ? '' : String(text),
|
|
2832
|
+
rate: typeof options.rate === 'number' ? options.rate : null,
|
|
2833
|
+
language: typeof options.language === 'string' ? options.language : null,
|
|
2834
|
+
interrupt: options.interrupt !== false,
|
|
2835
|
+
callbackId: id
|
|
2836
|
+
});
|
|
2837
|
+
return new Promise(function(resolve, reject) {
|
|
2838
|
+
self._callbacks[id] = {resolve: resolve, reject: reject};
|
|
2839
|
+
});
|
|
2840
|
+
},
|
|
2841
|
+
stop: function() {
|
|
2842
|
+
var self = window.craft;
|
|
2843
|
+
var id = 'cb_' + (++self._callbackId);
|
|
2844
|
+
window.webkit.messageHandlers.craft.postMessage({action: 'stopSpeaking', callbackId: id});
|
|
2845
|
+
return new Promise(function(resolve, reject) {
|
|
2846
|
+
self._callbacks[id] = {resolve: resolve, reject: reject};
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
},
|
|
2850
|
+
|
|
2791
2851
|
// Orientation Lock
|
|
2792
2852
|
lockOrientation: function(orientation) {
|
|
2793
2853
|
var self = this;
|
|
@@ -4762,6 +4822,153 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4762
4822
|
}
|
|
4763
4823
|
}
|
|
4764
4824
|
|
|
4825
|
+
// MARK: - Speech Synthesis
|
|
4826
|
+
//
|
|
4827
|
+
// Native rather than the page's own `speechSynthesis`, which in a
|
|
4828
|
+
// WKWebView plays through the default soloAmbient session: it stops
|
|
4829
|
+
// the person's music and the silent switch mutes it. A workout cue has
|
|
4830
|
+
// to do neither, so the session is set up here for as long as there is
|
|
4831
|
+
// something to say, and handed back when there is not.
|
|
4832
|
+
|
|
4833
|
+
private func speak(_ text: String, body: [String: Any], callbackId: String?) {
|
|
4834
|
+
DispatchQueue.main.async {
|
|
4835
|
+
if body["interrupt"] as? Bool ?? true {
|
|
4836
|
+
self.cancelPendingSpeech()
|
|
4837
|
+
}
|
|
4838
|
+
|
|
4839
|
+
let utterance = AVSpeechUtterance(string: text)
|
|
4840
|
+
utterance.rate = Self.utteranceRate(body["rate"] as? Double ?? 1)
|
|
4841
|
+
// An unknown language finds no voice, and a nil voice is the
|
|
4842
|
+
// device's own, which is the default anyway.
|
|
4843
|
+
if let language = body["language"] as? String, !language.isEmpty,
|
|
4844
|
+
let voice = AVSpeechSynthesisVoice(language: language) {
|
|
4845
|
+
utterance.voice = voice
|
|
4846
|
+
}
|
|
4847
|
+
|
|
4848
|
+
self.pendingUtterances.append((utterance, callbackId))
|
|
4849
|
+
self.claimAudioSessionForSpeech {
|
|
4850
|
+
// Stopped while the session was being set up: say nothing.
|
|
4851
|
+
guard self.pendingUtterances.contains(where: { $0.utterance === utterance }) else { return }
|
|
4852
|
+
self.speechSynthesizer.speak(utterance)
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
private func stopSpeaking(callbackId: String?) {
|
|
4858
|
+
DispatchQueue.main.async {
|
|
4859
|
+
// When something was playing, its didCancel releases the
|
|
4860
|
+
// session once the audio has actually stopped; deactivating
|
|
4861
|
+
// before that fails as busy and leaves the music ducked.
|
|
4862
|
+
if !self.cancelPendingSpeech() { self.releaseAudioSessionIfSilent() }
|
|
4863
|
+
self.resolveCallback(callbackId, result: true)
|
|
4864
|
+
}
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
/// Stop what is being said and settle every call waiting on it, false.
|
|
4868
|
+
/// Returns whether the synthesizer was making a sound.
|
|
4869
|
+
///
|
|
4870
|
+
/// Settled here rather than in `didCancel`, because the delegate hears
|
|
4871
|
+
/// about the utterance that was playing and nothing about the ones
|
|
4872
|
+
/// queued behind it, whose calls would otherwise never settle.
|
|
4873
|
+
@discardableResult
|
|
4874
|
+
private func cancelPendingSpeech() -> Bool {
|
|
4875
|
+
let waiting = pendingUtterances
|
|
4876
|
+
pendingUtterances.removeAll()
|
|
4877
|
+
let synthesizer = speechSynthesizer
|
|
4878
|
+
let wasSounding = synthesizer.isSpeaking || synthesizer.isPaused
|
|
4879
|
+
if wasSounding { synthesizer.stopSpeaking(at: .immediate) }
|
|
4880
|
+
for entry in waiting {
|
|
4881
|
+
resolveCallback(entry.callbackId, result: false)
|
|
4882
|
+
}
|
|
4883
|
+
return wasSounding
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4886
|
+
/// The page's rate, where 1 is normal, on AVSpeechUtterance's scale.
|
|
4887
|
+
///
|
|
4888
|
+
/// Apple's scale is not linear in speed: the default sits at 0.5 and
|
|
4889
|
+
/// the maximum is far faster than twice that. So a slower rate scales
|
|
4890
|
+
/// the default down in proportion, and a faster one climbs toward the
|
|
4891
|
+
/// maximum at half the slope, which keeps 2 brisk but intelligible.
|
|
4892
|
+
private static func utteranceRate(_ rate: Double) -> Float {
|
|
4893
|
+
let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
|
|
4894
|
+
let normal = AVSpeechUtteranceDefaultSpeechRate
|
|
4895
|
+
let mapped = requested <= 1
|
|
4896
|
+
? normal * requested
|
|
4897
|
+
: normal + (AVSpeechUtteranceMaximumSpeechRate - normal) * (requested - 1) / 2
|
|
4898
|
+
return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
/// Duck the music rather than stop it, and play through the silent
|
|
4902
|
+
/// switch.
|
|
4903
|
+
///
|
|
4904
|
+
/// `.playback` is what the switch does not mute, `.duckOthers` lowers
|
|
4905
|
+
/// the music under the cue, and the spoken-audio option pauses a
|
|
4906
|
+
/// podcast rather than talking over it. Left alone while recognition
|
|
4907
|
+
/// or a recording holds the session: switching it to playback would
|
|
4908
|
+
/// cut their microphone off mid-take.
|
|
4909
|
+
///
|
|
4910
|
+
/// Set up on the speech queue, then `start` runs on the main thread.
|
|
4911
|
+
private func claimAudioSessionForSpeech(then start: @escaping () -> Void) {
|
|
4912
|
+
let microphoneBusy = audioEngine.isRunning || (audioRecorder?.isRecording ?? false)
|
|
4913
|
+
speechAudioQueue.async {
|
|
4914
|
+
if self.speechAudioSessionToRestore == nil && !microphoneBusy {
|
|
4915
|
+
let session = AVAudioSession.sharedInstance()
|
|
4916
|
+
let previous = (session.category, session.mode, session.categoryOptions)
|
|
4917
|
+
do {
|
|
4918
|
+
try session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers, .interruptSpokenAudioAndMixWithOthers])
|
|
4919
|
+
try session.setActive(true)
|
|
4920
|
+
self.speechAudioSessionToRestore = previous
|
|
4921
|
+
} catch {
|
|
4922
|
+
// Still speak, through whatever session the app has: a
|
|
4923
|
+
// cue that does not duck beats one that never plays.
|
|
4924
|
+
print("[Craft] Speech audio session failed: \(error.localizedDescription)")
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
DispatchQueue.main.async(execute: start)
|
|
4928
|
+
}
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
/// Hand the audio back once nothing is left to say, so the music
|
|
4932
|
+
/// returns to full volume instead of staying ducked, and put back the
|
|
4933
|
+
/// category the app had, so the page's own media plays as before.
|
|
4934
|
+
///
|
|
4935
|
+
/// Checked on the main thread, where the queue of utterances lives, and
|
|
4936
|
+
/// done on the speech queue after any setup already asked for, so a
|
|
4937
|
+
/// cue that arrives meanwhile reclaims the session after this.
|
|
4938
|
+
private func releaseAudioSessionIfSilent(retry: Bool = true) {
|
|
4939
|
+
guard pendingUtterances.isEmpty else { return }
|
|
4940
|
+
speechAudioQueue.async {
|
|
4941
|
+
guard let previous = self.speechAudioSessionToRestore else { return }
|
|
4942
|
+
let session = AVAudioSession.sharedInstance()
|
|
4943
|
+
do {
|
|
4944
|
+
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
4945
|
+
} catch {
|
|
4946
|
+
// Busy: the synthesizer's audio has not quite drained, or
|
|
4947
|
+
// the page has its own media playing. Once more shortly
|
|
4948
|
+
// covers the first; for the second, staying active is
|
|
4949
|
+
// right, and the ducking still ends when the music app
|
|
4950
|
+
// hears nothing more.
|
|
4951
|
+
if retry {
|
|
4952
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
4953
|
+
self.releaseAudioSessionIfSilent(retry: false)
|
|
4954
|
+
}
|
|
4955
|
+
return
|
|
4956
|
+
}
|
|
4957
|
+
print("[Craft] Speech audio session release failed: \(error.localizedDescription)")
|
|
4958
|
+
}
|
|
4959
|
+
self.speechAudioSessionToRestore = nil
|
|
4960
|
+
try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
|
|
4965
|
+
if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
|
|
4966
|
+
let entry = pendingUtterances.remove(at: index)
|
|
4967
|
+
resolveCallback(entry.callbackId, result: spoken)
|
|
4968
|
+
}
|
|
4969
|
+
releaseAudioSessionIfSilent()
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4765
4972
|
// MARK: - Orientation Lock
|
|
4766
4973
|
private func lockOrientation(_ orientation: String, callbackId: String?) {
|
|
4767
4974
|
var mask: UIInterfaceOrientationMask = .all
|
|
@@ -6698,6 +6905,20 @@ extension URL {
|
|
|
6698
6905
|
}
|
|
6699
6906
|
|
|
6700
6907
|
// MARK: - Contact Picker Delegate
|
|
6908
|
+
// MARK: - AVSpeechSynthesizerDelegate
|
|
6909
|
+
//
|
|
6910
|
+
// The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
|
|
6911
|
+
// main because every piece of speech state is touched there and nowhere else.
|
|
6912
|
+
extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
|
|
6913
|
+
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
6914
|
+
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
|
|
6915
|
+
}
|
|
6916
|
+
|
|
6917
|
+
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
|
6918
|
+
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
|
|
6919
|
+
}
|
|
6920
|
+
}
|
|
6921
|
+
|
|
6701
6922
|
extension CraftWebView.Coordinator: CNContactPickerDelegate {
|
|
6702
6923
|
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
|
|
6703
6924
|
let contactData = formatContact(contact)
|
package/dist/mobile.js
CHANGED
|
@@ -559,6 +559,63 @@ var keepAwake = {
|
|
|
559
559
|
webWakeLock = null;
|
|
560
560
|
}
|
|
561
561
|
};
|
|
562
|
+
function getWebSpeech() {
|
|
563
|
+
if (typeof window === "undefined")
|
|
564
|
+
return;
|
|
565
|
+
const scope = window;
|
|
566
|
+
if (!scope.speechSynthesis || typeof scope.SpeechSynthesisUtterance !== "function")
|
|
567
|
+
return;
|
|
568
|
+
return { synthesis: scope.speechSynthesis, Utterance: scope.SpeechSynthesisUtterance };
|
|
569
|
+
}
|
|
570
|
+
var speakingOnTheWeb = new Set;
|
|
571
|
+
function speechRate(rate) {
|
|
572
|
+
return typeof rate === "number" && Number.isFinite(rate) ? Math.min(Math.max(rate, 0.5), 2) : 1;
|
|
573
|
+
}
|
|
574
|
+
var speech = {
|
|
575
|
+
isAvailable() {
|
|
576
|
+
return Boolean(getCraftRoot()?.speech?.speak) || getWebSpeech() !== undefined;
|
|
577
|
+
},
|
|
578
|
+
async speak(text, options = {}) {
|
|
579
|
+
const native = getCraftRoot()?.speech;
|
|
580
|
+
const web = native?.speak ? undefined : getWebSpeech();
|
|
581
|
+
if (!native?.speak && !web)
|
|
582
|
+
return false;
|
|
583
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
584
|
+
throw Object.assign(new Error("speech.speak needs some text to say"), { code: "INVALID_ARGUMENT" });
|
|
585
|
+
}
|
|
586
|
+
if (native?.speak)
|
|
587
|
+
return await native.speak(text, options) === true;
|
|
588
|
+
const { synthesis, Utterance } = web;
|
|
589
|
+
if (options.interrupt !== false)
|
|
590
|
+
synthesis.cancel();
|
|
591
|
+
return new Promise((resolve) => {
|
|
592
|
+
const utterance = new Utterance(text);
|
|
593
|
+
utterance.rate = speechRate(options.rate);
|
|
594
|
+
if (options.language)
|
|
595
|
+
utterance.lang = options.language;
|
|
596
|
+
const settle = (spoken) => {
|
|
597
|
+
speakingOnTheWeb.delete(utterance);
|
|
598
|
+
resolve(spoken);
|
|
599
|
+
};
|
|
600
|
+
utterance.onend = () => settle(true);
|
|
601
|
+
utterance.onerror = () => settle(false);
|
|
602
|
+
speakingOnTheWeb.add(utterance);
|
|
603
|
+
try {
|
|
604
|
+
synthesis.speak(utterance);
|
|
605
|
+
} catch {
|
|
606
|
+
settle(false);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
},
|
|
610
|
+
async stop() {
|
|
611
|
+
const native = getCraftRoot()?.speech;
|
|
612
|
+
if (native?.stop) {
|
|
613
|
+
await native.stop();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
getWebSpeech()?.synthesis.cancel();
|
|
617
|
+
}
|
|
618
|
+
};
|
|
562
619
|
var deepLinks = {
|
|
563
620
|
async getInitialURL() {
|
|
564
621
|
return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.());
|
|
@@ -797,6 +854,7 @@ function getCraftMobile() {
|
|
|
797
854
|
}
|
|
798
855
|
export {
|
|
799
856
|
watchConnectivity,
|
|
857
|
+
speech,
|
|
800
858
|
share,
|
|
801
859
|
secureStorage,
|
|
802
860
|
pushNotifications,
|