@livx.cc/appwrap 0.24.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -135,23 +135,69 @@ export const MODULES: ModuleManifest[] = [
135
135
  ] },
136
136
  android: { permissions: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'] },
137
137
  },
138
- {
139
- name: 'reviews', group: 'system',
140
- capabilities: { reviews: { ios: true, android: false } },
141
- },
142
138
  {
143
139
  name: 'billing', group: 'billing',
144
140
  capabilities: { billing: { ios: true, android: false } },
145
141
  },
146
142
 
147
- // ── oauth — system-browser OAuth (iOS ASWebAuthenticationSession) ──
148
- // Opt-in, strippable (own handler file). No permissions/entitlements — ASWebAuthenticationSession
149
- // needs none; it only relies on the app's urlScheme so the provider redirect returns to the app.
143
+ // ── reviews — in-app store review prompt — opt-in, STRIPPABLE (own handler + group) ──
144
+ // Moved out of the always-bundled parity/system handlers: the Android Play In-App Review path
145
+ // references `com.google.android.play:review` classes — a gradle dep that must NOT land in builds
146
+ // without `reviews` (missing-class crash / dead weight). iOS: SKStoreReviewController (no dep).
147
+ // HONEST LIMIT (Android): the dialog only surfaces for a Play-Store-track install; on a bare
148
+ // emulator / sideload the API resolves WITHOUT showing UI (documented; same class as billing).
149
+ {
150
+ name: 'reviews', group: 'reviews',
151
+ capabilities: { reviews: { ios: true, android: true } },
152
+ android: { gradleDeps: ['com.google.android.play:review:2.0.2'] },
153
+ },
154
+
155
+ // ── oauth — system-browser OAuth (iOS ASWebAuthenticationSession / Android Chrome Custom Tabs) ──
156
+ // Opt-in, strippable (own handler file). iOS: ASWebAuthenticationSession (auto-closes on redirect).
157
+ // Android: a Chrome Custom Tab — it does NOT auto-close on the provider redirect, so the callback
158
+ // (`callbackScheme://…`) returns via the app's EXISTING urlScheme deep-link path; the handler hooks
159
+ // that delivery, matches the scheme, and resolves. androidx.browser is already a baseline dep
160
+ // (browser.open); declared here too so the module stays self-contained (CLI dedups). No permission.
150
161
  // Lets Google et al. complete sign-in they reject inside an embedded WebView (disallowed_useragent).
151
- // Android (Custom Tabs + intent callback) is a future addition.
152
162
  {
153
163
  name: 'oauth', group: 'oauth',
154
- capabilities: { oauth: { ios: true, android: false } },
164
+ capabilities: { oauth: { ios: true, android: true } },
165
+ android: { gradleDeps: ['androidx.browser:browser:1.8.0'] },
166
+ },
167
+
168
+ // ── scanner — camera barcode/QR decode — opt-in (camera permission + decoder weight) ──
169
+ // Reuses the SAME camera permission as media/camera (the CLI dedups across active modules), so a
170
+ // scanner-only app still gets NSCameraUsageDescription / CAMERA without a second permission.
171
+ // iOS: AVCaptureMetadataOutput (no extra dep). Android: ZXing-android-embedded — it ships its own
172
+ // capture Activity, so the handler just launches it via startActivityForResult and reads the
173
+ // result (fewest moving parts vs ML Kit, which needs a hand-built Camera2/CameraX preview).
174
+ {
175
+ name: 'scanner', group: 'scanner',
176
+ capabilities: { scanner: 'native' },
177
+ ios: { permissions: [{ key: 'NSCameraUsageDescription', domain: 'camera', defaultUsage: 'Scan barcodes and QR codes with the camera.' }] },
178
+ android: {
179
+ permissions: ['android.permission.CAMERA'],
180
+ gradleDeps: ['com.journeyapps:zxing-android-embedded:4.3.0'],
181
+ },
182
+ },
183
+
184
+ // ── speech (TTS + STT) — opt-in module; ONE coherent kit.speech, TWO honest capabilities ──
185
+ // ONE module (not split TTS-core / STT-opt-in): TTS-only apps are rare and a split fractures
186
+ // `kit.speech` across tiers. The module declares the STT perms (mic + speech-recognition); the
187
+ // CLI only stamps them when `speech` is active, and TTS (`speak`/`voices`) rides along perm-free.
188
+ // It advertises TWO handshake caps so the kit gates each concern honestly:
189
+ // `speech` → TTS (synthesis), always native on a shell.
190
+ // `speechRecognition` → STT (transcription), always native on a shell.
191
+ // iOS: AVSpeechSynthesizer (TTS, no dep) + SFSpeechRecognizer/AVAudioEngine (STT). Android:
192
+ // TextToSpeech + SpeechRecognizer — both plain Java → NO kotlin flag, NO gradle dep.
193
+ {
194
+ name: 'speech', group: 'speech',
195
+ capabilities: { speech: 'native', speechRecognition: 'native' },
196
+ ios: { permissions: [
197
+ { key: 'NSSpeechRecognitionUsageDescription', domain: 'speechRecognition', defaultUsage: 'Transcribe your speech to text.' },
198
+ { key: 'NSMicrophoneUsageDescription', domain: 'microphone', defaultUsage: 'Listen to your voice for speech-to-text.' },
199
+ ] },
200
+ android: { permissions: ['android.permission.RECORD_AUDIO'] },
155
201
  },
