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.
- package/dist/android/src/index.js +5 -1
- package/dist/android/templates/AndroidManifest.xml.template +9 -0
- package/dist/android/templates/CraftBridge.kt.template +288 -1
- package/dist/android/templates/CraftNativeCapabilities.kt.template +455 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +27 -5
- package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +43 -0
- package/dist/android/templates/MainActivityNative.kt.template +229 -47
- package/dist/api/mobile.d.ts +53 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +27 -1
- package/dist/index.js +27 -1
- package/dist/ios/src/index.d.ts +18 -0
- package/dist/ios/src/index.js +29 -1
- package/dist/ios/templates/CraftApp.swift +260 -1
- package/dist/ios/templates/CraftNativeActions.swift +472 -31
- package/dist/ios/templates/CraftNativeScreen.swift +93 -22
- package/dist/ios/templates/Info.plist.template +1 -0
- package/dist/mobile.js +83 -0
- package/package.json +1 -1
|
@@ -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
|