@g9tm/lynxcontrols-native 1.4.3 → 1.4.5
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/README.md +19 -4
- package/android/build.gradle.kts +8 -0
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/com/g9lynx/g9native/G9HostModule.kt +31 -5
- package/android/src/main/java/com/g9lynx/g9native/G9SpeechModule.kt +206 -0
- package/android/src/main/java/com/g9lynx/g9native/generated/G9SpeechModuleSpec.java +27 -0
- package/android/src/main/java/com/g9lynx/g9native/host/core/G9SafeAreaCore.kt +19 -0
- package/android/src/main/java/com/g9lynx/g9native/input/G9InputUI.kt +118 -0
- package/android/src/main/java/com/g9lynx/g9native/input/core/G9DigitsCore.kt +56 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/protocol.d.ts +14 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +8 -2
- package/dist/services.js.map +1 -1
- package/dist/speech.d.ts +24 -0
- package/dist/speech.d.ts.map +1 -0
- package/dist/speech.js +114 -0
- package/dist/speech.js.map +1 -0
- package/generated/G9SpeechModule.ts +64 -0
- package/ios/g9lynx-native.podspec +4 -3
- package/ios/src/G9SpeechModule.h +18 -0
- package/ios/src/G9SpeechModule.m +201 -0
- package/ios/src/generated/G9SpeechModuleSpec.h +16 -0
- package/ios/src/generated/G9SpeechModuleSpec.m +2 -0
- package/package.json +4 -4
- package/types/platform-native-module.d.ts +21 -0
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# @g9tm/lynxcontrols-native
|
|
2
2
|
|
|
3
|
-
Android and iOS platform layer for G9:
|
|
3
|
+
Android and iOS platform layer for G9: three Lynx native modules and one element (`<g9-video>`), linked into a host by
|
|
4
4
|
**Lynx Autolink** from `node_modules`, plus their JS adapters.
|
|
5
5
|
|
|
6
6
|
| Module | JS adapter | Android (Kotlin) | iOS (Objective-C) |
|
|
7
7
|
| --- | --- | --- | --- |
|
|
8
8
|
| `G9SqliteModule` | `createNativeSqliteDriver()` | androidx.sqlite `BundledSQLiteDriver` 2.7.1 | system `sqlite3` (iOS 15+, serial queue per session, `sqlite3_interrupt`) |
|
|
9
9
|
| `G9HostModule` | `createNativeHostServices()` | `SharedPreferences`, `moveTaskToBack`, `InputMethodManager`, haptics, insets, `appDataDirectory()` = `filesDir` | `NSUserDefaults` suite, `resignFirstResponder`, `UIImpactFeedbackGenerator`, safe-area insets, `appDataDirectory()` = `Library` |
|
|
10
|
+
| `G9SpeechModule` | `createNativeSpeechToText()` → `services.speech` (dictation into fields) | `SpeechRecognizer` (main thread, partial results), the microphone asked through the host activity's `ActivityResultRegistry` | `SFSpeechRecognizer` over `AVAudioEngine`; register it on the host's `LynxConfig`; Info.plist `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription` |
|
|
10
11
|
| `<g9-video>` element | `createNativeMediaProvider()` (`@g9tm/lynxcontrols-native/media`) | `G9VideoUI`: Media3 ExoPlayer 1.8.0 into a `TextureView` (opacity fades work); register `G9VideoBehaviors.create()` | `G9VideoUI`: `AVQueuePlayer` + `AVPlayerLooper` in an `AVPlayerLayer`; self-registers (`LYNX_REGISTER_UI`) |
|
|
11
12
|
|
|
12
13
|
```ts
|
|
@@ -21,6 +22,17 @@ const db = createG9Sqlite({ driver: createNativeSqliteDriver(), locator: singleF
|
|
|
21
22
|
|
|
22
23
|
## How it works
|
|
23
24
|
|
|
25
|
+
- **Dictation** (`services.speech`, MAUI `IG9SpeechToText`). `createNativeSpeechToText()` returns `null` where
|
|
26
|
+
`G9SpeechModule` is not linked, so the fields hide their microphone. One session at a time natively; the adapter
|
|
27
|
+
numbers sessions, routes the `g9:speech` global event (`{"session","text"}`, interim hypotheses) to the session that
|
|
28
|
+
asked, turns an abort into `cancel(session)` and settles every `listen` once (`recognized` / `no-match` /
|
|
29
|
+
`cancelled` / `denied`; a recogniser fault rejects with a `G9Error`). Android: the manifest merges `RECORD_AUDIO` and
|
|
30
|
+
the `RecognitionService` query Android 11+ needs to see a recogniser; androidx.activity is compile-only (every host
|
|
31
|
+
has it; a host activity that is not an `ActivityResultRegistryOwner` gets the current permission state, never a
|
|
32
|
+
prompt).
|
|
33
|
+
`isAvailable(locale)` is "a recogniser exists" on Android (asking a locale needs a broadcast some recognisers never
|
|
34
|
+
answer; an unsupported language ends as `no-match`) and `SFSpeechRecognizer(locale).isAvailable` on iOS.
|
|
35
|
+
|
|
24
36
|
- **Video** (the intro carousel's `services.media`). `<g9-video>` props: `src`, `paused`, `loop`, `muted`,
|
|
25
37
|
`start-seconds`, `near-end-seconds`, `object-fit`; events `ready`, `nearend`, `looped`, `error`. The provider's
|
|
26
38
|
player renders it with reactive props (no UI-method calls) and unmounts it on `dispose()`, which releases the native
|
|
@@ -54,13 +66,16 @@ android/src/main/java/com/g9lynx/g9native/
|
|
|
54
66
|
sqlite/core/ pure Kotlin: G9SqliteCore, G9SqliteService (executors), G9SqliteTransport, values/errors
|
|
55
67
|
sqlite/androidx/ AndroidxSqlEngine (BundledSQLiteDriver)
|
|
56
68
|
host/core/ G9PreferencesCore
|
|
57
|
-
G9SqliteModule.kt, G9HostModule.kt, G9Bridge.kt Lynx modules (kapt + lynx-processor →
|
|
69
|
+
G9SqliteModule.kt, G9HostModule.kt, G9SpeechModule.kt, G9Bridge.kt Lynx modules (kapt + lynx-processor →
|
|
70
|
+
LynxLibraryProviderImpl)
|
|
58
71
|
android/src/test/ JVM tests: contract runner, core, service, transport golden, preferences
|
|
59
72
|
ios/src/core/ portable Objective-C (Foundation + sqlite3): G9SqliteCore, G9SqliteTransport, G9PreferencesCore
|
|
60
|
-
ios/src/ G9SqliteModule.m, G9HostModule.m (dispatch queues, UIKit); register them
|
|
73
|
+
ios/src/ G9SqliteModule.m, G9HostModule.m, G9SpeechModule.m (dispatch queues, UIKit, Speech); register them
|
|
74
|
+
on the host's LynxConfig
|
|
61
75
|
ios/Tests/ G9NativeTestSuite (XCTest on a simulator; Linux harness in Tests/linux)
|
|
62
76
|
jvm/ JVM-only Gradle build of the core + tests (no Android SDK needed)
|
|
63
|
-
android-check/ cloud compile check of module + reference host against the published Lynx 4.1.0 classes,
|
|
77
|
+
android-check/ cloud compile check of module + reference host against the published Lynx 4.1.0 classes (Media3,
|
|
78
|
+
androidx.sqlite and androidx.activity as compile-only stubs under stubs/), and
|
|
64
79
|
LynxBridgeSignatureTest: every @LynxMethod through Lynx's own LynxMethodWrapper
|
|
65
80
|
```
|
|
66
81
|
|
package/android/build.gradle.kts
CHANGED
|
@@ -34,6 +34,14 @@ dependencies {
|
|
|
34
34
|
// The generated specs import androidx.annotation and lynx-processor's output uses @Keep, but Lynx's POMs declare it
|
|
35
35
|
// at runtime scope only: without this line kapt depends on androidx.sqlite / media3 bringing it in transitively.
|
|
36
36
|
compileOnly("androidx.annotation:annotation:1.9.1")
|
|
37
|
+
// G9InputBehaviors (input/G9InputUI.kt) extends xelement-input's input and textarea; a host that registers them
|
|
38
|
+
// already depends on it.
|
|
39
|
+
compileOnly("org.lynxsdk.lynx:xelement-input:$lynx")
|
|
40
|
+
// Its EditText derives from AppCompatEditText, which xelement-input's POM lists at runtime scope only.
|
|
41
|
+
compileOnly("androidx.appcompat:appcompat:1.7.0")
|
|
42
|
+
// G9SpeechModule asks for the microphone through the host activity's ActivityResultRegistry: compile-only, as every
|
|
43
|
+
// host of these libraries ships androidx.activity (a host without it gets the current state, never a prompt).
|
|
44
|
+
compileOnly("androidx.activity:activity:1.10.1")
|
|
37
45
|
implementation("androidx.sqlite:sqlite-bundled:2.7.1")
|
|
38
46
|
// <g9-video> (video/G9VideoUI.kt): ExoPlayer drawing into a TextureView. Google Maven only.
|
|
39
47
|
implementation("androidx.media3:media3-exoplayer:1.8.0")
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
<manifest
|
|
2
2
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
3
3
|
package="com.g9lynx.g9native">
|
|
4
|
+
<!-- G9SpeechModule: dictation records the microphone, and Android 11+ only sees a recogniser it declares a query for. -->
|
|
5
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
6
|
+
<queries>
|
|
7
|
+
<intent>
|
|
8
|
+
<action android:name="android.speech.RecognitionService" />
|
|
9
|
+
</intent>
|
|
10
|
+
</queries>
|
|
4
11
|
<application />
|
|
5
12
|
</manifest>
|
|
@@ -13,6 +13,7 @@ import android.view.inputmethod.InputMethodManager
|
|
|
13
13
|
import com.g9lynx.g9native.generated.G9HostModuleSpec
|
|
14
14
|
import com.g9lynx.g9native.host.core.G9PreferenceBackend
|
|
15
15
|
import com.g9lynx.g9native.host.core.G9PreferencesCore
|
|
16
|
+
import com.g9lynx.g9native.host.core.G9SafeAreaCore
|
|
16
17
|
import com.lynx.jsbridge.LynxMethod
|
|
17
18
|
import com.lynx.jsbridge.LynxNativeModule
|
|
18
19
|
import com.lynx.react.bridge.Callback
|
|
@@ -86,7 +87,7 @@ class G9HostModule(private val lynxContext: LynxContext) : G9HostModuleSpec(lynx
|
|
|
86
87
|
val density = activity.resources.displayMetrics.density
|
|
87
88
|
val insets = activity.window.decorView.rootWindowInsets
|
|
88
89
|
val rtl = activity.window.decorView.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
|
89
|
-
val px = safeInsetsPx(insets)
|
|
90
|
+
val px = safeInsetsPx(insets, activity.window.decorView)
|
|
90
91
|
val left = px[0] / density
|
|
91
92
|
val right = px[2] / density
|
|
92
93
|
val value =
|
|
@@ -160,13 +161,38 @@ class G9HostModule(private val lynxContext: LynxContext) : G9HostModuleSpec(lynx
|
|
|
160
161
|
* by type, so the soft keyboard never counts (the deprecated `systemWindowInset*` added the IME height when the
|
|
161
162
|
* keyboard was up) and a host that hides the bars (immersive, as the AgriPad MAUI app) gets the cutout only.
|
|
162
163
|
*/
|
|
163
|
-
|
|
164
|
+
/** The cutout plus each system bar only while it shows (G9SafeAreaCore): an immersive host's hidden bars count zero. */
|
|
165
|
+
private fun safeInsetsPx(insets: android.view.WindowInsets?, decor: View): IntArray {
|
|
164
166
|
if (insets == null) return intArrayOf(0, 0, 0, 0)
|
|
165
167
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
fun px(i: android.graphics.Insets) = intArrayOf(i.left, i.top, i.right, i.bottom)
|
|
169
|
+
val status = android.view.WindowInsets.Type.statusBars()
|
|
170
|
+
val navigation = android.view.WindowInsets.Type.navigationBars()
|
|
171
|
+
return G9SafeAreaCore.compute(
|
|
172
|
+
px(insets.getInsets(android.view.WindowInsets.Type.displayCutout())),
|
|
173
|
+
px(insets.getInsets(status)),
|
|
174
|
+
insets.isVisible(status),
|
|
175
|
+
px(insets.getInsets(navigation)),
|
|
176
|
+
insets.isVisible(navigation),
|
|
177
|
+
)
|
|
168
178
|
}
|
|
179
|
+
// Android 10 and older: systemWindowInsets keep a hidden bar's size (LAYOUT_STABLE); the decor view's flags say
|
|
180
|
+
// whether it is hidden. The status bar is the top inset, the navigation bar the others.
|
|
181
|
+
@Suppress("DEPRECATION")
|
|
182
|
+
val flags = decor.systemUiVisibility
|
|
183
|
+
val cutout =
|
|
184
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
185
|
+
insets.displayCutout?.let { intArrayOf(it.safeInsetLeft, it.safeInsetTop, it.safeInsetRight, it.safeInsetBottom) }
|
|
186
|
+
} else {
|
|
187
|
+
null
|
|
188
|
+
} ?: intArrayOf(0, 0, 0, 0)
|
|
169
189
|
@Suppress("DEPRECATION")
|
|
170
|
-
return
|
|
190
|
+
return G9SafeAreaCore.compute(
|
|
191
|
+
cutout,
|
|
192
|
+
intArrayOf(0, insets.systemWindowInsetTop, 0, 0),
|
|
193
|
+
flags and View.SYSTEM_UI_FLAG_FULLSCREEN == 0,
|
|
194
|
+
intArrayOf(insets.systemWindowInsetLeft, 0, insets.systemWindowInsetRight, insets.systemWindowInsetBottom),
|
|
195
|
+
flags and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION == 0,
|
|
196
|
+
)
|
|
171
197
|
}
|
|
172
198
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
package com.g9lynx.g9native
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.ContextWrapper
|
|
7
|
+
import android.content.Intent
|
|
8
|
+
import android.content.pm.PackageManager
|
|
9
|
+
import android.os.Bundle
|
|
10
|
+
import android.os.Handler
|
|
11
|
+
import android.os.Looper
|
|
12
|
+
import android.speech.RecognitionListener
|
|
13
|
+
import android.speech.RecognizerIntent
|
|
14
|
+
import android.speech.SpeechRecognizer
|
|
15
|
+
import androidx.activity.result.ActivityResultLauncher
|
|
16
|
+
import androidx.activity.result.ActivityResultRegistryOwner
|
|
17
|
+
import androidx.activity.result.contract.ActivityResultContracts
|
|
18
|
+
import com.g9lynx.g9native.generated.G9SpeechModuleSpec
|
|
19
|
+
import com.lynx.jsbridge.LynxMethod
|
|
20
|
+
import com.lynx.jsbridge.LynxNativeModule
|
|
21
|
+
import com.lynx.react.bridge.Callback
|
|
22
|
+
import com.lynx.react.bridge.JavaOnlyArray
|
|
23
|
+
import com.lynx.tasm.behavior.LynxContext
|
|
24
|
+
import org.json.JSONObject
|
|
25
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `NativeModules.G9SpeechModule` (`G9SpeechToText`, MAUI `IG9SpeechToText`): Android's `SpeechRecognizer`, one session at
|
|
29
|
+
* a time, on the main thread (the recogniser demands it). Partial hypotheses go out as the global event `g9:speech`
|
|
30
|
+
* (`{"session","text"}`); the `listen` reply settles once. The microphone permission is asked through the activity's
|
|
31
|
+
* `ActivityResultRegistry` — the host's activity must be an `ActivityResultRegistryOwner` (every `ComponentActivity`;
|
|
32
|
+
* androidx.activity is compile-only here, every host of these libraries has it); another activity answers with the
|
|
33
|
+
* current state instead of a prompt.
|
|
34
|
+
* TWIN: ios/src/G9SpeechModule.m.
|
|
35
|
+
*/
|
|
36
|
+
@LynxNativeModule(name = "G9SpeechModule")
|
|
37
|
+
class G9SpeechModule(private val lynxContext: LynxContext) : G9SpeechModuleSpec(lynxContext) {
|
|
38
|
+
private val main = Handler(Looper.getMainLooper())
|
|
39
|
+
|
|
40
|
+
private class Session(val id: String, val recognizer: SpeechRecognizer, val callback: Callback?) {
|
|
41
|
+
var settled = false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private var current: Session? = null
|
|
45
|
+
|
|
46
|
+
private fun activity(): Activity? {
|
|
47
|
+
var context: Context? = lynxContext
|
|
48
|
+
while (context is ContextWrapper) {
|
|
49
|
+
if (context is Activity) return context
|
|
50
|
+
context = context.baseContext
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private fun ok(callback: Callback?, value: Any?) = G9Bridge.reply(callback, mapOf("ok" to true, "value" to value))
|
|
56
|
+
|
|
57
|
+
private fun fail(callback: Callback?, code: String, message: String) =
|
|
58
|
+
G9Bridge.reply(callback, mapOf("ok" to false, "error" to mapOf("code" to code, "message" to message)))
|
|
59
|
+
|
|
60
|
+
private fun micGranted(): Boolean =
|
|
61
|
+
lynxContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
|
62
|
+
|
|
63
|
+
@LynxMethod
|
|
64
|
+
override fun isAvailable(locale: String?, callback: Callback?) {
|
|
65
|
+
// The locale cannot be asked without starting a session (RecognizerIntent.ACTION_GET_LANGUAGE_DETAILS needs a
|
|
66
|
+
// broadcast round-trip that some recognisers never answer): a recogniser that exists is "available"; an unsupported
|
|
67
|
+
// language then ends its session as no-match.
|
|
68
|
+
main.post { ok(callback, SpeechRecognizer.isRecognitionAvailable(lynxContext)) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@LynxMethod
|
|
72
|
+
override fun requestPermission(callback: Callback?) {
|
|
73
|
+
if (micGranted()) return ok(callback, "granted")
|
|
74
|
+
main.post {
|
|
75
|
+
val host = activity()
|
|
76
|
+
try {
|
|
77
|
+
if (host !is ActivityResultRegistryOwner || host.isFinishing) return@post ok(callback, "denied")
|
|
78
|
+
var launcher: ActivityResultLauncher<String>? = null
|
|
79
|
+
launcher =
|
|
80
|
+
host.activityResultRegistry.register("g9.speech.mic." + nextKey.incrementAndGet(), ActivityResultContracts.RequestPermission()) { granted ->
|
|
81
|
+
launcher?.unregister()
|
|
82
|
+
ok(callback, if (granted) "granted" else "denied")
|
|
83
|
+
}
|
|
84
|
+
launcher.launch(Manifest.permission.RECORD_AUDIO)
|
|
85
|
+
} catch (t: Throwable) {
|
|
86
|
+
// No androidx.activity in this host, or the registry refused: say what is true now rather than fail.
|
|
87
|
+
ok(callback, if (micGranted()) "granted" else "denied")
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@LynxMethod
|
|
93
|
+
override fun listen(session: String?, locale: String?, callback: Callback?) {
|
|
94
|
+
val id = session ?: ""
|
|
95
|
+
main.post {
|
|
96
|
+
if (!micGranted()) return@post ok(callback, mapOf("status" to "denied"))
|
|
97
|
+
if (!SpeechRecognizer.isRecognitionAvailable(lynxContext)) return@post fail(callback, "speech-unavailable", "No speech recogniser on this device.")
|
|
98
|
+
current?.let { finish(it, mapOf("status" to "cancelled")) }
|
|
99
|
+
val recognizer =
|
|
100
|
+
try {
|
|
101
|
+
SpeechRecognizer.createSpeechRecognizer(lynxContext)
|
|
102
|
+
} catch (t: Throwable) {
|
|
103
|
+
return@post fail(callback, "speech-recognition", t.message ?: "The recogniser could not start.")
|
|
104
|
+
}
|
|
105
|
+
val s = Session(id, recognizer, callback)
|
|
106
|
+
current = s
|
|
107
|
+
recognizer.setRecognitionListener(listener(s))
|
|
108
|
+
val intent =
|
|
109
|
+
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
|
110
|
+
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
|
111
|
+
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
|
112
|
+
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
|
|
113
|
+
if (!locale.isNullOrBlank()) {
|
|
114
|
+
putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale)
|
|
115
|
+
putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, locale)
|
|
116
|
+
}
|
|
117
|
+
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, lynxContext.packageName)
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
recognizer.startListening(intent)
|
|
121
|
+
} catch (t: Throwable) {
|
|
122
|
+
finishWithError(s, "speech-recognition", t.message ?: "The recogniser could not start.")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@LynxMethod
|
|
128
|
+
override fun cancel(session: String?) {
|
|
129
|
+
main.post {
|
|
130
|
+
val s = current ?: return@post
|
|
131
|
+
if (s.id != session) return@post
|
|
132
|
+
try {
|
|
133
|
+
s.recognizer.cancel()
|
|
134
|
+
} catch (_: Throwable) {
|
|
135
|
+
}
|
|
136
|
+
finish(s, mapOf("status" to "cancelled"))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private fun listener(s: Session) =
|
|
141
|
+
object : RecognitionListener {
|
|
142
|
+
override fun onReadyForSpeech(params: Bundle?) {}
|
|
143
|
+
|
|
144
|
+
override fun onBeginningOfSpeech() {}
|
|
145
|
+
|
|
146
|
+
override fun onRmsChanged(rmsdB: Float) {}
|
|
147
|
+
|
|
148
|
+
override fun onBufferReceived(buffer: ByteArray?) {}
|
|
149
|
+
|
|
150
|
+
override fun onEndOfSpeech() {}
|
|
151
|
+
|
|
152
|
+
override fun onEvent(eventType: Int, params: Bundle?) {}
|
|
153
|
+
|
|
154
|
+
override fun onPartialResults(partialResults: Bundle?) {
|
|
155
|
+
val text = best(partialResults) ?: return
|
|
156
|
+
if (s.settled || text.isEmpty()) return
|
|
157
|
+
val args = JavaOnlyArray().apply { pushString(JSONObject(mapOf("session" to s.id, "text" to text)).toString()) }
|
|
158
|
+
try {
|
|
159
|
+
lynxContext.sendGlobalEvent("g9:speech", args)
|
|
160
|
+
} catch (_: Throwable) {
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
override fun onResults(results: Bundle?) {
|
|
165
|
+
val text = best(results)?.trim().orEmpty()
|
|
166
|
+
finish(s, if (text.isEmpty()) mapOf("status" to "no-match") else mapOf("status" to "recognized", "text" to text))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
override fun onError(error: Int) {
|
|
170
|
+
when (error) {
|
|
171
|
+
SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> finish(s, mapOf("status" to "no-match"))
|
|
172
|
+
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> finish(s, mapOf("status" to "denied"))
|
|
173
|
+
SpeechRecognizer.ERROR_CLIENT -> finish(s, mapOf("status" to "cancelled"))
|
|
174
|
+
else -> finishWithError(s, "speech-recognition", "Speech recognition failed (SpeechRecognizer error $error).")
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private fun best(bundle: Bundle?): String? = bundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()
|
|
180
|
+
|
|
181
|
+
private fun finish(s: Session, value: Map<String, Any?>) {
|
|
182
|
+
if (s.settled) return
|
|
183
|
+
s.settled = true
|
|
184
|
+
release(s)
|
|
185
|
+
ok(s.callback, value)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private fun finishWithError(s: Session, code: String, message: String) {
|
|
189
|
+
if (s.settled) return
|
|
190
|
+
s.settled = true
|
|
191
|
+
release(s)
|
|
192
|
+
fail(s.callback, code, message)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private fun release(s: Session) {
|
|
196
|
+
if (current === s) current = null
|
|
197
|
+
try {
|
|
198
|
+
s.recognizer.destroy()
|
|
199
|
+
} catch (_: Throwable) {
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private companion object {
|
|
204
|
+
val nextKey = AtomicInteger()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Generated by @lynx-js/autolink-codegen. Do not edit.
|
|
2
|
+
// Parameter types: scripts/android-bridge-types.mjs --fix (Lynx's bridge rejects the generator's Object).
|
|
3
|
+
package com.g9lynx.g9native.generated;
|
|
4
|
+
|
|
5
|
+
import androidx.annotation.Nullable;
|
|
6
|
+
import com.lynx.jsbridge.LynxContextModule;
|
|
7
|
+
import com.lynx.jsbridge.LynxMethod;
|
|
8
|
+
import com.lynx.react.bridge.Callback;
|
|
9
|
+
import com.lynx.tasm.behavior.LynxContext;
|
|
10
|
+
|
|
11
|
+
public abstract class G9SpeechModuleSpec extends LynxContextModule {
|
|
12
|
+
public G9SpeechModuleSpec(LynxContext context) {
|
|
13
|
+
super(context);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@LynxMethod
|
|
17
|
+
public abstract void isAvailable(String locale, Callback callback);
|
|
18
|
+
|
|
19
|
+
@LynxMethod
|
|
20
|
+
public abstract void requestPermission(Callback callback);
|
|
21
|
+
|
|
22
|
+
@LynxMethod
|
|
23
|
+
public abstract void listen(String session, String locale, Callback callback);
|
|
24
|
+
|
|
25
|
+
@LynxMethod
|
|
26
|
+
public abstract void cancel(String session);
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package com.g9lynx.g9native.host.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The safe area `G9HostModule.safeInsets` reports, as `[left, top, right, bottom]` px: the display cutout, plus a system bar
|
|
5
|
+
* only while it SHOWS. A host that hides its bars (immersive: they slide in transiently over the page) gets the cutout
|
|
6
|
+
* alone — the bars' own inset is reported whether or not they are hidden on Android 10 and older (`systemWindowInsets`
|
|
7
|
+
* under `SYSTEM_UI_FLAG_LAYOUT_STABLE`), and counting it put every bottom bar and sheet 48 dp above a navigation bar that
|
|
8
|
+
* was not there (AgriPad Lynx on a DOOGEE S96Pro, Android 10, 2026-11-02).
|
|
9
|
+
*/
|
|
10
|
+
object G9SafeAreaCore {
|
|
11
|
+
fun compute(cutout: IntArray, status: IntArray, statusShown: Boolean, navigation: IntArray, navigationShown: Boolean): IntArray {
|
|
12
|
+
val out = cutout.copyOf(4)
|
|
13
|
+
for (side in 0 until 4) {
|
|
14
|
+
if (statusShown) out[side] = maxOf(out[side], status.getOrElse(side) { 0 })
|
|
15
|
+
if (navigationShown) out[side] = maxOf(out[side], navigation.getOrElse(side) { 0 })
|
|
16
|
+
}
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package com.g9lynx.g9native.input
|
|
2
|
+
|
|
3
|
+
import android.text.method.PasswordTransformationMethod
|
|
4
|
+
import android.text.method.ReplacementTransformationMethod
|
|
5
|
+
import android.text.method.SingleLineTransformationMethod
|
|
6
|
+
import android.text.method.TransformationMethod
|
|
7
|
+
import com.g9lynx.g9native.input.core.G9DigitsCore
|
|
8
|
+
import com.lynx.tasm.behavior.Behavior
|
|
9
|
+
import com.lynx.tasm.behavior.LynxContext
|
|
10
|
+
import com.lynx.tasm.behavior.LynxProp
|
|
11
|
+
import com.lynx.tasm.behavior.StylesDiffMap
|
|
12
|
+
import com.lynx.tasm.behavior.shadow.ShadowNode
|
|
13
|
+
import com.lynx.tasm.behavior.ui.LynxUI
|
|
14
|
+
import com.lynx.xelement.input.LynxEditTextView
|
|
15
|
+
import com.lynx.xelement.input.LynxUIInput
|
|
16
|
+
import com.lynx.xelement.input.LynxUIInputShadowNode
|
|
17
|
+
import com.lynx.xelement.input.LynxUITextArea
|
|
18
|
+
import com.lynx.xelement.input.LynxUITextAreaShadowNode
|
|
19
|
+
|
|
20
|
+
/** Draws the field's ASCII digits in the culture's digits (G9DigitsCore); the text itself is never changed. */
|
|
21
|
+
internal class G9DigitsTransformation(private val pairs: Pair<CharArray, CharArray>) : ReplacementTransformationMethod() {
|
|
22
|
+
val key: String = String(pairs.first) + String(pairs.second)
|
|
23
|
+
|
|
24
|
+
override fun getOriginal(): CharArray = pairs.first
|
|
25
|
+
|
|
26
|
+
override fun getReplacement(): CharArray = pairs.second
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The digits state of one field: the props it was given and the transformation it applies. */
|
|
30
|
+
internal class G9FieldDigits(private val singleLine: Boolean) {
|
|
31
|
+
var system: String? = null
|
|
32
|
+
var decimalSeparator: String? = null
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Puts the digit transformation on `view` after xelement-input's own props ran (it sets SingleLine / Password there).
|
|
36
|
+
* A password keeps its mask; a latin field gets back what xelement-input would have set.
|
|
37
|
+
*/
|
|
38
|
+
fun apply(view: LynxEditTextView) {
|
|
39
|
+
val current: TransformationMethod? = view.transformationMethod
|
|
40
|
+
if (current is PasswordTransformationMethod) return
|
|
41
|
+
val pairs = G9DigitsCore.pairs(system, decimalSeparator, singleLine)
|
|
42
|
+
val wanted: TransformationMethod? =
|
|
43
|
+
when {
|
|
44
|
+
pairs != null -> {
|
|
45
|
+
if (current is G9DigitsTransformation && current.key == G9DigitsTransformation(pairs).key) return
|
|
46
|
+
G9DigitsTransformation(pairs)
|
|
47
|
+
}
|
|
48
|
+
current is G9DigitsTransformation -> if (singleLine) SingleLineTransformationMethod.getInstance() else null
|
|
49
|
+
else -> return
|
|
50
|
+
}
|
|
51
|
+
val start = view.selectionStart
|
|
52
|
+
val end = view.selectionEnd
|
|
53
|
+
view.transformationMethod = wanted
|
|
54
|
+
val length = view.text?.length ?: 0
|
|
55
|
+
if (start in 0..length && end in 0..length) view.setSelection(start, end)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** xelement-input's `<input>` with `g9-digits` / `g9-decimal-separator` (`@g9tm/lynxcontrols` G9TextEntry sets them). */
|
|
60
|
+
class G9UIInput(context: LynxContext) : LynxUIInput(context) {
|
|
61
|
+
private val digits = G9FieldDigits(singleLine = true)
|
|
62
|
+
|
|
63
|
+
@LynxProp(name = "g9-digits")
|
|
64
|
+
fun setG9Digits(value: String?) {
|
|
65
|
+
digits.system = value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@LynxProp(name = "g9-decimal-separator")
|
|
69
|
+
fun setG9DecimalSeparator(value: String?) {
|
|
70
|
+
digits.decimalSeparator = value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
override fun afterPropsUpdated(props: StylesDiffMap?) {
|
|
74
|
+
super.afterPropsUpdated(props)
|
|
75
|
+
digits.apply(view)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** xelement-input's `<textarea>` with the same digit props (G9Editor). */
|
|
80
|
+
class G9UITextArea(context: LynxContext) : LynxUITextArea(context) {
|
|
81
|
+
private val digits = G9FieldDigits(singleLine = false)
|
|
82
|
+
|
|
83
|
+
@LynxProp(name = "g9-digits")
|
|
84
|
+
fun setG9Digits(value: String?) {
|
|
85
|
+
digits.system = value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@LynxProp(name = "g9-decimal-separator")
|
|
89
|
+
fun setG9DecimalSeparator(value: String?) {
|
|
90
|
+
digits.decimalSeparator = value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override fun afterPropsUpdated(props: StylesDiffMap?) {
|
|
94
|
+
super.afterPropsUpdated(props)
|
|
95
|
+
digits.apply(view)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `input` and `textarea` for a host that registers xelement-input itself (an unregistered tag breaks the page, so a
|
|
101
|
+
* host registers exactly one of each): use these instead of `LynxUIInput` / `LynxUITextArea` and the fields draw the
|
|
102
|
+
* culture's digits. The host must still depend on `org.lynxsdk.lynx:xelement-input`.
|
|
103
|
+
*/
|
|
104
|
+
object G9InputBehaviors {
|
|
105
|
+
fun create(): List<Behavior> =
|
|
106
|
+
listOf(
|
|
107
|
+
object : Behavior("input", false, false, false) {
|
|
108
|
+
override fun createUI(context: LynxContext): LynxUI<*> = G9UIInput(context)
|
|
109
|
+
|
|
110
|
+
override fun createShadowNode(): ShadowNode = LynxUIInputShadowNode()
|
|
111
|
+
},
|
|
112
|
+
object : Behavior("textarea", false, false, false) {
|
|
113
|
+
override fun createUI(context: LynxContext): LynxUI<*> = G9UITextArea(context)
|
|
114
|
+
|
|
115
|
+
override fun createShadowNode(): ShadowNode = LynxUITextAreaShadowNode()
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package com.g9lynx.g9native.input.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a field DRAWS for the ASCII digits it holds (MAUI: the FaNum font draws «15» as «۱۵»; AgriPad owner 2026-11-02:
|
|
5
|
+
* «Lynx shows Persian too, but what is recorded is ASCII»). Pure, so the JVM suite tests it: the native input applies the
|
|
6
|
+
* pairs as a `ReplacementTransformationMethod`, which changes only what is drawn — the text, the value events and the
|
|
7
|
+
* selection keep the ASCII characters one for one.
|
|
8
|
+
*/
|
|
9
|
+
object G9DigitsCore {
|
|
10
|
+
/** `g9-digits` values (the culture's numbering system, foundation `G9NumberingSystem`). */
|
|
11
|
+
const val LATIN = "latin"
|
|
12
|
+
const val PERSIAN = "persian"
|
|
13
|
+
const val ARABIC_INDIC = "arabicIndic"
|
|
14
|
+
|
|
15
|
+
/** The zero of `system`, or `null` when the field draws ASCII (latin, empty, unknown). */
|
|
16
|
+
fun zeroOf(system: String?): Char? =
|
|
17
|
+
when (system) {
|
|
18
|
+
PERSIAN -> '۰'
|
|
19
|
+
ARABIC_INDIC -> '٠'
|
|
20
|
+
else -> null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The characters to replace and their replacements (same length), or `null` when nothing is replaced.
|
|
25
|
+
* `singleLine` keeps what `SingleLineTransformationMethod` does (newline → space, CR → U+FEFF), since this method
|
|
26
|
+
* replaces it on a single-line input; `decimalSeparator` (one character, e.g. «٫») is drawn for the ASCII '.'.
|
|
27
|
+
*/
|
|
28
|
+
fun pairs(system: String?, decimalSeparator: String?, singleLine: Boolean): Pair<CharArray, CharArray>? {
|
|
29
|
+
val zero = zeroOf(system) ?: return null
|
|
30
|
+
val from = StringBuilder("0123456789")
|
|
31
|
+
val to = StringBuilder()
|
|
32
|
+
for (d in 0..9) to.append(zero + d)
|
|
33
|
+
val separator = decimalSeparator?.takeIf { it.length == 1 && it != "." }
|
|
34
|
+
if (separator != null) {
|
|
35
|
+
from.append('.')
|
|
36
|
+
to.append(separator)
|
|
37
|
+
}
|
|
38
|
+
if (singleLine) {
|
|
39
|
+
from.append('\n').append('\r')
|
|
40
|
+
to.append(' ').append('')
|
|
41
|
+
}
|
|
42
|
+
return from.toString().toCharArray() to to.toString().toCharArray()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What `pairs` draws for `text` (the tests' view of the transformation). */
|
|
46
|
+
fun draw(text: String, pairs: Pair<CharArray, CharArray>?): String {
|
|
47
|
+
if (pairs == null) return text
|
|
48
|
+
val (from, to) = pairs
|
|
49
|
+
val out = StringBuilder(text.length)
|
|
50
|
+
for (c in text) {
|
|
51
|
+
val i = from.indexOf(c)
|
|
52
|
+
out.append(if (i >= 0) to[i] else c)
|
|
53
|
+
}
|
|
54
|
+
return out.toString()
|
|
55
|
+
}
|
|
56
|
+
}
|