156
202
 
157
203
  // ── health (steps) — opt-in heavy module; FG live + BG via OS step store ──
@@ -204,7 +250,7 @@ export const MODULES: ModuleManifest[] = [
204
250
 
205
251
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
206
252
  * groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
207
- export const OPTIONAL_GROUPS = ['health', 'oauth'] as const;
253
+ export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech'] as const;
208
254
 
209
255
  /** Resolve the active capability map for the handshake from a set of active capability names. */
210
256
  export function buildCapabilityMap(
@@ -50,4 +50,8 @@ export const SHELL_CONFIG = {
50
50
  * un-provisioned build honestly reports 'none' (and a personal-team iOS build keeps `pushIos:false`). */
51
51
  pushIos: false,
52
52
  pushAndroid: false,
53
+ /** Optional backend URL that the shell POSTs the device token to NATIVELY as soon as it's acquired —
54
+ * `{ token, platform: 'ios'|'android' }`. Native HTTP avoids the WKWebView `app://` cross-origin/CORS
55
+ * wall, so a token reaches your server without any WebView fetch. Empty = the app handles sending. */
56
+ pushRegistrationUrl: '',
53
57
  };
@@ -10,8 +10,19 @@ let pendingPushTap: { data: Record<string, string> } | null = null;
10
10
  // (the cold-start-from-notification bug). So we gate delivery on the handshake.
11
11
  let pwaReady = false;
12
12
 
13
+ /** A pending native-side consumer of an inbound deep link (e.g. the Android OAuth flow waiting for
14
+ * the provider's `callbackScheme://…` redirect). Returns true if it CONSUMED the URL — then it's an
15
+ * internal callback, not an app deep link, and must NOT reach the PWA. Set by handlers-oauth. */
16
+ let deepLinkInterceptor: ((url: string) => boolean) | null = null;
17
+ export function setDeepLinkInterceptor(fn: ((url: string) => boolean) | null): void {
18
+ deepLinkInterceptor = fn;
19
+ }
20
+
13
21
  /** Called by the iOS delegate (cold start or while running) and Android intents. */
14
22
  export function onDeepLink(url: string): void {
23
+ // Give a native consumer (OAuth callback) first refusal — a matched OAuth redirect is internal
24
+ // plumbing, not an app deep link, so it's swallowed here and never forwarded to the PWA.
25
+ if (deepLinkInterceptor?.(url)) return;
15
26
  if (pwaReady) bridge.emit('deeplink.open', { url });
16
27
  else pendingDeepLink = url; // buffer until the PWA handshakes
17
28
  }
@@ -127,7 +127,10 @@ export function registerExtendedHandlers(): void {
127
127
  Utils.dispatchToMainThread(() => {
128
128
  UNUserNotificationCenter.currentNotificationCenter().setBadgeCountWithCompletionHandler?.(
129
129
  count,
130
- () => {}
130
+ // iOS silently ignores the badge unless notification authorization (incl. .badge) was granted —
131
+ // the completion's error is the ONLY signal, so DON'T swallow it (else the badge just never
132
+ // appears with no explanation). Resolve regardless (the JS call itself didn't fail).
133
+ (error: any) => { if (error) console.warn('[appwrap] setBadge ignored — notifications not authorized?', String(error)); }
131
134
  );
132
135
  resolve();
133
136
  });
@@ -57,13 +57,17 @@ function registerIosFs(): void {
57
57
  if (!fm().fileExistsAtPath(full)) throw err('NATIVE_ERROR', `No such file: ${path}`);
58
58
  const data = NSData.dataWithContentsOfFile(full);
59
59
  if (!data) throw err('NATIVE_ERROR', `Read failed: ${path}`);
60
- if (encoding === 'base64') return data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions);
61
- return NSString.alloc().initWithDataEncoding(data, 4 /* NSUTF8StringEncoding */) as unknown as string;
60
+ // String(...) FORCES a JS string. Without it the NSString proxy goes over the bridge and
61
+ // JSON-serializes to `{}` — `as unknown as string` is a compile-time no-op, not a conversion.
62
+ if (encoding === 'base64') return String(data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions));
63
+ return String(NSString.alloc().initWithDataEncoding(data, 4 /* NSUTF8StringEncoding */));
62
64
  });
