@livx.cc/appwrap 0.28.0 → 0.28.1
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/app.ts +55 -54
- package/runtime/app/shell/android-helpers.ts +6 -6
- package/runtime/app/shell/banner.ts +9 -4
- package/runtime/app/shell/bridge.ts +2 -0
- package/runtime/app/shell/custom-webview.android.ts +50 -23
- package/runtime/app/shell/custom-webview.ios.ts +90 -78
- package/runtime/app/shell/devmenu.ts +2 -2
- package/runtime/app/shell/env.ts +1 -3
- package/runtime/app/shell/events.ts +5 -4
- package/runtime/app/shell/handlers-android.ts +16 -12
- package/runtime/app/shell/handlers-background.ts +47 -36
- package/runtime/app/shell/handlers-billing.ts +90 -82
- package/runtime/app/shell/handlers-extended.ts +141 -110
- package/runtime/app/shell/handlers-fs.ts +53 -41
- package/runtime/app/shell/handlers-health.ts +27 -19
- package/runtime/app/shell/handlers-media.ts +8 -14
- package/runtime/app/shell/handlers-oauth.ts +19 -19
- package/runtime/app/shell/handlers-parity.ts +118 -89
- package/runtime/app/shell/handlers-push.ts +5 -3
- package/runtime/app/shell/handlers-reviews.ts +5 -6
- package/runtime/app/shell/handlers-scanner.ts +36 -38
- package/runtime/app/shell/handlers-speech.ts +43 -49
- package/runtime/app/shell/handlers-system.ts +1 -1
- package/runtime/app/shell/handlers.ts +4 -3
- package/runtime/app/shell/ios-frameworks.d.ts +9 -0
- package/runtime/app/shell/ios-ui-delegate.ts +112 -108
- package/runtime/app/shell/native-log.ts +1 -3
- package/runtime/app/shell/status-bar.ts +7 -4
- package/runtime/app/shell/toast.ts +1 -1
- package/runtime/references.d.ts +6 -0
- package/runtime/tsconfig.json +1 -0
- package/src/cli.ts +43 -18
- package/src/derive.ts +8 -0
|
@@ -5,6 +5,94 @@ import { uiImageToDataUrl } from './ios-image';
|
|
|
5
5
|
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
6
6
|
const iosOnly = () => err('UNSUPPORTED', 'iOS only for now');
|
|
7
7
|
|
|
8
|
+
/** Read a CNContact into the kit-contract shape (name + phone/email string arrays). */
|
|
9
|
+
function readContact(contact: CNContact): { name: string; phones: string[]; emails: string[] } {
|
|
10
|
+
// CNLabeledValue<T>.value is typed `any` in the SDK, so the per-value map stays loosely typed.
|
|
11
|
+
const collect = (labeled: NSArray<CNLabeledValue<any>>, map: (v: any) => string): string[] => {
|
|
12
|
+
const out: string[] = [];
|
|
13
|
+
for (let i = 0; i < labeled.count; i++) out.push(map(labeled.objectAtIndex(i).value));
|
|
14
|
+
return out;
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
name: `${contact.givenName ?? ''} ${contact.familyName ?? ''}`.trim(),
|
|
18
|
+
phones: collect(contact.phoneNumbers, (v) => String(v.stringValue)),
|
|
19
|
+
emails: collect(contact.emailAddresses, (v) => String(v)),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// CLLocationManager delegate for the persistent geo.watch stream. Callbacks are wired via instance
|
|
24
|
+
// fields so the closure-captured behavior of the old (NSObject).extend(...) is preserved.
|
|
25
|
+
@NativeClass()
|
|
26
|
+
class GeoWatchDelegate extends NSObject implements CLLocationManagerDelegate {
|
|
27
|
+
static ObjCProtocols = [CLLocationManagerDelegate];
|
|
28
|
+
static new(): GeoWatchDelegate {
|
|
29
|
+
return <GeoWatchDelegate>super.new();
|
|
30
|
+
}
|
|
31
|
+
locationManagerDidUpdateLocations(_m: CLLocationManager, locations: NSArray<CLLocation>): void {
|
|
32
|
+
const loc = locations.lastObject;
|
|
33
|
+
if (!loc) return;
|
|
34
|
+
bridge.emit('geo.position', {
|
|
35
|
+
lat: loc.coordinate.latitude,
|
|
36
|
+
lng: loc.coordinate.longitude,
|
|
37
|
+
accuracy: loc.horizontalAccuracy,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
locationManagerDidFailWithError(_m: CLLocationManager, error: NSError): void {
|
|
41
|
+
console.warn('AppWrap: geo.watch error', error.localizedDescription);
|
|
42
|
+
}
|
|
43
|
+
locationManagerDidChangeAuthorization(m: CLLocationManager): void {
|
|
44
|
+
const st = m.authorizationStatus;
|
|
45
|
+
if (st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedWhenInUse ||
|
|
46
|
+
st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedAlways) {
|
|
47
|
+
m.startUpdatingLocation();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// CNContactPickerViewController delegate. The picker resolution callbacks are set as instance fields
|
|
53
|
+
// after construction (replacing the old closure-captured `resolve`).
|
|
54
|
+
@NativeClass()
|
|
55
|
+
class ContactPickerDelegate extends NSObject implements CNContactPickerDelegate {
|
|
56
|
+
static ObjCProtocols = [CNContactPickerDelegate];
|
|
57
|
+
static new(): ContactPickerDelegate {
|
|
58
|
+
return <ContactPickerDelegate>super.new();
|
|
59
|
+
}
|
|
60
|
+
onSelect?: (contact: CNContact) => void;
|
|
61
|
+
onCancel?: (picker: CNContactPickerViewController) => void;
|
|
62
|
+
contactPickerDidSelectContact(_picker: CNContactPickerViewController, contact: CNContact): void {
|
|
63
|
+
this.onSelect?.(contact);
|
|
64
|
+
}
|
|
65
|
+
contactPickerDidCancel(picker: CNContactPickerViewController): void {
|
|
66
|
+
this.onCancel?.(picker);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// UIImagePickerController delegate (camera.capture). Result/options are set as instance fields after
|
|
71
|
+
// construction, replacing the old closure-captured `resolve`/`dataUrl`/`maxSize`.
|
|
72
|
+
@NativeClass()
|
|
73
|
+
class CameraCaptureDelegate extends NSObject implements UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
74
|
+
static ObjCProtocols = [UIImagePickerControllerDelegate, UINavigationControllerDelegate];
|
|
75
|
+
static new(): CameraCaptureDelegate {
|
|
76
|
+
return <CameraCaptureDelegate>super.new();
|
|
77
|
+
}
|
|
78
|
+
wantDataUrl = false;
|
|
79
|
+
maxSize = 1024;
|
|
80
|
+
onResult?: (out: { picked: boolean; width?: number; height?: number; dataUrl?: string }) => void;
|
|
81
|
+
imagePickerControllerDidFinishPickingMediaWithInfo(picker: UIImagePickerController, info: NSDictionary<string, any>): void {
|
|
82
|
+
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
83
|
+
const img = info.objectForKey(UIImagePickerControllerOriginalImage) as UIImage | null;
|
|
84
|
+
if (!img) return this.onResult?.({ picked: true });
|
|
85
|
+
const out: { picked: boolean; width?: number; height?: number; dataUrl?: string } =
|
|
86
|
+
{ picked: true, width: img.size.width, height: img.size.height };
|
|
87
|
+
if (this.wantDataUrl) out.dataUrl = uiImageToDataUrl(img, this.maxSize);
|
|
88
|
+
this.onResult?.(out);
|
|
89
|
+
}
|
|
90
|
+
imagePickerControllerDidCancel(picker: UIImagePickerController): void {
|
|
91
|
+
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
92
|
+
this.onResult?.({ picked: false });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
8
96
|
/**
|
|
9
97
|
* Extended-parity handlers: dialogs, storage.clear, geo watch, theme color,
|
|
10
98
|
* motion, contacts, calendar, camera. iOS-first like handlers-extended.
|
|
@@ -50,39 +138,15 @@ export function registerParityHandlers(): void {
|
|
|
50
138
|
});
|
|
51
139
|
|
|
52
140
|
// ── geo.watch (persistent CLLocationManager) ───────────────────────
|
|
53
|
-
let geoManager:
|
|
54
|
-
let geoDelegate:
|
|
141
|
+
let geoManager: CLLocationManager | null = null;
|
|
142
|
+
let geoDelegate: GeoWatchDelegate | null = null;
|
|
55
143
|
|
|
56
144
|
bridge.register('geo.watch.start', () => {
|
|
57
145
|
if (!isIOS) throw iosOnly();
|
|
58
146
|
if (geoManager) return; // already streaming
|
|
59
|
-
const DelegateClass = (NSObject as any).extend(
|
|
60
|
-
{
|
|
61
|
-
locationManagerDidUpdateLocations(_m: CLLocationManager, locations: NSArray<CLLocation>) {
|
|
62
|
-
const loc = locations.lastObject;
|
|
63
|
-
if (!loc) return;
|
|
64
|
-
bridge.emit('geo.position', {
|
|
65
|
-
lat: loc.coordinate.latitude,
|
|
66
|
-
lng: loc.coordinate.longitude,
|
|
67
|
-
accuracy: loc.horizontalAccuracy,
|
|
68
|
-
});
|
|
69
|
-
},
|
|
70
|
-
locationManagerDidFailWithError(_m: CLLocationManager, error: NSError) {
|
|
71
|
-
console.warn('AppWrap: geo.watch error', error.localizedDescription);
|
|
72
|
-
},
|
|
73
|
-
locationManagerDidChangeAuthorization(m: CLLocationManager) {
|
|
74
|
-
const st = m.authorizationStatus;
|
|
75
|
-
if (st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedWhenInUse ||
|
|
76
|
-
st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedAlways) {
|
|
77
|
-
m.startUpdatingLocation();
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
{ protocols: [CLLocationManagerDelegate] }
|
|
82
|
-
);
|
|
83
147
|
Utils.dispatchToMainThread(() => {
|
|
84
148
|
geoManager = CLLocationManager.new();
|
|
85
|
-
geoDelegate =
|
|
149
|
+
geoDelegate = GeoWatchDelegate.new();
|
|
86
150
|
geoManager.delegate = geoDelegate;
|
|
87
151
|
geoManager.desiredAccuracy = kCLLocationAccuracyHundredMeters;
|
|
88
152
|
const st = geoManager.authorizationStatus;
|
|
@@ -105,8 +169,8 @@ export function registerParityHandlers(): void {
|
|
|
105
169
|
// block: the repeated JS↔Obj-C block is fragile under NativeScript (the handler
|
|
106
170
|
// can silently stop firing on-device). startDeviceMotionUpdates() + reading
|
|
107
171
|
// `mm.deviceMotion` is a plain property read on the main thread — reliable.
|
|
108
|
-
let motionManager:
|
|
109
|
-
let motionTimer:
|
|
172
|
+
let motionManager: CMMotionManager | null = null;
|
|
173
|
+
let motionTimer: ReturnType<typeof setInterval> | null = null;
|
|
110
174
|
|
|
111
175
|
bridge.register('motion.start', () => {
|
|
112
176
|
if (!isIOS) throw iosOnly();
|
|
@@ -143,31 +207,16 @@ export function registerParityHandlers(): void {
|
|
|
143
207
|
if (!isIOS) throw iosOnly();
|
|
144
208
|
return new Promise((resolve) => {
|
|
145
209
|
Utils.dispatchToMainThread(() => {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
resolve({
|
|
155
|
-
picked: true,
|
|
156
|
-
name: `${contact.givenName ?? ''} ${contact.familyName ?? ''}`.trim(),
|
|
157
|
-
phones: collect(contact.phoneNumbers, (v) => String(v.stringValue)),
|
|
158
|
-
emails: collect(contact.emailAddresses, (v) => String(v)),
|
|
159
|
-
});
|
|
160
|
-
},
|
|
161
|
-
contactPickerDidCancel(picker: any) {
|
|
162
|
-
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
163
|
-
resolve({ picked: false });
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
{ protocols: [CNContactPickerDelegate] }
|
|
167
|
-
);
|
|
210
|
+
const delegate = ContactPickerDelegate.new();
|
|
211
|
+
delegate.onSelect = (contact) => {
|
|
212
|
+
resolve({ picked: true, ...readContact(contact) });
|
|
213
|
+
};
|
|
214
|
+
delegate.onCancel = (picker) => {
|
|
215
|
+
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
216
|
+
resolve({ picked: false });
|
|
217
|
+
};
|
|
168
218
|
const picker = CNContactPickerViewController.new();
|
|
169
|
-
|
|
170
|
-
(picker as any)._appwrapDelegate = delegate; // retain
|
|
219
|
+
(picker as any)._appwrapDelegate = delegate; // retain delegate against ARC; not a typed property
|
|
171
220
|
picker.delegate = delegate;
|
|
172
221
|
Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(picker, true, null);
|
|
173
222
|
});
|
|
@@ -179,30 +228,23 @@ export function registerParityHandlers(): void {
|
|
|
179
228
|
if (!isIOS) throw iosOnly();
|
|
180
229
|
const store = CNContactStore.new();
|
|
181
230
|
return new Promise((resolve, reject) => {
|
|
182
|
-
store.requestAccessForEntityTypeCompletionHandler(CNEntityType.Contacts, (granted: boolean, error: NSError
|
|
231
|
+
store.requestAccessForEntityTypeCompletionHandler(CNEntityType.Contacts, (granted: boolean, error: NSError) => {
|
|
183
232
|
if (!granted) {
|
|
184
233
|
return reject(err('DENIED', error?.localizedDescription ?? 'contacts access denied'));
|
|
185
234
|
}
|
|
186
235
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
for (let i = 0; i < labeled.count; i++) out.push(map(labeled.objectAtIndex(i).value));
|
|
190
|
-
return out;
|
|
191
|
-
};
|
|
192
|
-
const keys = NSArray.arrayWithArray([
|
|
236
|
+
// Key string constants conform to CNKeyDescriptor (NSString) at runtime; type the array as such.
|
|
237
|
+
const keys = NSArray.arrayWithArray<CNKeyDescriptor>([
|
|
193
238
|
CNContactGivenNameKey,
|
|
194
239
|
CNContactFamilyNameKey,
|
|
195
240
|
CNContactPhoneNumbersKey,
|
|
196
241
|
CNContactEmailAddressesKey,
|
|
197
|
-
]
|
|
242
|
+
]);
|
|
198
243
|
const request = CNContactFetchRequest.alloc().initWithKeysToFetch(keys);
|
|
199
244
|
const contacts: Array<{ name: string; phones: string[]; emails: string[] }> = [];
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
phones: collect(contact.phoneNumbers, (v) => String(v.stringValue)),
|
|
204
|
-
emails: collect(contact.emailAddresses, (v) => String(v)),
|
|
205
|
-
});
|
|
245
|
+
// null error out-param (ObjC nil) — SDK types it as a non-nullable interop reference.
|
|
246
|
+
store.enumerateContactsWithFetchRequestErrorUsingBlock(request, null as any, (contact: CNContact) => {
|
|
247
|
+
contacts.push(readContact(contact));
|
|
206
248
|
});
|
|
207
249
|
resolve({ contacts });
|
|
208
250
|
} catch (e: any) {
|
|
@@ -228,8 +270,9 @@ export function registerParityHandlers(): void {
|
|
|
228
270
|
const startDate = start
|
|
229
271
|
? NSDate.dateWithTimeIntervalSince1970(Date.parse(start) / 1000)
|
|
230
272
|
: NSDate.dateWithTimeIntervalSinceNow(3600);
|
|
231
|
-
|
|
232
|
-
event.
|
|
273
|
+
// EKEvent.start/endDate are typed `Date`; NS marshals the NSDate we build here.
|
|
274
|
+
event.startDate = startDate as unknown as Date;
|
|
275
|
+
event.endDate = startDate.dateByAddingTimeInterval((durationMin ?? 60) * 60) as unknown as Date;
|
|
233
276
|
if (notes) event.notes = String(notes);
|
|
234
277
|
event.calendar = store.defaultCalendarForNewEvents;
|
|
235
278
|
store.saveEventSpanError(event, EKSpan.ThisEvent, null as any);
|
|
@@ -249,34 +292,20 @@ export function registerParityHandlers(): void {
|
|
|
249
292
|
});
|
|
250
293
|
|
|
251
294
|
// ── camera capture (UIImagePickerController) ───────────────────────
|
|
252
|
-
bridge.register('camera.capture', ({ dataUrl, maxSize }:
|
|
295
|
+
bridge.register('camera.capture', ({ dataUrl, maxSize }: { dataUrl?: boolean; maxSize?: number } = {}) => {
|
|
253
296
|
if (!isIOS) throw iosOnly();
|
|
254
297
|
if (!UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.Camera)) {
|
|
255
298
|
throw err('UNSUPPORTED', 'No camera on this device (simulator?)');
|
|
256
299
|
}
|
|
257
300
|
return new Promise((resolve) => {
|
|
258
301
|
Utils.dispatchToMainThread(() => {
|
|
259
|
-
const DelegateClass = (NSObject as any).extend(
|
|
260
|
-
{
|
|
261
|
-
imagePickerControllerDidFinishPickingMediaWithInfo(picker: any, info: NSDictionary<string, any>) {
|
|
262
|
-
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
263
|
-
const img = info.objectForKey(UIImagePickerControllerOriginalImage) as UIImage | null;
|
|
264
|
-
if (!img) return resolve({ picked: true });
|
|
265
|
-
const out: any = { picked: true, width: img.size.width, height: img.size.height };
|
|
266
|
-
if (dataUrl) out.dataUrl = uiImageToDataUrl(img, maxSize ?? 1024);
|
|
267
|
-
resolve(out);
|
|
268
|
-
},
|
|
269
|
-
imagePickerControllerDidCancel(picker: any) {
|
|
270
|
-
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
271
|
-
resolve({ picked: false });
|
|
272
|
-
},
|
|
273
|
-
},
|
|
274
|
-
{ protocols: [UIImagePickerControllerDelegate, UINavigationControllerDelegate] }
|
|
275
|
-
);
|
|
276
302
|
const picker = UIImagePickerController.new();
|
|
277
303
|
picker.sourceType = UIImagePickerControllerSourceType.Camera;
|
|
278
|
-
const delegate =
|
|
279
|
-
|
|
304
|
+
const delegate = CameraCaptureDelegate.new();
|
|
305
|
+
delegate.wantDataUrl = !!dataUrl;
|
|
306
|
+
delegate.maxSize = maxSize ?? 1024;
|
|
307
|
+
delegate.onResult = resolve;
|
|
308
|
+
(picker as any)._appwrapDelegate = delegate; // interop: retain delegate past the present call (no typed slot)
|
|
280
309
|
picker.delegate = delegate;
|
|
281
310
|
Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(picker, true, null);
|
|
282
311
|
});
|
|
@@ -62,7 +62,7 @@ export function onApnsError(message: string): void {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** iOS AppDelegate / UN delegate → a remote notification arrived (tapped = user opened it). */
|
|
65
|
-
export function onRemoteMessage(userInfo: any, tapped: boolean): void {
|
|
65
|
+
export function onRemoteMessage(userInfo: any, tapped: boolean): void { // any: APNs userInfo is NSDictionary or auto-marshalled JS object (dual-path)
|
|
66
66
|
bridge.emit(tapped ? 'push.tap' : 'push.message', parseApnsPayload(userInfo));
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -74,6 +74,7 @@ export function onFcmMessage(data: Record<string, string>, title?: string, body?
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** APNs userInfo (NSDictionary or auto-marshalled object) → { data, title?, body? }. */
|
|
77
|
+
// any: dual-path payload (NSDictionary vs marshalled JS object) probed dynamically below.
|
|
77
78
|
function parseApnsPayload(userInfo: any): { data: Record<string, unknown>; title?: string; body?: string } {
|
|
78
79
|
const data: Record<string, unknown> = {};
|
|
79
80
|
let title: string | undefined;
|
|
@@ -181,7 +182,7 @@ function unsupported(): Error {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
// ── Android (Firebase Messaging) — guarded; classes exist only when FCM is wired ──
|
|
184
|
-
function fcm(): any {
|
|
185
|
+
function fcm(): any { // no NS types: third-party firebase (com.google.firebase.messaging)
|
|
185
186
|
const FM = (global as any).com?.google?.firebase?.messaging?.FirebaseMessaging;
|
|
186
187
|
if (!FM) throw Object.assign(new Error('FCM not configured — wire google-services.json + the messaging plugin'), { code: 'UNSUPPORTED' });
|
|
187
188
|
return FM.getInstance();
|
|
@@ -198,6 +199,7 @@ function androidRequestPermission(): Promise<string> {
|
|
|
198
199
|
const granted = ctx.checkSelfPermission('android.permission.POST_NOTIFICATIONS') === pm.PERMISSION_GRANTED;
|
|
199
200
|
if (granted) return resolve('granted');
|
|
200
201
|
const activity = Application.android.foregroundActivity || Application.android.startActivity;
|
|
202
|
+
// any: NS activity-result event payload (grantResults/requestCode) is untyped in core's event bus.
|
|
201
203
|
Application.android.on(Application.android.activityRequestPermissionsEvent as any, (args: any) => {
|
|
202
204
|
if (args.requestCode !== 7613) return;
|
|
203
205
|
const ok = args.grantResults?.length && args.grantResults[0] === pm.PERMISSION_GRANTED;
|
|
@@ -229,7 +231,7 @@ function androidGetToken(): Promise<PushToken> {
|
|
|
229
231
|
const OnCompleteListener = (global as any).com.google.android.gms.tasks.OnCompleteListener;
|
|
230
232
|
const task = fcm().getToken();
|
|
231
233
|
task.addOnCompleteListener(new OnCompleteListener({
|
|
232
|
-
onComplete(t: any) {
|
|
234
|
+
onComplete(t: any) { // no NS types: play-services gms Task<String> (FCM token)
|
|
233
235
|
if (t.isSuccessful()) {
|
|
234
236
|
const tok = String(t.getResult());
|
|
235
237
|
registerTokenWithBackend('android', tok); // parity with iOS onApnsToken
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
|
|
4
|
-
// SKStoreReviewController comes from the StoreKit typings referenced in references.d.ts.
|
|
5
|
-
//
|
|
6
|
-
declare const com: any;
|
|
4
|
+
// SKStoreReviewController comes from the StoreKit typings referenced in references.d.ts.
|
|
5
|
+
declare const com: any; // no NS types: third-party play-core review (com.google.android.play.core.review)
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* In-app store review prompt — opt-in, strippable (its own handler file + group). Lives apart from
|
|
@@ -28,8 +27,8 @@ function registerIos(): void {
|
|
|
28
27
|
bridge.register('reviews.requestReview', () => {
|
|
29
28
|
Utils.dispatchToMainThread(() => {
|
|
30
29
|
const scene = UIApplication.sharedApplication.keyWindow?.windowScene;
|
|
31
|
-
if (scene && SKStoreReviewController.
|
|
32
|
-
SKStoreReviewController.
|
|
30
|
+
if (scene && SKStoreReviewController.requestReviewInScene) {
|
|
31
|
+
SKStoreReviewController.requestReviewInScene(scene);
|
|
33
32
|
} else {
|
|
34
33
|
SKStoreReviewController.requestReview();
|
|
35
34
|
}
|
|
@@ -50,7 +49,7 @@ function registerAndroid(): void {
|
|
|
50
49
|
// Both tasks COMPLETE even when the OS shows nothing (no Play track / rate-limited), so we
|
|
51
50
|
// resolve on completion regardless — never block, never fabricate a "shown" signal.
|
|
52
51
|
request.addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener({
|
|
53
|
-
onComplete(infoTask: any) {
|
|
52
|
+
onComplete(infoTask: any) { // no NS types: play-services gms Task<ReviewInfo>
|
|
54
53
|
if (!infoTask.isSuccessful()) { resolve(); return; }
|
|
55
54
|
try {
|
|
56
55
|
const flow = manager.launchReviewFlow(activity, infoTask.getResult());
|
|
@@ -2,30 +2,36 @@ import { Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { requestPermissions, startActivityForResult } from './android-helpers';
|
|
4
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
5
|
// `dispatch_get_main_queue()` is a C macro, NOT a bridged symbol — NativeScript can't see it
|
|
22
6
|
// ("is not defined" at runtime). `dispatch_queue_create` IS a real bridged libdispatch function;
|
|
23
7
|
// a dedicated serial queue is the correct delegate queue for AVCaptureMetadataOutput anyway.
|
|
24
8
|
declare function dispatch_queue_create(label: string, attr: any): any;
|
|
25
|
-
declare const
|
|
9
|
+
declare const com: any; // no NS types: third-party ZXing (com.google.zxing.integration.android.*)
|
|
26
10
|
|
|
27
11
|
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
28
12
|
|
|
13
|
+
// iOS metadata-output delegate. NativeScript uses the declared protocol to map the camelCase JS
|
|
14
|
+
// method to the colonated selector `captureOutput:didOutputMetadataObjects:fromConnection:`; with an
|
|
15
|
+
// empty protocols list it registers under the wrong selector and AVFoundation never calls it — the
|
|
16
|
+
// scanner opens but never decodes. The first decoded object is forwarded via the `onResult` instance
|
|
17
|
+
// field (assigned after `new()`), replacing the original closure capture.
|
|
18
|
+
@NativeClass()
|
|
19
|
+
class MetadataDelegate extends NSObject implements AVCaptureMetadataOutputObjectsDelegate {
|
|
20
|
+
static ObjCProtocols = [AVCaptureMetadataOutputObjectsDelegate];
|
|
21
|
+
onResult?: (obj: AVMetadataObject) => void;
|
|
22
|
+
static new(): MetadataDelegate {
|
|
23
|
+
return <MetadataDelegate>super.new();
|
|
24
|
+
}
|
|
25
|
+
captureOutputDidOutputMetadataObjectsFromConnection(
|
|
26
|
+
_output: AVCaptureOutput,
|
|
27
|
+
metadataObjects: NSArray<AVMetadataObject> | AVMetadataObject[],
|
|
28
|
+
_connection: AVCaptureConnection
|
|
29
|
+
): void {
|
|
30
|
+
const obj = (metadataObjects as NSArray<AVMetadataObject>)?.firstObject;
|
|
31
|
+
if (obj) this.onResult?.(obj);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
/**
|
|
30
36
|
* Camera barcode / QR decoding. iOS: AVCaptureMetadataOutput in a full-screen capture VC presented
|
|
31
37
|
* over the WebView; first machine-readable code → stop session, dismiss, resolve. Android: ZXing's
|
|
@@ -41,12 +47,12 @@ export function registerScannerHandlers(): void {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/** Map our small enum → the platform metadata-object types. Empty/`all` = the full supported set. */
|
|
44
|
-
function iosFormatTypes(formats: any):
|
|
50
|
+
function iosFormatTypes(formats: any): string[] {
|
|
45
51
|
const all = [AVMetadataObjectTypeQRCode, AVMetadataObjectTypeEAN13Code, AVMetadataObjectTypeCode128Code];
|
|
46
52
|
const want = Array.isArray(formats) ? formats : formats ? [formats] : [];
|
|
47
53
|
const sel = want.filter((f: string) => f && f !== 'all');
|
|
48
54
|
if (!sel.length) return all;
|
|
49
|
-
const map: Record<string,
|
|
55
|
+
const map: Record<string, string> = {
|
|
50
56
|
qr: AVMetadataObjectTypeQRCode,
|
|
51
57
|
ean13: AVMetadataObjectTypeEAN13Code,
|
|
52
58
|
code128: AVMetadataObjectTypeCode128Code,
|
|
@@ -54,7 +60,7 @@ function iosFormatTypes(formats: any): any[] {
|
|
|
54
60
|
return sel.map((f: string) => map[f]).filter(Boolean);
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
function iosFormatName(type:
|
|
63
|
+
function iosFormatName(type: string): string {
|
|
58
64
|
if (type === AVMetadataObjectTypeQRCode) return 'qr';
|
|
59
65
|
if (type === AVMetadataObjectTypeEAN13Code) return 'ean13';
|
|
60
66
|
if (type === AVMetadataObjectTypeCode128Code) return 'code128';
|
|
@@ -98,7 +104,7 @@ function registerIos(): void {
|
|
|
98
104
|
Utils.dispatchToMainThread(() => {
|
|
99
105
|
try {
|
|
100
106
|
const wantFront = params?.camera === 'front';
|
|
101
|
-
const position = wantFront ?
|
|
107
|
+
const position = wantFront ? AVCaptureDevicePosition.Front : AVCaptureDevicePosition.Back;
|
|
102
108
|
const discovery = AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypesMediaTypePosition(
|
|
103
109
|
[AVCaptureDeviceTypeBuiltInWideAngleCamera], AVMediaTypeVideo, position
|
|
104
110
|
);
|
|
@@ -118,25 +124,17 @@ function registerIos(): void {
|
|
|
118
124
|
|
|
119
125
|
// Delegate fires on a dedicated serial queue (see dispatch_queue_create below); settle()
|
|
120
126
|
// hops back to the main thread for the WKWebView bridge reply.
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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();
|
|
127
|
+
const delegate = MetadataDelegate.new();
|
|
128
|
+
delegate.onResult = (obj) => {
|
|
129
|
+
if (!pending) return;
|
|
130
|
+
const value = (obj as AVMetadataMachineReadableCodeObject).stringValue;
|
|
131
|
+
if (value == null) return;
|
|
132
|
+
settle({ value: String(value), format: iosFormatName(obj.type) });
|
|
133
|
+
};
|
|
134
|
+
activeDelegate = delegate;
|
|
138
135
|
output.setMetadataObjectsDelegateQueue(activeDelegate, dispatch_queue_create('cc.livx.scanner.metadata', null));
|
|
139
136
|
// metadataObjectTypes must be set AFTER addOutput (the available set is session-derived).
|
|
137
|
+
// interop: JS string[] is accepted at runtime where NSArray<string> is declared.
|
|
140
138
|
output.metadataObjectTypes = iosFormatTypes(params?.formats) as any;
|
|
141
139
|
|
|
142
140
|
const vc = UIViewController.new();
|
|
@@ -2,23 +2,25 @@ import { Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { requestPermissions } from './android-helpers';
|
|
4
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
5
|
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
21
6
|
|
|
7
|
+
// iOS speech-synthesizer delegate — both finish & cancel settle the pending speak (the JS
|
|
8
|
+
// `speakPending` is captured via the `onSettle` instance field, assigned after `new()`).
|
|
9
|
+
@NativeClass()
|
|
10
|
+
class SpeakDelegate extends NSObject implements AVSpeechSynthesizerDelegate {
|
|
11
|
+
static ObjCProtocols = [AVSpeechSynthesizerDelegate];
|
|
12
|
+
onSettle?: () => void;
|
|
13
|
+
static new(): SpeakDelegate {
|
|
14
|
+
return <SpeakDelegate>super.new();
|
|
15
|
+
}
|
|
16
|
+
speechSynthesizerDidFinishSpeechUtterance(_s: AVSpeechSynthesizer, _u: AVSpeechUtterance): void {
|
|
17
|
+
this.onSettle?.();
|
|
18
|
+
}
|
|
19
|
+
speechSynthesizerDidCancelSpeechUtterance(_s: AVSpeechSynthesizer, _u: AVSpeechUtterance): void {
|
|
20
|
+
this.onSettle?.();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
22
24
|
/**
|
|
23
25
|
* Voice I/O. TTS: iOS AVSpeechSynthesizer / Android TextToSpeech — speak resolves on finish.
|
|
24
26
|
* STT: iOS SFSpeechRecognizer + AVAudioEngine mic tap / Android SpeechRecognizer — listen resolves
|
|
@@ -37,15 +39,15 @@ export function registerSpeechHandlers(): void {
|
|
|
37
39
|
function registerIos(): void {
|
|
38
40
|
// Strong refs while a session is live — ARC would otherwise free the synthesizer/engine/delegate
|
|
39
41
|
// the moment the JS locals fall out of scope (same hazard as handlers-scanner/oauth).
|
|
40
|
-
let synth:
|
|
41
|
-
let speakDelegate:
|
|
42
|
+
let synth: AVSpeechSynthesizer | null = null;
|
|
43
|
+
let speakDelegate: SpeakDelegate | null = null;
|
|
42
44
|
let speakPending: { resolve: () => void } | null = null;
|
|
43
45
|
|
|
44
|
-
let engine:
|
|
45
|
-
let recognizer:
|
|
46
|
-
let request:
|
|
47
|
-
let task:
|
|
48
|
-
let listenPending: { resolve: (v: string) => void; reject: (e:
|
|
46
|
+
let engine: AVAudioEngine | null = null;
|
|
47
|
+
let recognizer: SFSpeechRecognizer | null = null;
|
|
48
|
+
let request: SFSpeechAudioBufferRecognitionRequest | null = null;
|
|
49
|
+
let task: SFSpeechRecognitionTask | null = null;
|
|
50
|
+
let listenPending: { resolve: (v: string) => void; reject: (e: Error) => void } | null = null;
|
|
49
51
|
let best = '';
|
|
50
52
|
|
|
51
53
|
const teardownListen = () => {
|
|
@@ -71,18 +73,8 @@ function registerIos(): void {
|
|
|
71
73
|
try {
|
|
72
74
|
if (!synth) {
|
|
73
75
|
synth = AVSpeechSynthesizer.new();
|
|
74
|
-
|
|
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();
|
|
76
|
+
speakDelegate = SpeakDelegate.new();
|
|
77
|
+
speakDelegate.onSettle = () => { const p = speakPending; speakPending = null; p?.resolve(); };
|
|
86
78
|
synth.delegate = speakDelegate;
|
|
87
79
|
}
|
|
88
80
|
// A new speak resolves any prior pending (its utterance is being superseded).
|
|
@@ -136,8 +128,8 @@ function registerIos(): void {
|
|
|
136
128
|
best = '';
|
|
137
129
|
listenPending = { resolve, reject };
|
|
138
130
|
|
|
139
|
-
SFSpeechRecognizer.requestAuthorization((status:
|
|
140
|
-
if (status !==
|
|
131
|
+
SFSpeechRecognizer.requestAuthorization((status: SFSpeechRecognizerAuthorizationStatus) => {
|
|
132
|
+
if (status !== SFSpeechRecognizerAuthorizationStatus.Authorized) {
|
|
141
133
|
const p = listenPending; listenPending = null;
|
|
142
134
|
p?.reject(err('DENIED', 'Speech recognition not authorized'));
|
|
143
135
|
return;
|
|
@@ -167,17 +159,18 @@ function registerIos(): void {
|
|
|
167
159
|
|
|
168
160
|
const session = AVAudioSession.sharedInstance();
|
|
169
161
|
session.setCategoryModeOptionsError(AVAudioSessionCategoryPlayAndRecord, AVAudioSessionModeMeasurement, 1 /* duckOthers */, null);
|
|
170
|
-
|
|
162
|
+
// 0 = no activation flags; not a named member of the options enum, so cast (preserve value).
|
|
163
|
+
session.setActiveWithOptionsError(true, 0 as AVAudioSessionSetActiveOptions, null);
|
|
171
164
|
|
|
172
165
|
engine = AVAudioEngine.new();
|
|
173
166
|
request = SFSpeechAudioBufferRecognitionRequest.new();
|
|
174
167
|
request.shouldReportPartialResults = !!params?.partial;
|
|
175
168
|
|
|
176
|
-
task = recognizer.recognitionTaskWithRequestResultHandler(request, (result:
|
|
169
|
+
task = recognizer.recognitionTaskWithRequestResultHandler(request, (result: SFSpeechRecognitionResult, error: NSError) => {
|
|
177
170
|
if (result) {
|
|
178
171
|
best = String(result.bestTranscription?.formattedString ?? best);
|
|
179
|
-
if (params?.partial && !result.
|
|
180
|
-
if (result.
|
|
172
|
+
if (params?.partial && !result.final) bridge.emit('speech.partial', { transcript: best });
|
|
173
|
+
if (result.final && listenPending) settleListen();
|
|
181
174
|
}
|
|
182
175
|
if (error && listenPending) {
|
|
183
176
|
const p = listenPending; listenPending = null;
|
|
@@ -188,7 +181,7 @@ function registerIos(): void {
|
|
|
188
181
|
|
|
189
182
|
const input = engine.inputNode;
|
|
190
183
|
const format = input.outputFormatForBus(0);
|
|
191
|
-
input.installTapOnBusBufferSizeFormatBlock(0, 1024, format, (buffer:
|
|
184
|
+
input.installTapOnBusBufferSizeFormatBlock(0, 1024, format, (buffer: AVAudioPCMBuffer, _when: AVAudioTime) => {
|
|
192
185
|
request?.appendAudioPCMBuffer(buffer);
|
|
193
186
|
});
|
|
194
187
|
engine.prepare();
|
|
@@ -206,11 +199,11 @@ function registerIos(): void {
|
|
|
206
199
|
|
|
207
200
|
function registerAndroid(): void {
|
|
208
201
|
// TextToSpeech + SpeechRecognizer are plain Java → direct interop, no kotlin flag, no gradle dep.
|
|
209
|
-
let tts:
|
|
202
|
+
let tts: android.speech.tts.TextToSpeech | null = null;
|
|
210
203
|
let ttsReady = false;
|
|
211
204
|
const ctx = () => Utils.android.getApplicationContext();
|
|
212
205
|
|
|
213
|
-
const ensureTts = (): Promise<
|
|
206
|
+
const ensureTts = (): Promise<android.speech.tts.TextToSpeech | null> =>
|
|
214
207
|
new Promise((resolve) => {
|
|
215
208
|
if (tts && ttsReady) { resolve(tts); return; }
|
|
216
209
|
tts = new android.speech.tts.TextToSpeech(
|
|
@@ -236,7 +229,8 @@ function registerAndroid(): void {
|
|
|
236
229
|
const id = 'u' + Date.now();
|
|
237
230
|
return new Promise<void>((resolve) => {
|
|
238
231
|
engine.setOnUtteranceProgressListener(
|
|
239
|
-
|
|
232
|
+
// NS interop: subclass the abstract Java class via object-literal ctor (untypeable — cast base).
|
|
233
|
+
new (android.speech.tts.UtteranceProgressListener as any)({
|
|
240
234
|
onStart() {},
|
|
241
235
|
onDone() { resolve(); },
|
|
242
236
|
onError() { resolve(); }, // resolve regardless — speak() promises completion, not success
|
|
@@ -264,8 +258,8 @@ function registerAndroid(): void {
|
|
|
264
258
|
});
|
|
265
259
|
|
|
266
260
|
// STT — SpeechRecognizer must be created + driven on the main (UI) thread.
|
|
267
|
-
let recognizer:
|
|
268
|
-
let listenPending: { resolve: (v: string) => void; reject: (e:
|
|
261
|
+
let recognizer: android.speech.SpeechRecognizer | null = null;
|
|
262
|
+
let listenPending: { resolve: (v: string) => void; reject: (e: Error) => void } | null = null;
|
|
269
263
|
let best = '';
|
|
270
264
|
|
|
271
265
|
const settle = () => {
|
|
@@ -295,7 +289,7 @@ function registerAndroid(): void {
|
|
|
295
289
|
return;
|
|
296
290
|
}
|
|
297
291
|
recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(ctx());
|
|
298
|
-
const pickText = (b:
|
|
292
|
+
const pickText = (b: android.os.Bundle): string => {
|
|
299
293
|
const arr = b?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION);
|
|
300
294
|
return arr && arr.size() ? String(arr.get(0)) : '';
|
|
301
295
|
};
|
|
@@ -303,12 +297,12 @@ function registerAndroid(): void {
|
|
|
303
297
|
new android.speech.RecognitionListener({
|
|
304
298
|
onReadyForSpeech() {}, onBeginningOfSpeech() {}, onRmsChanged() {},
|
|
305
299
|
onBufferReceived() {}, onEndOfSpeech() {}, onEvent() {},
|
|
306
|
-
onPartialResults(b:
|
|
300
|
+
onPartialResults(b: android.os.Bundle) {
|
|
307
301
|
if (!params?.partial) return;
|
|
308
302
|
const t = pickText(b);
|
|
309
303
|
if (t) { best = t; bridge.emit('speech.partial', { transcript: t }); }
|
|
310
304
|
},
|
|
311
|
-
onResults(b:
|
|
305
|
+
onResults(b: android.os.Bundle) {
|
|
312
306
|
const t = pickText(b);
|
|
313
307
|
if (t) best = t;
|
|
314
308
|
if (listenPending) settle();
|