@livx.cc/appwrap 0.23.2 → 0.25.0
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/package.json +1 -1
- package/runtime/App_Resources/Android/src/main/res/xml/file_paths.xml +7 -1
- package/runtime/app/main-page.ts +4 -0
- package/runtime/app/shell/capabilities.manifest.ts +64 -12
- package/runtime/app/shell/config.ts +9 -0
- package/runtime/app/shell/custom-webview.android.ts +2 -2
- package/runtime/app/shell/custom-webview.ios.ts +3 -2
- package/runtime/app/shell/events.ts +11 -0
- package/runtime/app/shell/handlers-android.ts +43 -0
- package/runtime/app/shell/handlers-extended.ts +4 -1
- package/runtime/app/shell/handlers-fs.ts +372 -0
- package/runtime/app/shell/handlers-oauth.ts +90 -1
- package/runtime/app/shell/handlers-parity.ts +41 -13
- package/runtime/app/shell/handlers-push.ts +24 -1
- package/runtime/app/shell/handlers-reviews.ts +73 -0
- package/runtime/app/shell/handlers-scanner.ts +222 -0
- package/runtime/app/shell/handlers-speech.ts +336 -0
- package/runtime/app/shell/ios-frameworks.d.ts +8 -0
- package/runtime/app/shell/keyboard.ts +85 -0
- package/runtime/app/shell/web-quirks.ts +58 -0
- package/runtime/package.json +1 -1
- package/src/cli.ts +29 -2
- package/src/config.ts +11 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
import { requestPermissions, startActivityForResult } from './android-helpers';
|
|
4
|
+
|
|
5
|
+
// AVFoundation / UIKit globals (NS auto-links the frameworks via metadata when referenced).
|
|
6
|
+
declare const AVCaptureSession: any;
|
|
7
|
+
declare const AVCaptureDevice: any;
|
|
8
|
+
declare const AVCaptureDeviceInput: any;
|
|
9
|
+
declare const AVCaptureMetadataOutput: any;
|
|
10
|
+
declare const AVCaptureVideoPreviewLayer: any;
|
|
11
|
+
declare const AVMetadataObjectTypeQRCode: any;
|
|
12
|
+
declare const AVMetadataObjectTypeEAN13Code: any;
|
|
13
|
+
declare const AVMetadataObjectTypeCode128Code: any;
|
|
14
|
+
declare const AVLayerVideoGravityResizeAspectFill: any;
|
|
15
|
+
declare const AVCaptureDevicePositionFront: any;
|
|
16
|
+
declare const AVCaptureDevicePositionBack: any;
|
|
17
|
+
declare const AVCaptureDeviceDiscoverySession: any;
|
|
18
|
+
declare const AVMediaTypeVideo: any;
|
|
19
|
+
declare const AVCaptureDeviceTypeBuiltInWideAngleCamera: any;
|
|
20
|
+
declare const AVCaptureMetadataOutputObjectsDelegate: any;
|
|
21
|
+
// `dispatch_get_main_queue()` is a C macro, NOT a bridged symbol — NativeScript can't see it
|
|
22
|
+
// ("is not defined" at runtime). `dispatch_queue_create` IS a real bridged libdispatch function;
|
|
23
|
+
// a dedicated serial queue is the correct delegate queue for AVCaptureMetadataOutput anyway.
|
|
24
|
+
declare function dispatch_queue_create(label: string, attr: any): any;
|
|
25
|
+
declare const android: any, com: any;
|
|
26
|
+
|
|
27
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Camera barcode / QR decoding. iOS: AVCaptureMetadataOutput in a full-screen capture VC presented
|
|
31
|
+
* over the WebView; first machine-readable code → stop session, dismiss, resolve. Android: ZXing's
|
|
32
|
+
* own capture Activity (zxing-android-embedded) launched via startActivityForResult.
|
|
33
|
+
*
|
|
34
|
+
* Contract: `scanner.scan` resolves `{ value, format, bounds? }` on first decode, or
|
|
35
|
+
* `{ cancelled: true }` if the user dismisses (or `scanner.cancel` fires). It never rejects on a
|
|
36
|
+
* plain cancel — only on a hard failure (no camera, permission denied at the OS level).
|
|
37
|
+
*/
|
|
38
|
+
export function registerScannerHandlers(): void {
|
|
39
|
+
if (isIOS) registerIos();
|
|
40
|
+
else if (isAndroid) registerAndroid();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Map our small enum → the platform metadata-object types. Empty/`all` = the full supported set. */
|
|
44
|
+
function iosFormatTypes(formats: any): any[] {
|
|
45
|
+
const all = [AVMetadataObjectTypeQRCode, AVMetadataObjectTypeEAN13Code, AVMetadataObjectTypeCode128Code];
|
|
46
|
+
const want = Array.isArray(formats) ? formats : formats ? [formats] : [];
|
|
47
|
+
const sel = want.filter((f: string) => f && f !== 'all');
|
|
48
|
+
if (!sel.length) return all;
|
|
49
|
+
const map: Record<string, any> = {
|
|
50
|
+
qr: AVMetadataObjectTypeQRCode,
|
|
51
|
+
ean13: AVMetadataObjectTypeEAN13Code,
|
|
52
|
+
code128: AVMetadataObjectTypeCode128Code,
|
|
53
|
+
};
|
|
54
|
+
return sel.map((f: string) => map[f]).filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function iosFormatName(type: any): string {
|
|
58
|
+
if (type === AVMetadataObjectTypeQRCode) return 'qr';
|
|
59
|
+
if (type === AVMetadataObjectTypeEAN13Code) return 'ean13';
|
|
60
|
+
if (type === AVMetadataObjectTypeCode128Code) return 'code128';
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function registerIos(): void {
|
|
65
|
+
// Strong refs while the scanner is up — ARC would otherwise free the session/VC/delegate the moment
|
|
66
|
+
// the JS locals fall out of scope, killing capture mid-stream (same hazard as handlers-oauth).
|
|
67
|
+
let activeSession: any = null;
|
|
68
|
+
let activeVC: any = null;
|
|
69
|
+
let activeDelegate: any = null;
|
|
70
|
+
let pending: { resolve: (v: any) => void } | null = null;
|
|
71
|
+
|
|
72
|
+
const dismiss = () => {
|
|
73
|
+
Utils.dispatchToMainThread(() => {
|
|
74
|
+
try { activeSession?.stopRunning(); } catch { /* already stopped */ }
|
|
75
|
+
activeVC?.dismissViewControllerAnimatedCompletion(true, null);
|
|
76
|
+
activeSession = null;
|
|
77
|
+
activeVC = null;
|
|
78
|
+
activeDelegate = null;
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const settle = (value: any) => {
|
|
83
|
+
const p = pending;
|
|
84
|
+
pending = null;
|
|
85
|
+
dismiss();
|
|
86
|
+
// The metadata delegate now fires on a background serial queue; the bridge reply drives
|
|
87
|
+
// WKWebView.evaluateJavaScript, which must run on the main thread — so hop back to resolve.
|
|
88
|
+
Utils.dispatchToMainThread(() => p?.resolve(value));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
bridge.register('scanner.cancel', () => { if (pending) settle({ cancelled: true }); });
|
|
92
|
+
|
|
93
|
+
bridge.register('scanner.scan', (params: any) =>
|
|
94
|
+
new Promise((resolve, reject) => {
|
|
95
|
+
if (pending) { reject(err('NATIVE_ERROR', 'scanner.scan: a scan is already in progress')); return; }
|
|
96
|
+
pending = { resolve };
|
|
97
|
+
|
|
98
|
+
Utils.dispatchToMainThread(() => {
|
|
99
|
+
try {
|
|
100
|
+
const wantFront = params?.camera === 'front';
|
|
101
|
+
const position = wantFront ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack;
|
|
102
|
+
const discovery = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypesMediaTypePosition(
|
|
103
|
+
[AVCaptureDeviceTypeBuiltInWideAngleCamera], AVMediaTypeVideo, position
|
|
104
|
+
);
|
|
105
|
+
const device = discovery?.devices?.firstObject ?? AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo);
|
|
106
|
+
if (!device) { pending = null; reject(err('UNSUPPORTED', 'No camera available')); return; }
|
|
107
|
+
|
|
108
|
+
const input = AVCaptureDeviceInput.deviceInputWithDeviceError(device, null);
|
|
109
|
+
if (!input) { pending = null; reject(err('NATIVE_ERROR', 'Cannot open camera input')); return; }
|
|
110
|
+
|
|
111
|
+
const session = AVCaptureSession.new();
|
|
112
|
+
if (!session.canAddInput(input)) { pending = null; reject(err('NATIVE_ERROR', 'Cannot add camera input')); return; }
|
|
113
|
+
session.addInput(input);
|
|
114
|
+
|
|
115
|
+
const output = AVCaptureMetadataOutput.new();
|
|
116
|
+
if (!session.canAddOutput(output)) { pending = null; reject(err('NATIVE_ERROR', 'Cannot add metadata output')); return; }
|
|
117
|
+
session.addOutput(output);
|
|
118
|
+
|
|
119
|
+
// Delegate fires on a dedicated serial queue (see dispatch_queue_create below); settle()
|
|
120
|
+
// hops back to the main thread for the WKWebView bridge reply.
|
|
121
|
+
const DelegateClass = (NSObject as any).extend(
|
|
122
|
+
{
|
|
123
|
+
captureOutputDidOutputMetadataObjectsFromConnection(_out: any, objects: any, _conn: any) {
|
|
124
|
+
if (!pending) return;
|
|
125
|
+
const obj = objects?.firstObject;
|
|
126
|
+
const value = obj?.stringValue;
|
|
127
|
+
if (value == null) return;
|
|
128
|
+
settle({ value: String(value), format: iosFormatName(obj.type) });
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
// The protocol MUST be declared: NativeScript uses it to map the camelCase JS method to the
|
|
132
|
+
// colonated selector `captureOutput:didOutputMetadataObjects:fromConnection:`. With an empty
|
|
133
|
+
// protocols list the method registers under the wrong selector and AVFoundation never calls
|
|
134
|
+
// it — the scanner opens but never decodes.
|
|
135
|
+
{ protocols: [AVCaptureMetadataOutputObjectsDelegate] }
|
|
136
|
+
);
|
|
137
|
+
activeDelegate = DelegateClass.new();
|
|
138
|
+
output.setMetadataObjectsDelegateQueue(activeDelegate, dispatch_queue_create('cc.livx.scanner.metadata', null));
|
|
139
|
+
// metadataObjectTypes must be set AFTER addOutput (the available set is session-derived).
|
|
140
|
+
output.metadataObjectTypes = iosFormatTypes(params?.formats) as any;
|
|
141
|
+
|
|
142
|
+
const vc = UIViewController.new();
|
|
143
|
+
const preview = AVCaptureVideoPreviewLayer.layerWithSession(session);
|
|
144
|
+
preview.videoGravity = AVLayerVideoGravityResizeAspectFill;
|
|
145
|
+
preview.frame = vc.view.bounds;
|
|
146
|
+
vc.view.layer.addSublayer(preview);
|
|
147
|
+
|
|
148
|
+
// Cancel button so the user can dismiss without a successful read.
|
|
149
|
+
const btn = UIButton.buttonWithType(0 /* UIButtonTypeCustom */);
|
|
150
|
+
btn.setTitleForState('Cancel', 0 /* UIControlStateNormal */);
|
|
151
|
+
btn.frame = CGRectMake(16, 56, 100, 40);
|
|
152
|
+
const Target = (NSObject as any).extend(
|
|
153
|
+
{ tap() { if (pending) settle({ cancelled: true }); } },
|
|
154
|
+
{ exposedMethods: { tap: { returns: interop.types.void } } }
|
|
155
|
+
);
|
|
156
|
+
activeVC = vc;
|
|
157
|
+
(vc as any)._cancelTarget = Target.new(); // retain via the (also-retained) VC
|
|
158
|
+
btn.addTargetActionForControlEvents((vc as any)._cancelTarget, 'tap', 1 << 6 /* TouchUpInside */);
|
|
159
|
+
vc.view.addSubview(btn);
|
|
160
|
+
|
|
161
|
+
activeSession = session;
|
|
162
|
+
session.startRunning();
|
|
163
|
+
|
|
164
|
+
const root = Utils.ios.getRootViewController();
|
|
165
|
+
vc.modalPresentationStyle = 0; // fullScreen
|
|
166
|
+
root.presentViewControllerAnimatedCompletion(vc, true, null);
|
|
167
|
+
} catch (e: any) {
|
|
168
|
+
pending = null;
|
|
169
|
+
reject(err('NATIVE_ERROR', e?.message ?? 'scanner failed to start'));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function registerAndroid(): void {
|
|
177
|
+
// ZXing-android-embedded ships its own capture Activity (CaptureActivity). We build the launch
|
|
178
|
+
// Intent via IntentIntegrator, run it through startActivityForResult, and parse the result —
|
|
179
|
+
// no hand-built Camera2 preview. IntentResult carries the decoded text + format name.
|
|
180
|
+
const formatName = (zxingName: string): string => {
|
|
181
|
+
const n = String(zxingName ?? '').toUpperCase();
|
|
182
|
+
if (n === 'QR_CODE') return 'qr';
|
|
183
|
+
if (n === 'EAN_13') return 'ean13';
|
|
184
|
+
if (n === 'CODE_128') return 'code128';
|
|
185
|
+
return 'unknown';
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
bridge.register('scanner.cancel', () => {
|
|
189
|
+
// The ZXing capture Activity owns its own back/up dismissal; there's no in-process handle to it
|
|
190
|
+
// once launched. Cancel is honored by the user pressing back, which returns a null-contents result.
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
bridge.register('scanner.scan', async (params: any) => {
|
|
194
|
+
const granted = await requestPermissions(['android.permission.CAMERA']);
|
|
195
|
+
if (!granted) throw err('DENIED', 'Camera permission denied');
|
|
196
|
+
|
|
197
|
+
const activity = Utils.android.getCurrentActivity?.() ?? undefined;
|
|
198
|
+
const integrator = activity
|
|
199
|
+
? new com.google.zxing.integration.android.IntentIntegrator(activity)
|
|
200
|
+
: new com.google.zxing.integration.android.IntentIntegrator(Utils.android.getApplicationContext());
|
|
201
|
+
|
|
202
|
+
// Restrict to our format set (or all). IntentIntegrator names match ZXing BarcodeFormat names.
|
|
203
|
+
const want = Array.isArray(params?.formats) ? params.formats : params?.formats ? [params.formats] : [];
|
|
204
|
+
const sel = want.filter((f: string) => f && f !== 'all');
|
|
205
|
+
if (sel.length) {
|
|
206
|
+
const map: Record<string, string> = { qr: 'QR_CODE', ean13: 'EAN_13', code128: 'CODE_128' };
|
|
207
|
+
const list = new java.util.ArrayList();
|
|
208
|
+
sel.forEach((f: string) => { const z = map[f]; if (z) list.add(z); });
|
|
209
|
+
if (list.size()) integrator.setDesiredBarcodeFormats(list);
|
|
210
|
+
}
|
|
211
|
+
integrator.setBeepEnabled(false);
|
|
212
|
+
integrator.setOrientationLocked(false);
|
|
213
|
+
|
|
214
|
+
const intent = integrator.createScanIntent();
|
|
215
|
+
const { resultCode, intent: data } = await startActivityForResult(intent);
|
|
216
|
+
|
|
217
|
+
const result = com.google.zxing.integration.android.IntentIntegrator.parseActivityResult(resultCode, data);
|
|
218
|
+
const contents = result?.getContents?.();
|
|
219
|
+
if (contents == null) return { cancelled: true }; // user backed out
|
|
220
|
+
return { value: String(contents), format: formatName(result.getFormatName?.()) };
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
import { requestPermissions } from './android-helpers';
|
|
4
|
+
|
|
5
|
+
// AVFoundation / Speech globals (NS auto-links the frameworks via metadata when referenced).
|
|
6
|
+
declare const AVSpeechSynthesizer: any;
|
|
7
|
+
declare const AVSpeechUtterance: any;
|
|
8
|
+
declare const AVSpeechSynthesisVoice: any;
|
|
9
|
+
declare const AVAudioEngine: any;
|
|
10
|
+
declare const AVAudioSession: any;
|
|
11
|
+
declare const AVAudioSessionCategoryPlayAndRecord: any;
|
|
12
|
+
declare const AVAudioSessionModeMeasurement: any;
|
|
13
|
+
declare const SFSpeechRecognizer: any;
|
|
14
|
+
declare const SFSpeechAudioBufferRecognitionRequest: any;
|
|
15
|
+
declare const SFSpeechRecognizerAuthorizationStatusAuthorized: any;
|
|
16
|
+
declare const NSLocale: any;
|
|
17
|
+
declare const android: any;
|
|
18
|
+
declare const java: any;
|
|
19
|
+
|
|
20
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Voice I/O. TTS: iOS AVSpeechSynthesizer / Android TextToSpeech — speak resolves on finish.
|
|
24
|
+
* STT: iOS SFSpeechRecognizer + AVAudioEngine mic tap / Android SpeechRecognizer — listen resolves
|
|
25
|
+
* the FINAL transcript; partials emit `speech.partial` while listening; stopListening resolves with
|
|
26
|
+
* the best-so-far transcript.
|
|
27
|
+
*
|
|
28
|
+
* DEVICE-UNVERIFIED (any-typed FFI, no device run this session): the iOS AVAudioEngine tap +
|
|
29
|
+
* SFSpeechRecognizer authorization/lifecycle, and the Android SpeechRecognizer onResults/
|
|
30
|
+
* onPartialResults threading + TextToSpeech init race. Compile-clean only.
|
|
31
|
+
*/
|
|
32
|
+
export function registerSpeechHandlers(): void {
|
|
33
|
+
if (isIOS) registerIos();
|
|
34
|
+
else if (isAndroid) registerAndroid();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function registerIos(): void {
|
|
38
|
+
// Strong refs while a session is live — ARC would otherwise free the synthesizer/engine/delegate
|
|
39
|
+
// the moment the JS locals fall out of scope (same hazard as handlers-scanner/oauth).
|
|
40
|
+
let synth: any = null;
|
|
41
|
+
let speakDelegate: any = null;
|
|
42
|
+
let speakPending: { resolve: () => void } | null = null;
|
|
43
|
+
|
|
44
|
+
let engine: any = null;
|
|
45
|
+
let recognizer: any = null;
|
|
46
|
+
let request: any = null;
|
|
47
|
+
let task: any = null;
|
|
48
|
+
let listenPending: { resolve: (v: string) => void; reject: (e: any) => void } | null = null;
|
|
49
|
+
let best = '';
|
|
50
|
+
|
|
51
|
+
const teardownListen = () => {
|
|
52
|
+
try { task?.cancel?.(); } catch { /* noop */ }
|
|
53
|
+
try { request?.endAudio?.(); } catch { /* noop */ }
|
|
54
|
+
try {
|
|
55
|
+
engine?.inputNode?.removeTapOnBus?.(0);
|
|
56
|
+
engine?.stop?.();
|
|
57
|
+
} catch { /* noop */ }
|
|
58
|
+
task = null; request = null; engine = null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const settleListen = () => {
|
|
62
|
+
const p = listenPending;
|
|
63
|
+
listenPending = null;
|
|
64
|
+
teardownListen();
|
|
65
|
+
p?.resolve(best);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
bridge.register('speech.speak', (params: any) =>
|
|
69
|
+
new Promise<void>((resolve, reject) => {
|
|
70
|
+
Utils.dispatchToMainThread(() => {
|
|
71
|
+
try {
|
|
72
|
+
if (!synth) {
|
|
73
|
+
synth = AVSpeechSynthesizer.new();
|
|
74
|
+
const DelegateClass = (NSObject as any).extend(
|
|
75
|
+
{
|
|
76
|
+
speechSynthesizerDidFinishSpeechUtterance(_s: any, _u: any) {
|
|
77
|
+
const p = speakPending; speakPending = null; p?.resolve();
|
|
78
|
+
},
|
|
79
|
+
speechSynthesizerDidCancelSpeechUtterance(_s: any, _u: any) {
|
|
80
|
+
const p = speakPending; speakPending = null; p?.resolve();
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{ protocols: [] }
|
|
84
|
+
);
|
|
85
|
+
speakDelegate = DelegateClass.new();
|
|
86
|
+
synth.delegate = speakDelegate;
|
|
87
|
+
}
|
|
88
|
+
// A new speak resolves any prior pending (its utterance is being superseded).
|
|
89
|
+
speakPending?.resolve();
|
|
90
|
+
const u = AVSpeechUtterance.speechUtteranceWithString(String(params?.text ?? ''));
|
|
91
|
+
if (params?.voice) {
|
|
92
|
+
const v = AVSpeechSynthesisVoice.voiceWithIdentifier(String(params.voice));
|
|
93
|
+
if (v) u.voice = v;
|
|
94
|
+
} else if (params?.lang) {
|
|
95
|
+
const v = AVSpeechSynthesisVoice.voiceWithLanguage(String(params.lang));
|
|
96
|
+
if (v) u.voice = v;
|
|
97
|
+
}
|
|
98
|
+
if (params?.rate != null) u.rate = Number(params.rate) * AVSpeechUtterance.alloc().rate; // relative scale
|
|
99
|
+
if (params?.pitch != null) u.pitchMultiplier = Number(params.pitch);
|
|
100
|
+
speakPending = { resolve };
|
|
101
|
+
synth.speakUtterance(u);
|
|
102
|
+
} catch (e: any) {
|
|
103
|
+
reject(err('NATIVE_ERROR', e?.message ?? 'speak failed'));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
bridge.register('speech.stop', () => {
|
|
110
|
+
Utils.dispatchToMainThread(() => {
|
|
111
|
+
try { synth?.stopSpeakingAtBoundary?.(0 /* immediate */); } catch { /* noop */ }
|
|
112
|
+
const p = speakPending; speakPending = null; p?.resolve();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
bridge.register('speech.voices', () => {
|
|
117
|
+
const voices = AVSpeechSynthesisVoice.speechVoices();
|
|
118
|
+
const out: Array<{ id: string; name: string; lang: string }> = [];
|
|
119
|
+
const n = voices?.count ?? 0;
|
|
120
|
+
for (let i = 0; i < n; i++) {
|
|
121
|
+
const v = voices.objectAtIndex(i);
|
|
122
|
+
out.push({ id: String(v.identifier), name: String(v.name), lang: String(v.language) });
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
bridge.register('speech.stopListening', () => {
|
|
128
|
+
// End audio → the recognizer flushes a final result, then we settle with best-so-far.
|
|
129
|
+
try { engine?.inputNode?.removeTapOnBus?.(0); engine?.stop?.(); request?.endAudio?.(); } catch { /* noop */ }
|
|
130
|
+
if (listenPending) settleListen();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
bridge.register('speech.listen', (params: any) =>
|
|
134
|
+
new Promise<string>((resolve, reject) => {
|
|
135
|
+
if (listenPending) { reject(err('NATIVE_ERROR', 'speech.listen: already listening')); return; }
|
|
136
|
+
best = '';
|
|
137
|
+
listenPending = { resolve, reject };
|
|
138
|
+
|
|
139
|
+
SFSpeechRecognizer.requestAuthorization((status: any) => {
|
|
140
|
+
if (status !== SFSpeechRecognizerAuthorizationStatusAuthorized) {
|
|
141
|
+
const p = listenPending; listenPending = null;
|
|
142
|
+
p?.reject(err('DENIED', 'Speech recognition not authorized'));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
Utils.dispatchToMainThread(() => {
|
|
146
|
+
try {
|
|
147
|
+
// Pick a locale SFSpeechRecognizer has a model for: try the requested lang, the device
|
|
148
|
+
// language code, then en-US (always supported) as a last resort. Do NOT gate on
|
|
149
|
+
// `isAvailable` — it's unreliable SYNCHRONOUSLY right after init (becomes true async via
|
|
150
|
+
// the availabilityDidChange delegate), so checking it here wrongly rejects a good recognizer
|
|
151
|
+
// (proven on-device: en-US reported unavailable). Accept any non-nil recognizer; a genuine
|
|
152
|
+
// failure surfaces in the recognitionTask error callback instead.
|
|
153
|
+
const tryRec = (id: string) => {
|
|
154
|
+
try { return SFSpeechRecognizer.alloc().initWithLocale(NSLocale.localeWithLocaleIdentifier(id)) || null; }
|
|
155
|
+
catch { return null; }
|
|
156
|
+
};
|
|
157
|
+
let langCode = 'en';
|
|
158
|
+
try { langCode = String(NSLocale.currentLocale.languageCode || 'en'); } catch { /* default */ }
|
|
159
|
+
const tried = params?.lang ? [String(params.lang)] : [langCode, 'en-US'];
|
|
160
|
+
recognizer = null;
|
|
161
|
+
for (const id of tried) { recognizer = tryRec(id); if (recognizer) break; }
|
|
162
|
+
if (!recognizer) {
|
|
163
|
+
const p = listenPending; listenPending = null;
|
|
164
|
+
p?.reject(err('UNSUPPORTED', `Speech recognizer unavailable (tried: ${tried.join(', ')})`));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const session = AVAudioSession.sharedInstance();
|
|
169
|
+
session.setCategoryModeOptionsError(AVAudioSessionCategoryPlayAndRecord, AVAudioSessionModeMeasurement, 1 /* duckOthers */, null);
|
|
170
|
+
session.setActiveWithOptionsError(true, 0, null);
|
|
171
|
+
|
|
172
|
+
engine = AVAudioEngine.new();
|
|
173
|
+
request = SFSpeechAudioBufferRecognitionRequest.new();
|
|
174
|
+
request.shouldReportPartialResults = !!params?.partial;
|
|
175
|
+
|
|
176
|
+
task = recognizer.recognitionTaskWithRequestResultHandler(request, (result: any, error: any) => {
|
|
177
|
+
if (result) {
|
|
178
|
+
best = String(result.bestTranscription?.formattedString ?? best);
|
|
179
|
+
if (params?.partial && !result.isFinal) bridge.emit('speech.partial', { transcript: best });
|
|
180
|
+
if (result.isFinal && listenPending) settleListen();
|
|
181
|
+
}
|
|
182
|
+
if (error && listenPending) {
|
|
183
|
+
const p = listenPending; listenPending = null;
|
|
184
|
+
teardownListen();
|
|
185
|
+
p?.resolve(best); // resolve with whatever we have — stopListening contract
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const input = engine.inputNode;
|
|
190
|
+
const format = input.outputFormatForBus(0);
|
|
191
|
+
input.installTapOnBusBufferSizeFormatBlock(0, 1024, format, (buffer: any, _when: any) => {
|
|
192
|
+
request?.appendAudioPCMBuffer(buffer);
|
|
193
|
+
});
|
|
194
|
+
engine.prepare();
|
|
195
|
+
engine.startAndReturnError(null);
|
|
196
|
+
} catch (e: any) {
|
|
197
|
+
const p = listenPending; listenPending = null;
|
|
198
|
+
teardownListen();
|
|
199
|
+
p?.reject(err('NATIVE_ERROR', e?.message ?? 'listen failed'));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
})
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function registerAndroid(): void {
|
|
208
|
+
// TextToSpeech + SpeechRecognizer are plain Java → direct interop, no kotlin flag, no gradle dep.
|
|
209
|
+
let tts: any = null;
|
|
210
|
+
let ttsReady = false;
|
|
211
|
+
const ctx = () => Utils.android.getApplicationContext();
|
|
212
|
+
|
|
213
|
+
const ensureTts = (): Promise<any> =>
|
|
214
|
+
new Promise((resolve) => {
|
|
215
|
+
if (tts && ttsReady) { resolve(tts); return; }
|
|
216
|
+
tts = new android.speech.tts.TextToSpeech(
|
|
217
|
+
ctx(),
|
|
218
|
+
new android.speech.tts.TextToSpeech.OnInitListener({
|
|
219
|
+
onInit(status: number) {
|
|
220
|
+
ttsReady = status === android.speech.tts.TextToSpeech.SUCCESS;
|
|
221
|
+
resolve(ttsReady ? tts : null);
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
bridge.register('speech.speak', async (params: any) => {
|
|
228
|
+
const engine = await ensureTts();
|
|
229
|
+
if (!engine) throw err('NATIVE_ERROR', 'TextToSpeech init failed');
|
|
230
|
+
const text = String(params?.text ?? '');
|
|
231
|
+
if (params?.lang) {
|
|
232
|
+
try { engine.setLanguage(new java.util.Locale.Builder().setLanguageTag(String(params.lang)).build()); } catch { /* keep default */ }
|
|
233
|
+
}
|
|
234
|
+
if (params?.rate != null) engine.setSpeechRate(Number(params.rate));
|
|
235
|
+
if (params?.pitch != null) engine.setPitch(Number(params.pitch));
|
|
236
|
+
const id = 'u' + Date.now();
|
|
237
|
+
return new Promise<void>((resolve) => {
|
|
238
|
+
engine.setOnUtteranceProgressListener(
|
|
239
|
+
new android.speech.tts.UtteranceProgressListener({
|
|
240
|
+
onStart() {},
|
|
241
|
+
onDone() { resolve(); },
|
|
242
|
+
onError() { resolve(); }, // resolve regardless — speak() promises completion, not success
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, id);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
bridge.register('speech.stop', () => { try { tts?.stop?.(); } catch { /* noop */ } });
|
|
250
|
+
|
|
251
|
+
bridge.register('speech.voices', () => {
|
|
252
|
+
const out: Array<{ id: string; name: string; lang: string }> = [];
|
|
253
|
+
try {
|
|
254
|
+
const voices = tts?.getVoices?.();
|
|
255
|
+
if (voices) {
|
|
256
|
+
const it = voices.iterator();
|
|
257
|
+
while (it.hasNext()) {
|
|
258
|
+
const v = it.next();
|
|
259
|
+
out.push({ id: String(v.getName()), name: String(v.getName()), lang: String(v.getLocale().toLanguageTag()) });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch { /* TTS not yet init — return empty */ }
|
|
263
|
+
return out;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// STT — SpeechRecognizer must be created + driven on the main (UI) thread.
|
|
267
|
+
let recognizer: any = null;
|
|
268
|
+
let listenPending: { resolve: (v: string) => void; reject: (e: any) => void } | null = null;
|
|
269
|
+
let best = '';
|
|
270
|
+
|
|
271
|
+
const settle = () => {
|
|
272
|
+
const p = listenPending; listenPending = null;
|
|
273
|
+
try { recognizer?.stopListening?.(); recognizer?.destroy?.(); } catch { /* noop */ }
|
|
274
|
+
recognizer = null;
|
|
275
|
+
p?.resolve(best);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
bridge.register('speech.stopListening', () => {
|
|
279
|
+
Utils.dispatchToMainThread(() => { if (listenPending) settle(); });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
bridge.register('speech.listen', async (params: any) => {
|
|
283
|
+
const granted = await requestPermissions(['android.permission.RECORD_AUDIO']);
|
|
284
|
+
if (!granted) throw err('DENIED', 'Microphone permission denied');
|
|
285
|
+
if (listenPending) throw err('NATIVE_ERROR', 'speech.listen: already listening');
|
|
286
|
+
|
|
287
|
+
return new Promise<string>((resolve, reject) => {
|
|
288
|
+
best = '';
|
|
289
|
+
listenPending = { resolve, reject };
|
|
290
|
+
Utils.dispatchToMainThread(() => {
|
|
291
|
+
try {
|
|
292
|
+
if (!android.speech.SpeechRecognizer.isRecognitionAvailable(ctx())) {
|
|
293
|
+
const p = listenPending; listenPending = null;
|
|
294
|
+
p?.reject(err('UNSUPPORTED', 'No speech recognition service'));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(ctx());
|
|
298
|
+
const pickText = (b: any): string => {
|
|
299
|
+
const arr = b?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION);
|
|
300
|
+
return arr && arr.size() ? String(arr.get(0)) : '';
|
|
301
|
+
};
|
|
302
|
+
recognizer.setRecognitionListener(
|
|
303
|
+
new android.speech.RecognitionListener({
|
|
304
|
+
onReadyForSpeech() {}, onBeginningOfSpeech() {}, onRmsChanged() {},
|
|
305
|
+
onBufferReceived() {}, onEndOfSpeech() {}, onEvent() {},
|
|
306
|
+
onPartialResults(b: any) {
|
|
307
|
+
if (!params?.partial) return;
|
|
308
|
+
const t = pickText(b);
|
|
309
|
+
if (t) { best = t; bridge.emit('speech.partial', { transcript: t }); }
|
|
310
|
+
},
|
|
311
|
+
onResults(b: any) {
|
|
312
|
+
const t = pickText(b);
|
|
313
|
+
if (t) best = t;
|
|
314
|
+
if (listenPending) settle();
|
|
315
|
+
},
|
|
316
|
+
onError(_code: number) {
|
|
317
|
+
if (listenPending) settle(); // resolve with best-so-far (e.g. timeout/no-match)
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
const intent = new android.content.Intent(android.speech.RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
|
|
322
|
+
intent.putExtra(
|
|
323
|
+
android.speech.RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
|
324
|
+
android.speech.RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
|
325
|
+
);
|
|
326
|
+
if (params?.lang) intent.putExtra(android.speech.RecognizerIntent.EXTRA_LANGUAGE, String(params.lang));
|
|
327
|
+
if (params?.partial) intent.putExtra(android.speech.RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
|
|
328
|
+
recognizer.startListening(intent);
|
|
329
|
+
} catch (e: any) {
|
|
330
|
+
const p = listenPending; listenPending = null;
|
|
331
|
+
p?.reject(err('NATIVE_ERROR', e?.message ?? 'listen failed'));
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
@@ -30,6 +30,14 @@ declare const HKStatisticsOptions: any;
|
|
|
30
30
|
declare const HKUnit: any;
|
|
31
31
|
declare const CNContactPickerViewController: any;
|
|
32
32
|
declare const CNContactPickerDelegate: any;
|
|
33
|
+
// Contacts framework (contacts.getAll bulk read — CNContactStore, vs the ContactsUI picker above)
|
|
34
|
+
declare const CNContactStore: any;
|
|
35
|
+
declare const CNEntityType: any;
|
|
36
|
+
declare const CNContactFetchRequest: any;
|
|
37
|
+
declare const CNContactGivenNameKey: any;
|
|
38
|
+
declare const CNContactFamilyNameKey: any;
|
|
39
|
+
declare const CNContactPhoneNumbersKey: any;
|
|
40
|
+
declare const CNContactEmailAddressesKey: any;
|
|
33
41
|
declare const EKEventStore: any;
|
|
34
42
|
declare const EKEvent: any;
|
|
35
43
|
declare const EKSpan: any;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
|
|
4
|
+
declare const android: any;
|
|
5
|
+
// iOS keyboard-notification globals (marshalled from UIKit at runtime).
|
|
6
|
+
declare const NSNotificationCenter: any;
|
|
7
|
+
declare const UIKeyboardWillShowNotification: string;
|
|
8
|
+
declare const UIKeyboardWillHideNotification: string;
|
|
9
|
+
declare const UIKeyboardFrameEndUserInfoKey: string;
|
|
10
|
+
|
|
11
|
+
let observersArmed = false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `kit.keyboard` shell handlers: dismiss the software keyboard (`keyboard.hide`)
|
|
15
|
+
* and forward show/hide so the page can lift content above it.
|
|
16
|
+
*
|
|
17
|
+
* Heights are emitted in CSS px — iOS keyboard frames are in points (≈ CSS px in
|
|
18
|
+
* a WKWebView), Android raw pixels are divided by display density. iOS observes
|
|
19
|
+
* `UIKeyboardWillShow/Hide`; Android watches the decor view's visible frame.
|
|
20
|
+
*/
|
|
21
|
+
export function registerKeyboardHandlers(): void {
|
|
22
|
+
bridge.register('keyboard.hide', () => {
|
|
23
|
+
Utils.dispatchToMainThread(() => {
|
|
24
|
+
if (isIOS) {
|
|
25
|
+
// WKWebView is a UIView → endEditing resigns the active field's first responder.
|
|
26
|
+
(bridge.getWebView()?.ios as any)?.endEditing?.(true);
|
|
27
|
+
} else if (isAndroid) {
|
|
28
|
+
hideAndroidKeyboard();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (observersArmed) return;
|
|
34
|
+
observersArmed = true;
|
|
35
|
+
if (isIOS) armIosKeyboardObservers();
|
|
36
|
+
else if (isAndroid) armAndroidKeyboardObserver();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** iOS: NSNotificationCenter observers → emit keyboard.show {height} / keyboard.hide. */
|
|
40
|
+
function armIosKeyboardObservers(): void {
|
|
41
|
+
const center = NSNotificationCenter.defaultCenter;
|
|
42
|
+
center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillShowNotification, null, null, (note: any) => {
|
|
43
|
+
const value = note?.userInfo?.objectForKey?.(UIKeyboardFrameEndUserInfoKey);
|
|
44
|
+
const height = value ? value.CGRectValue.size.height : 0; // points ≈ CSS px in WKWebView
|
|
45
|
+
bridge.emit('keyboard.show', { height: Math.round(height) });
|
|
46
|
+
});
|
|
47
|
+
center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillHideNotification, null, null, () => {
|
|
48
|
+
bridge.emit('keyboard.hide');
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Android: a global-layout listener compares the decor view's visible frame to its full height. */
|
|
53
|
+
function armAndroidKeyboardObserver(): void {
|
|
54
|
+
const activity = androidActivity();
|
|
55
|
+
if (!activity) return;
|
|
56
|
+
const rootView = activity.getWindow().getDecorView();
|
|
57
|
+
const density = Utils.android.getApplicationContext().getResources().getDisplayMetrics().density || 1;
|
|
58
|
+
let lastShown = false;
|
|
59
|
+
const listener = new android.view.ViewTreeObserver.OnGlobalLayoutListener({
|
|
60
|
+
onGlobalLayout() {
|
|
61
|
+
const rect = new android.graphics.Rect();
|
|
62
|
+
rootView.getWindowVisibleDisplayFrame(rect);
|
|
63
|
+
const screenHeight = rootView.getHeight();
|
|
64
|
+
const hiddenPx = screenHeight - rect.bottom; // band covered at the bottom
|
|
65
|
+
const shown = hiddenPx > screenHeight * 0.15; // >15% ≈ keyboard, not a nav/status bar
|
|
66
|
+
if (shown === lastShown) return;
|
|
67
|
+
lastShown = shown;
|
|
68
|
+
if (shown) bridge.emit('keyboard.show', { height: Math.round(hiddenPx / density) });
|
|
69
|
+
else bridge.emit('keyboard.hide');
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
rootView.getViewTreeObserver().addOnGlobalLayoutListener(listener);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hideAndroidKeyboard(): void {
|
|
76
|
+
const activity = androidActivity();
|
|
77
|
+
if (!activity) return;
|
|
78
|
+
const imm = activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE);
|
|
79
|
+
const view = activity.getCurrentFocus() ?? activity.getWindow().getDecorView();
|
|
80
|
+
imm?.hideSoftInputFromWindow(view.getWindowToken(), 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function androidActivity(): any {
|
|
84
|
+
return Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
85
|
+
}
|