63
65
 
64
- bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8', recursive }: any) => {
66
+ bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
65
67
  const full = iosResolve(path, dir);
66
- if (recursive) ensureParentIos(full);
68
+ // Always create the parent dir — writing to a sub-path (e.g. 'demo/note.txt') otherwise fails
69
+ // ("Write failed"), which is a footgun. fs.append already does this; match it.
70
+ ensureParentIos(full);
67
71
  const blob = encoding === 'base64'
68
72
  ? NSData.alloc().initWithBase64EncodedStringOptions(data, 0 as unknown as NSDataBase64DecodingOptions)
69
73
  : (NSString.stringWithString(String(data ?? '')) as any).dataUsingEncoding(4);
@@ -245,9 +249,10 @@ function registerAndroidFs(): void {
245
249
  return new java.lang.String(bytes, 'UTF-8').toString();
246
250
  });
247
251
 
248
- bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8', recursive }: any) => {
252
+ bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
249
253
  const file = androidFile(path, dir);
250
- if (recursive) file.getParentFile()?.mkdirs();
254
+ // Always create the parent dir (match fs.append + iOS) — writing to a sub-path otherwise fails.
255
+ file.getParentFile()?.mkdirs();
251
256
  writeAndroid(file, data, encoding, false);
252
257
  return { uri: android.net.Uri.fromFile(file).toString() };
253
258
  });
@@ -1,9 +1,11 @@
1
- import { Utils, isIOS } from '@nativescript/core';
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
+ import { setDeepLinkInterceptor } from './events';
3
4
 
4
5
  // AuthenticationServices globals (NS auto-links the framework via metadata when referenced).
5
6
  declare const ASWebAuthenticationSession: any;
6
7
  declare const ASWebAuthenticationPresentationContextProviding: any;
8
+ declare const android: any, androidx: any;
7
9
 
8
10
  const err = (code: string, message: string) => Object.assign(new Error(message), { code });
9
11
 
@@ -19,6 +21,7 @@ let activeProvider: any = null;
19
21
  * the auth URL + does token exchange; this only opens the browser and hands back the callback.
20
22
  */
21
23
  export function registerOAuthHandlers(): void {
24
+ if (isAndroid) { registerAndroid(); return; }
22
25
  if (!isIOS) return;
23
26
 
24
27
  bridge.register(
@@ -67,3 +70,89 @@ export function registerOAuthHandlers(): void {
67
70
  })
68
71
  );
69
72
  }
73
+
74
+ /**
75
+ * Android: launch the auth URL in a Chrome Custom Tab. Unlike iOS ASWebAuthenticationSession, a
76
+ * Custom Tab does NOT auto-close on the provider redirect — the redirect to `callbackScheme://…`
77
+ * re-launches our (singleTask) activity as a VIEW intent, which the shell's existing deep-link path
78
+ * (events.wireAndroidDeepLinks → onDeepLink) already surfaces. We register a deep-link interceptor
79
+ * that matches the pending callbackScheme, swallows that URL (it's OAuth plumbing, not an app deep
80
+ * link), and resolves the pending authorize() with the same { url } shape the iOS path returns.
81
+ *
82
+ * Cancel contract (matches iOS user-dismiss → CANCELLED): Custom Tabs gives no dismissal callback, so
83
+ * we infer cancellation on app.resume — if the app comes back to the foreground after the tab opened
84
+ * and NO matching redirect arrived within a short grace window, the user backed out → reject CANCELLED.
85
+ *
86
+ * DEVICE-UNVERIFIED (any-typed FFI, no device run this session): the CustomTabsIntent launch + the
87
+ * redirect→deep-link round-trip + the resume-based cancel inference. Compile-clean only.
88
+ */
89
+ function registerAndroid(): void {
90
+ // Single in-flight authorize at a time (mirrors the iOS single-session model).
91
+ let pending:
92
+ | { scheme: string; resolve: (v: any) => void; reject: (e: any) => void; settled: boolean; resumeOff: () => void }
93
+ | null = null;
94
+
95
+ const finish = (fn: () => void) => {
96
+ if (!pending || pending.settled) return;
97
+ pending.settled = true;
98
+ pending.resumeOff();
99
+ const p = pending;
100
+ pending = null;
101
+ fn.call(p);
102
+ };
103
+
104
+ // First refusal on every inbound deep link: consume the one that matches the pending callbackScheme.
105
+ setDeepLinkInterceptor((url: string): boolean => {
106
+ if (!pending || pending.settled) return false;
107
+ const scheme = pending.scheme;
108
+ if (!scheme || !String(url ?? '').toLowerCase().startsWith(scheme.toLowerCase() + ':')) return false;
109
+ finish(function (this: any) { this.resolve({ url: String(url) }); });
110
+ return true; // swallow — internal OAuth callback, not an app deep link
111
+ });
112
+
113
+ bridge.register(
114
+ 'oauth.authorize',
115
+ ({ url, callbackScheme, ephemeral }: { url: string; callbackScheme: string; ephemeral?: boolean }) =>
116
+ new Promise((resolve, reject) => {
117
+ const target = String(url ?? '');
118
+ const scheme = String(callbackScheme ?? '');
119
+ if (!target) { reject(err('NATIVE_ERROR', 'oauth.authorize: empty url')); return; }
120
+ if (!scheme) { reject(err('NATIVE_ERROR', 'oauth.authorize: empty callbackScheme')); return; }
121
+ if (pending && !pending.settled) { reject(err('NATIVE_ERROR', 'oauth.authorize: a flow is already in progress')); return; }
122
+
123
+ Utils.dispatchToMainThread(() => {
124
+ try {
125
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
126
+ if (!activity) { reject(err('NATIVE_ERROR', 'oauth.authorize: no foreground activity')); return; }
127
+
128
+ // Cancel inference: the tab launch will background us; the FIRST resume afterwards means
129
+ // the user is back in our app. If no redirect consumed the flow by then (+grace), it's a
130
+ // user-cancel. A small delay lets a redirect that arrives in the same resume win the race.
131
+ let launched = false;
132
+ const onResume = () => {
133
+ if (!launched || !pending || pending.settled) return;
134
+ setTimeout(() => finish(function (this: any) { this.reject(err('CANCELLED', 'oauth cancelled')); }), 400);
135
+ };
136
+ Application.on(Application.resumeEvent, onResume);
137
+ const resumeOff = () => Application.off(Application.resumeEvent, onResume);
138
+ pending = { scheme, resolve, reject, settled: false, resumeOff };
139
+
140
+ const builder = new androidx.browser.customtabs.CustomTabsIntent.Builder();
141
+ // `ephemeral` (fresh login) → don't share the persisted browser session. Custom Tabs has
142
+ // no per-launch incognito API; the closest honest knob is disabling the recents/share
143
+ // surface so the auth page isn't trivially resumable. Cookie isolation isn't guaranteed.
144
+ if (ephemeral) builder.setShareState(androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF);
145
+ const tabs = builder.build();
146
+ // No extra intent flags: the `callbackScheme://…` redirect re-launches our singleTask
147
+ // activity to the front (consumed by the deep-link interceptor), leaving the tab behind it.
148
+ tabs.launchUrl(activity, android.net.Uri.parse(target));
149
+ launched = true;
150
+ } catch (e: any) {
151
+ const fail = err('NATIVE_ERROR', e?.message ?? 'oauth failed to start');
152
+ if (pending && !pending.settled) finish(function (this: any) { this.reject(fail); });
153
+ else reject(fail); // failed before `pending` was wired
154
+ }
155
+ });
156
+ })
157
+ );
158
+ }
@@ -6,7 +6,7 @@ const err = (code: string, message: string) => Object.assign(new Error(message),
6
6
  const iosOnly = () => err('UNSUPPORTED', 'iOS only for now');
7
7
 
8
8
  /**
9
- * Extended-parity handlers: dialogs, reviews, storage.clear, geo watch, theme color,
9
+ * Extended-parity handlers: dialogs, storage.clear, geo watch, theme color,
10
10
  * motion, contacts, calendar, camera. iOS-first like handlers-extended.
11
11
  */
12
12
  export function registerParityHandlers(): void {
@@ -31,18 +31,8 @@ export function registerParityHandlers(): void {
31
31
  return idx >= 0 ? idx : null;
32
32
  });
33
33
 
34
- // ── reviews (SKStoreReviewController) ──────────────────────────────
35
- bridge.register('reviews.requestReview', () => {
36
- if (!isIOS) throw iosOnly();
37
- Utils.dispatchToMainThread(() => {
38
- const scene = UIApplication.sharedApplication.keyWindow?.windowScene;
39
- if (scene && SKStoreReviewController.requestReviewInWindowScene) {
40
- SKStoreReviewController.requestReviewInWindowScene(scene);
41
- } else {
42
- SKStoreReviewController.requestReview();
43
- }
44
- });
45
- });
34
+ // reviews → moved to the strippable `reviews` module (handlers-reviews.ts): its Android Play
35
+ // In-App Review path carries a gradle dep that must not bundle into builds without `reviews`.
46
36
 
47
37
  // ── storage.clear ──────────────────────────────────────────────────
48
38
  bridge.register('storage.clear', () => {
@@ -1,4 +1,4 @@
1
- import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
1
+ import { Application, Http, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { SHELL_CONFIG } from './config';
4
4
 
@@ -21,9 +21,32 @@ let cachedToken: string | null = null;
21
21
 
22
22
  interface PushToken { platform: 'apns' | 'fcm'; token: string; }
23
23
 
24
+ /** POST the device token to the app's configured backend NATIVELY (no WKWebView fetch → no app://
25
+ * cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured. */
26
+ function registerTokenWithBackend(platform: 'ios' | 'android', token: string): void {
27
+ const url = SHELL_CONFIG.pushRegistrationUrl;
28
+ if (!url) return;
29
+ // NativeScript Http (NOT the global fetch — unreliable in the NS runtime). Native HTTP, no CORS.
30
+ // Guarded: never let a backend-register hiccup break token resolution (this runs before resolve()).
31
+ try {
32
+ Http.request({
33
+ url, method: 'POST', headers: { 'Content-Type': 'application/json' },
34
+ content: JSON.stringify({ token, platform }),
35
+ })
36
+ .then((res) => console.log('[push] token registered with backend → ' + res.statusCode))
37
+ .catch((e: any) => console.log('[push] backend register failed: ' + (e?.message ?? e)));
38
+ } catch (e: any) {
39
+ console.log('[push] backend register threw: ' + (e?.message ?? e));
40
+ }
41
+ }
42
+
24
43
  /** iOS AppDelegate → APNs device token arrived. Resolve any pending register() + cache. */
25
44
  export function onApnsToken(hexToken: string): void {
26
45
  cachedToken = hexToken;
46
+ // Log the FULL token so it's readable headlessly (`appwrap logs ios`) to send a test push —
47
+ // mirrors the Android FCM boot-log. The token alone isn't a secret (you still need the APNs key).
48
+ console.log('[push] APNs token: ' + hexToken);
49
+ registerTokenWithBackend('ios', hexToken);
27
50
  if (pendingRegister) {
28
51
  pendingRegister.resolve({ platform: 'apns', token: hexToken });
29
52
  pendingRegister = null;
@@ -0,0 +1,73 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+
4
+ // SKStoreReviewController is declared globally in ios-frameworks.d.ts. `com` is the Android namespace.
5
+ declare const com: any;
6
+
7
+ /**
8
+ * In-app store review prompt — opt-in, strippable (its own handler file + group). Lives apart from
9
+ * the always-bundled parity/system handlers because the Android path references the Play In-App
10
+ * Review classes (`com.google.android.play:review`), a gradle dep that must NOT land in builds that
11
+ * don't enable `reviews` (missing-class crash / dead weight otherwise).
12
+ *
13
+ * iOS: SKStoreReviewController — the OS rate-limits display (may show nothing). Fire-and-forget.
14
+ * Android: Play In-App Review (ReviewManager). HONEST LIMIT — the dialog only actually surfaces for a
15
+ * Play-Store-track install; on a bare emulator / sideload the API resolves WITHOUT showing UI (same
16
+ * class as android-billing). We call the API and resolve gracefully; we never fabricate "shown".
17
+ *
18
+ * DEVICE-UNVERIFIED (any-typed FFI, no device run this session): the Android ReviewManager
19
+ * requestReviewFlow → launchReviewFlow task/listener chain. Compile-clean only.
20
+ */
21
+ export function registerReviewsHandlers(): void {
22
+ if (isIOS) registerIos();
23
+ else if (isAndroid) registerAndroid();
24
+ }
25
+
26
+ function registerIos(): void {
27
+ bridge.register('reviews.requestReview', () => {
28
+ Utils.dispatchToMainThread(() => {
29
+ const scene = UIApplication.sharedApplication.keyWindow?.windowScene;
30
+ if (scene && SKStoreReviewController.requestReviewInWindowScene) {
31
+ SKStoreReviewController.requestReviewInWindowScene(scene);
32
+ } else {
33
+ SKStoreReviewController.requestReview();
34
+ }
35
+ });
36
+ });
37
+ }
38
+
39
+ function registerAndroid(): void {
40
+ bridge.register('reviews.requestReview', () =>
41
+ new Promise<void>((resolve) => {
42
+ Utils.dispatchToMainThread(() => {
43
+ try {
44
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
45
+ if (!activity) { resolve(); return; }
46
+ const manager = com.google.android.play.core.review.ReviewManagerFactory.create(activity);
47
+ const request = manager.requestReviewFlow();
48
+ // requestReviewFlow() → Task<ReviewInfo>; on success launchReviewFlow() → Task<Void>.
49
+ // Both tasks COMPLETE even when the OS shows nothing (no Play track / rate-limited), so we
50
+ // resolve on completion regardless — never block, never fabricate a "shown" signal.
51
+ request.addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener({
52
+ onComplete(infoTask: any) {
53
+ if (!infoTask.isSuccessful()) { resolve(); return; }
54
+ try {
55
+ const flow = manager.launchReviewFlow(activity, infoTask.getResult());
56
+ flow.addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener({
57
+ onComplete() { resolve(); },
58
+ }));
59
+ } catch (e) {
60
+ console.warn('AppWrap: reviews.launchReviewFlow failed', e);
61
+ resolve();
62
+ }
63
+ },
64
+ }));
65
+ } catch (e) {
66
+ // Play services / review lib absent (e.g. non-GMS device) → honest no-op, don't reject.
67
+ console.warn('AppWrap: reviews.requestReview unavailable', e);
68
+ resolve();
69
+ }
70
+ });
71
+ })
72
+ );
73
+ }
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrap-shell",
3
3
  "main": "app/app.ts",
4
- "version": "0.21.0",
4
+ "version": "0.28.0",
5
5
  "private": true,
6
6
  "scripts": {
7
7
  "dev:ios": "ns run ios",
package/src/cli.ts CHANGED
@@ -172,6 +172,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
172
172
  const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
173
173
  health: { file: './handlers-health', fn: 'registerHealthHandlers' },
174
174
  oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
175
+ reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
176
+ scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
177
+ speech: { file: './handlers-speech', fn: 'registerSpeechHandlers' },
175
178
  };
176
179
 
177
180
  /** Generate the two composition artifacts in the wrapper: the active capability list (drives the
@@ -366,6 +369,7 @@ export const SHELL_CONFIG = {
366
369
  openNewWindowsInBrowser: ${JSON.stringify(cfg.openNewWindowsInBrowser ?? false)},
367
370
  pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
368
371
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
372
+ pushRegistrationUrl: ${JSON.stringify(cfg.push?.registrationUrl ?? '')},
369
373
  };
370
374
  `;
371
375
  writeFileSync(join(outDir, 'app/shell/config.ts'), content);
@@ -434,7 +438,15 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
434
438
 
435
439
  function stampTeamId(outDir: string, cfg: AppwrapConfig): void {
436
440
  const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
437
- if (!existsSync(xcconfig) || !cfg.teamId) return;
441
+ // A placeholder/empty teamId silently produces an unsignable build (DEVELOPMENT_TEAM = YOUR_APPLE_TEAM_ID
442
+ // → xcodebuild "No Account for Team"). Warn loudly rather than let a long build fail cryptically.
443
+ if (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId)) {
444
+ console.warn(`⚠ teamId is unset/placeholder ("${cfg.teamId ?? ''}") — device builds won't sign.\n` +
445
+ ' Set it to your Apple Team ID in appwrap.config (Xcode → Settings → Accounts shows it; ' +
446
+ 'Individual = paid, Personal Team = free).');
447
+ return;
448
+ }
449
+ if (!existsSync(xcconfig)) return;
438
450
  let src = readFileSync(xcconfig, 'utf8');
439
451
  src = /DEVELOPMENT_TEAM\s*=/.test(src)
440
452
  ? src.replace(/DEVELOPMENT_TEAM\s*=\s*[^;\n]*;?/, `DEVELOPMENT_TEAM = ${cfg.teamId};`)
@@ -1147,7 +1159,21 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1147
1159
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1148
1160
  stampShellConfig(outDir, { ...cfg, debug: true });
1149
1161
  console.log('▶ ns build ios --for-device (debug: keep-awake + inspector on)');
1150
- execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1162
+ try {
1163
+ execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1164
+ } catch (e) {
1165
+ // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
1166
+ console.error(
1167
+ '\n✖ Device build failed — if the errors above mention signing:\n' +
1168
+ ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
1169
+ ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
1170
+ ' unique string and re-deploy.\n' +
1171
+ ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
1172
+ ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
1173
+ ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
1174
+ );
1175
+ process.exit(1);
1176
+ }
1151
1177
 
1152
1178
  const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
1153
1179
  const ipa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
package/src/config.ts CHANGED
@@ -139,6 +139,10 @@ export interface AppwrapConfig {
139
139
  apsEnvironment?: 'development' | 'production';
140
140
  /** Path (relative to the PWA project) to the Firebase `google-services.json` for Android FCM. */
141
141
  googleServicesJson?: string;
142
+ /** Optional backend URL the shell POSTs the device token to NATIVELY on acquisition (`{token,
143
+ * platform}`). Native HTTP sidesteps the WKWebView app:// cross-origin wall — the token reaches
144
+ * your server with no WebView fetch. Your backend stores it + sends pushes (provider-agnostic). */
145
+ registrationUrl?: string;
142
146
  };
143
147
  }
144
148